chore: merge main and release 3.7.3 bugfix bundle

Resolve conflicts with deep-reform (PR #452) while keeping bugfix branch
changes: sidebar visibility, verbose logging, device login name, and
notification archive. Bump to 3.7.3 with What's New release notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 20:12:58 +09:30
93 changed files with 2628 additions and 1368 deletions
@@ -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();
};
},
};
+199 -58
View File
@@ -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,24 @@ export const isFirefox =
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
!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> {
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 escapedUrl = escJsSingleQuoted(url);
const escapedOrigin = escJsSingleQuoted(pageOrigin);
script.textContent = `
(function() {
@@ -375,19 +469,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) {
@@ -449,23 +544,22 @@ export async function extractPDFText(url: string): Promise<string> {
if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
const pageOrigin = trustedPageOrigin();
const escapedOrigin = escJsSingleQuoted(pageOrigin);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
const escapedUrl = url
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
const escapedUrl = escJsSingleQuoted(url);
script.textContent = `
(function() {
const requestId = '${requestId}';
const pageOrigin = '${escapedOrigin}';
const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
@@ -485,7 +579,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 +600,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
}, '*');
}, pageOrigin);
return;
}
@@ -542,21 +636,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 +659,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Network error fetching PDF'
}, '*');
}, pageOrigin);
};
xhr.ontimeout = function() {
@@ -573,7 +667,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Timeout fetching PDF'
}, '*');
}, pageOrigin);
};
xhr.timeout = 30000;
@@ -583,13 +677,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 +741,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 +781,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 +904,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 +923,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 +938,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,25 +1040,35 @@ 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___']",
interface WeightingTabContext {
assessmentID?: string;
autoWeight?: number;
override?: number | string;
weightingUnavailable: boolean;
statusNote: string;
}
async function resolveWeightingTabContext(api: any): Promise<WeightingTabContext> {
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)
: undefined;
const rawWeight = entry?.weight;
const weightingUnavailable = rawWeight === "N/A";
const autoWeight =
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
? rawWeight
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
@@ -966,6 +1081,22 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
? "No weighting was found in the marksheet. Set one manually."
: "Overrides the auto-detected value.";
return {
assessmentID,
autoWeight,
override,
weightingUnavailable,
statusNote,
};
}
function renderWeightingTabHtml(
sheet: HTMLElement,
context: WeightingTabContext,
) {
const { assessmentID, autoWeight, override, weightingUnavailable, statusNote } =
context;
sheet.innerHTML = `
<style>
#betterseqta-weight-override::placeholder {
@@ -1010,9 +1141,13 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
</div>
`;
}
if (!assessmentID) return;
function attachWeightingInputHandlers(
sheet: HTMLElement,
api: any,
assessmentID: string,
) {
const input = sheet.querySelector(
"#betterseqta-weight-override",
) as HTMLInputElement;
@@ -1022,20 +1157,17 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const save = () => {
const raw = input.value.trim();
if (raw === "") {
const result = saveWeightingOverride(api, assessmentID, "");
if (!result.ok) return;
input.style.borderColor = "rgba(128,128,128,0.3)";
} else {
const result = saveWeightingOverride(api, assessmentID, raw);
if (!result.ok) {
const result = saveWeightingOverride(api, assessmentID, raw);
if (!result.ok) {
if (raw !== "") {
input.style.borderColor = "rgba(255,80,80,0.6)";
statusEl.textContent = result.error ?? "Invalid. Must be 0 or greater";
statusEl.style.color = "rgba(255,80,80,0.8)";
return;
}
input.style.borderColor = "rgba(128,128,128,0.3)";
return;
}
input.style.borderColor = "rgba(128,128,128,0.3)";
statusEl.textContent = "Saved";
statusEl.style.color = "";
setTimeout(() => (statusEl.textContent = ""), 2000);
@@ -1055,6 +1187,13 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
});
}
async function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const context = await resolveWeightingTabContext(api);
renderWeightingTabHtml(sheet, context);
if (!context.assessmentID) return;
attachWeightingInputHandlers(sheet, api, context.assessmentID);
}
export function injectWeightingsTab(api: any) {
const tabList = document.querySelector(
'[class*="TabSet__tabs___"]',
@@ -1093,7 +1232,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 +1246,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";
@@ -61,11 +61,14 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
const gridItem = document.createElement("li");
gridItem.className = "item";
gridItem.classList.add(OVERVIEW_MENU_CLASS);
gridItem.dataset.betterseqta = "true";
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.firstChild);
let loadRequestId = 0;
const menuObserver = new MutationObserver(() => {
ensureOverviewMenuPosition(menu, gridItem);
});
@@ -77,11 +80,24 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
const clickHandler = (e: Event) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
void loadGridView();
};
gridItem.addEventListener("click", clickHandler);
gridItem.addEventListener("click", clickHandler, true);
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 +114,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 +126,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 +151,11 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
}
return () => {
loadRequestId += 1;
window.removeEventListener("popstate", popstateHandler);
menuObserver.disconnect();
gridItem.removeEventListener("click", clickHandler);
gridItem.removeEventListener("click", clickHandler, true);
teardownOverviewUi();
gridItem.remove();
};
},
@@ -67,7 +67,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;
@@ -202,7 +202,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";
}
@@ -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();
@@ -5,7 +5,7 @@
import { circOut, quintOut } from 'svelte/easing';
import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types';
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import { createSearchIndexes, applyDynamicIndexDelta, performSearch as doSearch, type DynamicItemsUpdatedDetail } from '../search/searchUtils';
import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions';
@@ -130,7 +130,31 @@
window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = () => {
const itemsUpdatedHandler = (event: Event) => {
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();
performSearch();
};
@@ -176,29 +200,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
@@ -215,7 +215,7 @@ const staticCommands: StaticCommandItem[] = [
code: 'KeyM',
keyCode: 77,
altKey: true
}, "*");
}, location.origin);
},
keywords: ["compose", "message", "dm", "direct message", "new message"],
priority: 3,
@@ -287,10 +287,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const title = document.querySelector("#title");
if (title) {
mountSearchBar(title, api, appRef);
void mountSearchBar(title, api, appRef);
} else {
const titleElement = await waitForElm("#title", true, 100, 60);
mountSearchBar(titleElement, api, appRef);
void mountSearchBar(titleElement, api, appRef);
}
return () => {
@@ -1,11 +1,10 @@
import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill";
export function mountSearchBar(
export async function mountSearchBar(
titleElement: Element,
api: any,
appRef: {
@@ -37,6 +36,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";
@@ -94,102 +128,9 @@ export function mountSearchBar(
}
};
const updateProgressDisplay = () => {
const indexingStoppedThisTick = indexingJustStoppedFlag;
indexingJustStoppedFlag = false;
const active = isIndexing && totalJobs > 0;
// Stray pulses (missing total, 0 completed, etc.) used to hit the idle
// branch and call clearDoneFlashTimer(), killing the Done! hold/fade.
if (doneFlashTimer !== null || doneFadeTimer !== null) {
if (!active) {
return;
}
clearDoneFlashTimer();
}
const completionEligible =
ranIndexingCycle &&
!active &&
totalJobs > 0 &&
(completedJobs >= totalJobs || indexingStoppedThisTick);
if (active) {
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-rough-complete");
progressText.classList.remove(
"is-rough",
"is-fading-done",
"is-done-message",
);
const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.classList.add("is-active");
searchAnchor.classList.add("is-indexing");
searchButton.classList.add("is-indexing");
if (indexingStatus) {
progressText.textContent = `${truncateStatus(indexingStatus)} · ${percentage}%`;
} else {
progressText.textContent = `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
}
progressText.classList.add("is-active");
return;
}
if (completionEligible) {
// Duplicate end-of-run ticks must not reschedule hold/fade timers
if (doneFlashTimer !== null || doneFadeTimer !== null) {
return;
}
const rough =
indexingStatus != null && statusLooksRough(indexingStatus);
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove("is-fading-done");
progressText.textContent = rough ? truncateStatus(indexingStatus!, 52) : "Done!";
if (rough) {
progressText.classList.add("is-rough");
progressBarWrapper.classList.add("is-rough-complete");
} else {
progressText.classList.remove("is-rough");
progressBarWrapper.classList.remove("is-rough-complete");
}
progressText.classList.add("is-active", "is-done-message");
doneFlashTimer = setTimeout(() => {
doneFlashTimer = null;
progressText.classList.add("is-fading-done");
doneFadeTimer = setTimeout(() => {
doneFadeTimer = null;
ranIndexingCycle = false;
indexingStatus = null;
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressText.textContent = "";
}, DONE_FADE_MS);
}, DONE_HOLD_MS);
return;
}
const resetIdleProgressUi = () => {
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
progressBarWrapper.classList.remove("is-active", "is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
@@ -204,6 +145,75 @@ export function mountSearchBar(
indexingStatus = null;
};
const showActiveIndexingUi = (percentage: number) => {
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-rough-complete");
progressText.classList.remove("is-rough", "is-fading-done", "is-done-message");
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.classList.add("is-active");
searchAnchor.classList.add("is-indexing");
searchButton.classList.add("is-indexing");
progressText.textContent = indexingStatus
? `${truncateStatus(indexingStatus)} · ${percentage}%`
: `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
progressText.classList.add("is-active");
};
const scheduleCompletionFlash = (rough: boolean) => {
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove("is-fading-done");
progressText.textContent = rough ? truncateStatus(indexingStatus!, 52) : "Done!";
progressText.classList.toggle("is-rough", rough);
progressBarWrapper.classList.toggle("is-rough-complete", rough);
progressText.classList.add("is-active", "is-done-message");
doneFlashTimer = setTimeout(() => {
doneFlashTimer = null;
progressText.classList.add("is-fading-done");
doneFadeTimer = setTimeout(() => {
doneFadeTimer = null;
resetIdleProgressUi();
}, DONE_FADE_MS);
}, DONE_HOLD_MS);
};
const updateProgressDisplay = () => {
const indexingStoppedThisTick = indexingJustStoppedFlag;
indexingJustStoppedFlag = false;
const active = isIndexing && totalJobs > 0;
// Stray pulses (missing total, 0 completed, etc.) used to hit the idle
// branch and call clearDoneFlashTimer(), killing the Done! hold/fade.
if (doneFlashTimer !== null || doneFadeTimer !== null) {
if (!active) return;
clearDoneFlashTimer();
}
const completionEligible =
ranIndexingCycle &&
!active &&
totalJobs > 0 &&
(completedJobs >= totalJobs || indexingStoppedThisTick);
if (active) {
showActiveIndexingUi(Math.round((completedJobs / totalJobs) * 100));
return;
}
if (completionEligible) {
if (doneFlashTimer !== null || doneFadeTimer !== null) return;
const rough = indexingStatus != null && statusLooksRough(indexingStatus);
scheduleCompletionFlash(rough);
return;
}
resetIdleProgressUi();
};
// Listen for indexing progress events
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing, status } = event.detail as {
@@ -234,14 +244,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();
@@ -274,6 +280,7 @@ export function mountSearchBar(
});
try {
const { default: renderSvelte } = await import("@/interface/main");
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst,
@@ -184,6 +184,56 @@ 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> {
try {
const s = await getStore(store, "readwrite");
@@ -1,10 +1,10 @@
import { clear, get, getAll, put, remove, resetDatabase } from "./db";
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db";
import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents";
import { decorateIndexItems } 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";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
@@ -90,12 +90,64 @@ function shouldRun(job: Job, lastRun?: number): boolean {
}
function getLastRunMeta(jobId: string): Promise<number | undefined> {
return getAll(META_STORE).then((metaItems) => {
const match = metaItems.find((m: any) => m.jobId === jobId);
return match?.lastRun;
return get(META_STORE, jobId).then((rec) => rec?.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> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
}
@@ -221,6 +273,7 @@ export async function runIndexing(): Promise<void> {
startHeartbeat();
verboseDebug("%c[Indexer] Starting indexing...", "color: green");
try {
const jobIds = Object.keys(jobs);
let completedJobs = 0;
const totalSteps = jobIds.length + 1;
@@ -255,14 +308,7 @@ export async function runIndexing(): Promise<void> {
await getAll(storeId ?? jobId);
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
const targetStore = storeId ?? jobId;
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)));
await diffAndStoreItems(targetStore, items);
};
const addItem = async (item: IndexItem, storeId?: string) => {
const targetStore = storeId ?? jobId;
@@ -321,6 +367,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) {
verboseDebug(
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
@@ -435,38 +492,17 @@ export async function runIndexing(): Promise<void> {
);
}
stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems();
// 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;
}
});
const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores);
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(new Event("dynamic-items-updated"));
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: { fullRebuild: true },
}),
);
} finally {
stopHeartbeat();
}
}
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
@@ -1,5 +1,5 @@
import type { IndexItem } from "./types";
import { put, getAll } from "./db";
import { getAll, put } from "./db";
import {
buildIndexItem,
extractTextFromValue,
@@ -8,10 +8,8 @@ import {
} from "./extract";
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import { loadAllStoredItems } from "./indexer";
import { loadDynamicItems } from "../utils/dynamicItems";
import { renderComponentMap } from "./renderComponents";
import { jobs } from "./jobs";
import { mergeDynamicItems } from "../utils/dynamicItems";
import { decorateIndexItems } from "./renderComponents";
/**
* Passive network observer.
@@ -42,6 +40,8 @@ const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
let installed = false;
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
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 {
return installed;
@@ -387,6 +387,7 @@ async function persistItems(items: IndexItem[]): Promise<void> {
for (const item of items) {
try {
await put(STORE_ID, item, item.id);
pendingChangedItems.set(item.id, item);
} catch (e) {
console.warn(
`[Passive Observer] Failed to persist item ${item.id}:`,
@@ -410,38 +411,20 @@ function scheduleFlush() {
}
async function flushDynamicItems(): Promise<void> {
if (pendingChangedItems.size === 0) return;
const rawChanged = Array.from(pendingChangedItems.values());
pendingChangedItems.clear();
try {
const all = await loadAllStoredItems();
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);
const decorated = decorateIndexItems(rawChanged);
mergeDynamicItems(decorated);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
incremental: true,
jobId: STORE_ID,
changedItems: decorated,
streaming: false,
},
}),
@@ -3,6 +3,8 @@ import AssessmentItem from "../components/items/AssessmentItem.svelte";
import ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte";
import GenericItem from "../components/items/GenericItem.svelte";
import type { IndexItem } from "./types";
import { jobs } from "./jobs";
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as typeof SvelteComponent,
@@ -22,3 +24,37 @@ export const renderComponentMap: Record<string, typeof SvelteComponent> = {
goal: 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;
}
});
}
@@ -54,6 +54,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");
@@ -132,6 +132,7 @@ export async function hybridSearch(
bm25Results: CombinedResult[],
query: string,
options: HybridSearchOptions = {},
precomputedVectorResults?: VectorSearchResult[],
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
@@ -146,9 +147,10 @@ export async function hybridSearch(
if (trimmedQuery.length > 2) {
try {
// Get more vector results than BM25 results to ensure coverage
// This allows us to find semantic matches that BM25 might have missed
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
const vectorTopK = opts.bm25TopK * 2;
const vectorSearchResults =
precomputedVectorResults ??
(await searchVectors(trimmedQuery, vectorTopK));
// Create a map of item ID to vector similarity
const vectorMap = new Map<string, number>();
@@ -242,20 +244,33 @@ 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);
// If query is too short, skip vector expansion
if (trimmedQuery.length <= 2) {
return rerankedBm25;
return hybridSearch(bm25Results, query, options);
}
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
// noise (and is the main reason results "flicker" between adjacent
// keystrokes). Keep semantic recall for longer queries.
@@ -263,15 +278,6 @@ export async function hybridSearchWithExpansion(
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
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
const vectorOnlyResults: CombinedResult[] = [];
@@ -298,6 +304,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;
@@ -102,6 +102,94 @@ 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() {
clearSearchCache();
const commands = getStaticCommands();
@@ -119,49 +207,9 @@ export function createSearchIndexes() {
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 {
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
dynamicContentFuse: new Fuse(
dynamicItems,
dynamicOptions,
) as Fuse<IndexItem>,
dynamicContentFuse: createDynamicContentFuse(dynamicItems),
commands,
dynamicItems,
};
@@ -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: '',
};
}
}
@@ -16,12 +16,29 @@ export interface DynamicContentItem {
let dynamicItems: IndexItem[] = [];
/**
* Loads a new set of dynamic items.
* Loads a new set of dynamic items (full replace).
*/
export function loadDynamicItems(items: IndexItem[]) {
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.
*/
@@ -6,6 +6,14 @@ export interface ParsedHotkey {
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 {
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"
@@ -310,8 +310,6 @@
y: { type: "tween", duration: 600, easing: cubicInOut },
height: { type: "tween", duration: 600, easing: cubicInOut },
},
},
@@ -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()) &&
+15 -3
View File
@@ -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;
+134 -52
View File
@@ -67,6 +67,116 @@ 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 MESSAGE_LIST_ITEM_SELECTOR =
"[class*='MessageList__MessageList___'] ol > li[data-message]";
function getMessageListItems(): NodeListOf<Element> {
return document.querySelectorAll(MESSAGE_LIST_ITEM_SELECTOR);
}
function clearMessageListBadges(
messageItems: NodeListOf<Element>,
restoreSubjectPlain: (subject: Element) => void,
): void {
for (const li of messageItems) {
const subject = li.querySelector("[class*='MessageList__subject___']");
if (
subject &&
(subject.querySelector(".bsplus-msg-badges") ||
subject.querySelector(".bsplus-subject-text"))
) {
restoreSubjectPlain(subject);
} else {
li.querySelector(".bsplus-msg-badges")?.remove();
}
}
}
function getAssignedFolderIds(
msgId: string,
assignments: Record<string, string[]>,
): string[] {
return Object.entries(assignments)
.filter(([, messageIds]) => messageIds.includes(msgId))
.map(([folderId]) => folderId);
}
function ensureMessageBadgeContainer(li: Element): HTMLElement {
const existing = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
if (existing) return existing;
const badgeContainer = document.createElement("div");
badgeContainer.className = "bsplus-msg-badges";
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject) {
if (!subject.querySelector(".bsplus-subject-text")) {
const textWrap = document.createElement("span");
textWrap.className = "bsplus-subject-text";
textWrap.textContent = subject.textContent;
subject.textContent = "";
subject.appendChild(textWrap);
}
subject.appendChild(badgeContainer);
} else {
li.appendChild(badgeContainer);
}
return badgeContainer;
}
function createFolderBadge(
folder: Folder,
onFilter: (folderId: string) => void,
): HTMLElement {
const badge = document.createElement("span");
badge.className = "bsplus-msg-badge";
badge.style.background = folder.color;
appendFolderBadgeContent(badge, folder);
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
onFilter(folder.id);
});
return badge;
}
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
id: "messageFolders",
name: "Message Folders",
@@ -95,7 +205,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 +409,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 +733,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 +836,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);
@@ -760,66 +871,37 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
};
const applyBadges = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
const messageItems = getMessageListItems();
if (!shouldShowBadgesInList()) {
for (const li of messageItems) {
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject && (subject.querySelector(".bsplus-msg-badges") || subject.querySelector(".bsplus-subject-text"))) {
restoreSubjectPlain(subject);
} else {
li.querySelector(".bsplus-msg-badges")?.remove();
}
}
clearMessageListBadges(messageItems, restoreSubjectPlain);
return;
}
const folders = getFolders();
const assignments = getAssignments();
const selectFolder = (folderId: string) => {
activeFolderId = folderId;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
};
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (!msgId) continue;
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
const folderIds: string[] = [];
for (const [fId, mIds] of Object.entries(assignments)) {
if (mIds.includes(msgId)) folderIds.push(fId);
}
const folderIds = getAssignedFolderIds(msgId, assignments);
if (folderIds.length === 0) {
badgeContainer?.remove();
li.querySelector(".bsplus-msg-badges")?.remove();
continue;
}
if (!badgeContainer) {
badgeContainer = document.createElement("div");
badgeContainer.className = "bsplus-msg-badges";
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject) {
if (!subject.querySelector(".bsplus-subject-text")) {
const textWrap = document.createElement("span");
textWrap.className = "bsplus-subject-text";
textWrap.textContent = subject.textContent;
subject.textContent = "";
subject.appendChild(textWrap);
}
subject.appendChild(badgeContainer);
} else {
li.appendChild(badgeContainer);
}
}
badgeContainer.innerHTML = "";
for (const fId of folderIds) {
const folder = folders.find((f) => f.id === fId);
const badgeContainer = ensureMessageBadgeContainer(li);
badgeContainer.replaceChildren();
for (const folderId of folderIds) {
const folder = folders.find((f) => f.id === folderId);
if (!folder) continue;
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}`;
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
badgeContainer.appendChild(badge);
badgeContainer.appendChild(createFolderBadge(folder, selectFolder));
}
}
};
+7 -6
View File
@@ -1,7 +1,5 @@
import renderSvelte from "@/interface/main";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { unmount } from "svelte";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let themeCreatorSvelteApp: any = null;
@@ -11,10 +9,15 @@ let themeCreatorSvelteApp: any = null;
* @param themeID - The ID of the theme to load in the Theme Creator
* @returns void
*/
export function OpenThemeCreator(themeID: string = "") {
export async function OpenThemeCreator(themeID: string = "") {
CloseThemeCreator();
// Only store original color if we're not editing an existing theme
const [{ default: renderSvelte }, { default: themeCreator }] =
await Promise.all([
import("@/interface/main"),
import("@/interface/pages/themeCreator.svelte"),
]);
localStorage.setItem("themeCreatorOpen", "true");
if (!themeID) {
localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
@@ -34,7 +37,6 @@ export function OpenThemeCreator(themeID: string = "") {
const mainContent = document.querySelector("#container") as HTMLDivElement;
if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
// close button
const closeButton = document.createElement("button");
closeButton.classList.add("themeCloseButton");
closeButton.textContent = "×";
@@ -92,7 +94,6 @@ export function OpenThemeCreator(themeID: string = "") {
* @returns void
*/
export function CloseThemeCreator() {
// Remove the stored flag
localStorage.removeItem("themeCreatorOpen");
const themeCreator = document.getElementById("themeCreator");
+6 -4
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 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,
@@ -603,8 +603,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);
@@ -732,10 +736,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 }> };
+4
View File
@@ -88,6 +88,8 @@ async function handleTimetable(): Promise<void> {
}
function handleTimetableZoom(): void {
if (document.querySelector(".timetable-zoom-controls")) return;
verboseLog("Initializing timetable zoom controls");
// Create zoom controls
@@ -136,6 +138,8 @@ function handleTimetableZoom(): void {
}
function handleTimetableAssessmentHide(): void {
if (document.querySelector(".timetable-hide-controls")) return;
const hideControls = document.createElement("div");
hideControls.className = "timetable-hide-controls";