mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
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:
@@ -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,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 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"
|
||||
|
||||
@@ -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()) &&
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }> };
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user