From 678a958351ec71a14a65d75b3ebe334f5065f047 Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Mon, 4 May 2026 18:32:32 +0800 Subject: [PATCH] assessmentsAverage: Add ability to override/set weighting per assessment. --- .../built-in/assessmentsAverage/index.ts | 265 ++++++++------- .../built-in/assessmentsAverage/utils.ts | 310 +++++++++++++++++- 2 files changed, 434 insertions(+), 141 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index ed7140f1..c99ffad1 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -12,6 +12,7 @@ import { clearStuck, getClassByPattern, initStorage, + injectWeightingsTab, letterToNumber, parseAssessments, processAssessments, @@ -20,6 +21,7 @@ import { interface weightingsStorage { weightings: Record; assessments: Record; + weightingOverrides: Record; } const settings = defineSettings({ @@ -37,6 +39,8 @@ class AssessmentsAveragePluginClass extends BasePlugin { const instance = new AssessmentsAveragePluginClass(); +let overrideListenerController: AbortController | null = null; + const assessmentsAveragePlugin: Plugin = { id: "assessments-average", name: "Assessment Averages", @@ -58,143 +62,150 @@ const assessmentsAveragePlugin: Plugin = { ); await parseAssessments(api); - - const sampleAssessmentItem = document.querySelector( - "[class*='AssessmentItem__AssessmentItem___']", + await renderSubjectAverage(api); + overrideListenerController?.abort(); + overrideListenerController = new AbortController(); + document.addEventListener( + "betterseqta:overrideChanged", + () => renderSubjectAverage(api), + { signal: overrideListenerController.signal }, ); - if (!sampleAssessmentItem) return; - - const assessmentItemClass = - Array.from(sampleAssessmentItem.classList).find((c) => - c.startsWith("AssessmentItem__AssessmentItem___"), - ) || ""; - - const metaContainerClass = getClassByPattern( - sampleAssessmentItem, - "AssessmentItem__metaContainer___", - ); - const metaClass = getClassByPattern( - sampleAssessmentItem, - "AssessmentItem__meta___", - ); - const simpleResultClass = getClassByPattern( - sampleAssessmentItem, - "AssessmentItem__simpleResult___", - ); - const titleClass = getClassByPattern( - sampleAssessmentItem, - "AssessmentItem__title___", - ); - - const thermoscoreElement = document.querySelector( - "[class*='Thermoscore__Thermoscore___']", - ); - if (!thermoscoreElement) return; - - const thermoscoreClass = - Array.from(thermoscoreElement.classList).find((c) => - c.startsWith("Thermoscore__Thermoscore___"), - ) || ""; - const fillClass = getClassByPattern( - thermoscoreElement, - "Thermoscore__fill___", - ); - const textClass = getClassByPattern( - thermoscoreElement, - "Thermoscore__text___", - ); - - const assessmentsList = document.querySelector( - "#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']", - ); - if (!assessmentsList) return; - - const state = await ReactFiber.find( - "[class*='AssessmentList__items___']", - ).getState(); - const marks = state["marks"]; - if (!marks || !marks.length) return; - - const assessmentItems = Array.from( - assessmentsList.querySelectorAll( - `[class*='AssessmentItem__AssessmentItem___']`, - ), - ).filter( - (item) => - !item - .querySelector(`[class*='AssessmentItem__title___']`) - ?.textContent?.includes("Subject Average"), - ); - - const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = - await processAssessments(api, assessmentItems); - - if (!count || totalWeight === 0) return; - - const avg = weightedTotal / totalWeight; - const rounded = Math.ceil(avg / 5) * 5; - const numberToLetter = Object.entries(letterToNumber).reduce( - (acc, [k, v]) => { - acc[v] = k; - return acc; - }, - {} as Record, - ); - - const letterAvg = numberToLetter[rounded] ?? "N/A"; - const display = api.settings.lettergrade - ? letterAvg - : `${avg.toFixed(2)}%`; - - const existing = assessmentsList.querySelector( - `[class*='AssessmentItem__title___']`, - ); - if (existing?.textContent === "Subject Average") return; - - let warningHTML = ""; - if (hasInaccurateWeighting) { - warningHTML = /* html */ ` -
- ⚠ Some weightings unavailable -
- `; - } - - assessmentsList.insertBefore( - stringToHTML(/* html */ ` -
-
-
-
-
Subject Average
- ${warningHTML} -
-
-
-
-
-
${display}
-
-
-
- `).firstChild!, - assessmentsList.firstChild, - ); - - applySubjectColourToOverallResult(); - - const observer = new MutationObserver(() => { - applySubjectColourToOverallResult(); - }); const wrapper = document.querySelector(".assessmentsWrapper"); if (wrapper) { + const observer = new MutationObserver(() => { + applySubjectColourToOverallResult(); + }); observer.observe(wrapper, { childList: true, subtree: true }); setTimeout(() => observer.disconnect(), 10000); } }); + api.seqta.onMount("[class*='SelectedAssessment__']", () => { + injectWeightingsTab(api); + }); }, }; +let renderInFlight = false; +async function renderSubjectAverage(api: any) { + if (renderInFlight) return; + renderInFlight = true; + + try { + const assessmentsList = document.querySelector( + "#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']", + ); + if (!assessmentsList) return; + + // Remove existing subject average before re-rendering + Array.from( + assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`), + ) + .find((el) => el.textContent === "Subject Average") + ?.closest("[class*='AssessmentItem__AssessmentItem___']") + ?.remove(); + + const sampleAssessmentItem = document.querySelector( + "[class*='AssessmentItem__AssessmentItem___']", + ); + if (!sampleAssessmentItem) return; + const assessmentItemClass = + Array.from(sampleAssessmentItem.classList).find((c) => + c.startsWith("AssessmentItem__AssessmentItem___"), + ) || ""; + const metaContainerClass = getClassByPattern( + sampleAssessmentItem, + "AssessmentItem__metaContainer___", + ); + const metaClass = getClassByPattern( + sampleAssessmentItem, + "AssessmentItem__meta___", + ); + const simpleResultClass = getClassByPattern( + sampleAssessmentItem, + "AssessmentItem__simpleResult___", + ); + const titleClass = getClassByPattern( + sampleAssessmentItem, + "AssessmentItem__title___", + ); + const thermoscoreElement = document.querySelector( + "[class*='Thermoscore__Thermoscore___']", + ); + if (!thermoscoreElement) return; + const thermoscoreClass = + Array.from(thermoscoreElement.classList).find((c) => + c.startsWith("Thermoscore__Thermoscore___"), + ) || ""; + const fillClass = getClassByPattern( + thermoscoreElement, + "Thermoscore__fill___", + ); + const textClass = getClassByPattern( + thermoscoreElement, + "Thermoscore__text___", + ); + const state = await ReactFiber.find( + "[class*='AssessmentList__items___']", + ).getState(); + const marks = state["marks"]; + if (!marks || !marks.length) return; + const assessmentItems = Array.from( + assessmentsList.querySelectorAll( + `[class*='AssessmentItem__AssessmentItem___']`, + ), + ).filter( + (item) => + !item + .querySelector(`[class*='AssessmentItem__title___']`) + ?.textContent?.includes("Subject Average"), + ); + const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = + await processAssessments(api, assessmentItems); + if (!count || totalWeight === 0) return; + const avg = weightedTotal / totalWeight; + const rounded = Math.ceil(avg / 5) * 5; + const numberToLetter = Object.entries(letterToNumber).reduce( + (acc, [k, v]) => { + acc[v] = k; + return acc; + }, + {} as Record, + ); + const letterAvg = numberToLetter[rounded] ?? "N/A"; + const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`; + let warningHTML = ""; + if (hasInaccurateWeighting) { + warningHTML = /* html */ ` +
+ ⚠ Some weightings unavailable +
+ `; + } + assessmentsList.insertBefore( + stringToHTML(/* html */ ` +
+
+
+
+
Subject Average
+ ${warningHTML} +
+
+
+
+
+
${display}
+
+
+
+ `).firstChild!, + assessmentsList.firstChild, + ); + applySubjectColourToOverallResult(); + } finally { + renderInFlight = false; + } +} function applySubjectColourToOverallResult() { const selectedAssessmentItem = document.querySelector( "[class*='AssessmentItem__AssessmentItem___'][class*='selected___']", diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index 2740b524..c27a5ec4 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -17,6 +17,9 @@ export async function initStorage(api: any) { if (!api.storage.assessments) { api.storage.assessments = {}; } + if (!api.storage.weightingOverrides) { + api.storage.weightingOverrides = {}; + } } export function clearStuck(api: any) { @@ -82,12 +85,24 @@ function createWeightLabel( const statsContainer = assessmentItem.querySelector( `[class*='AssessmentItem__stats___']`, ) as HTMLElement; + if (!statsContainer) return; - if ( - !statsContainer || - statsContainer.querySelector(".betterseqta-weight-label") - ) + const displayText = + weighting && weighting !== "processing" + ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` + : "N/A"; + + const existingLabel = statsContainer.querySelector( + ".betterseqta-weight-label", + ) as HTMLElement | null; + + if (existingLabel) { + const textNodes = Array.from(existingLabel.childNodes).filter( + (node) => node.nodeType === Node.TEXT_NODE, + ); + if (textNodes.length) textNodes[0].textContent = displayText; return; + } const label = statsContainer.querySelector( `[class*='Label__Label___']`, @@ -106,14 +121,7 @@ function createWeightLabel( const textNodes = Array.from(weightLabel.childNodes).filter( (node) => node.nodeType === Node.TEXT_NODE, ); - - if (textNodes.length) { - textNodes[0].textContent = - weighting && weighting !== "processing" - ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` - : "N/A"; - } - + if (textNodes.length) textNodes[0].textContent = displayText; statsContainer.style.display = "flex"; statsContainer.style.alignItems = "center"; statsContainer.style.justifyContent = "space-between"; @@ -225,7 +233,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise { export async function extractPDFText(url: string): Promise { try { if (isFirefox) { - const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls(); + const { lib: pdfLibUrl, worker: pdfWorkerUrl } = + getPdfjsPageContextUrls(); const escJsSingleQuoted = (s: string) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const pdfLibInj = escJsSingleQuoted(pdfLibUrl); @@ -547,9 +556,13 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { if (!title) continue; const assessmentID = api.storage.assessments?.[title]; - const weighting = assessmentID + const autoWeighting = assessmentID ? api.storage.weightings?.[assessmentID] : undefined; + const override = assessmentID + ? api.storage.weightingOverrides?.[assessmentID] + : undefined; + const weighting = override ?? autoWeighting; createWeightLabel(assessmentItem, weighting); @@ -584,3 +597,272 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { count, }; } + +// Add this above injectWeightingsTab in utils.ts +function resolveTabSetClasses(): Record { + const patterns = [ + "TabSet__tabsheet___", + "TabSet__hidden___", + "TabSet__selected___", + "TabSet__disappearToLeft___", + "TabSet__disappearToRight___", + "TabSet__appearFromRight___", + "TabSet__appearFromLeft___", + ]; + + const resolved: Record = {}; + + // First pass: scan live DOM elements (fast, covers currently-applied classes) + const allClasses = Array.from( + document.querySelectorAll('[class*="TabSet__"]'), + ).flatMap((el) => Array.from(el.classList)); + + for (const pattern of patterns) { + const found = allClasses.find((c) => c.startsWith(pattern)); + if (found) resolved[pattern] = found; + } + + // Second pass: scan stylesheets for any classes not yet in the DOM + // (e.g. animation classes that haven't been applied yet) + const missing = patterns.filter((p) => !resolved[p]); + if (missing.length > 0) { + try { + for (const sheet of Array.from(document.styleSheets)) { + if (missing.every((p) => resolved[p])) break; + try { + for (const rule of Array.from(sheet.cssRules ?? [])) { + if (!(rule instanceof CSSStyleRule)) continue; + const selectorClasses = + rule.selectorText.match(/\.([\w-]+)/g) ?? []; + for (const pattern of missing) { + if (!resolved[pattern]) { + const match = selectorClasses.find((c) => + c.slice(1).startsWith(pattern), + ); + if (match) resolved[pattern] = match.slice(1); + } + } + } + } catch { + // Cross-origin stylesheet — skip + } + } + } catch {} + } + + // Fallback: use the base pattern as-is so the function doesn't crash, + // though styles won't apply if the hash is truly unknown. + for (const pattern of patterns) { + if (!resolved[pattern]) resolved[pattern] = pattern; + } + + return resolved; +} + +function buildWeightingsTabContent(api: any, sheet: HTMLElement) { + const titleEl = document.querySelector( + "[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']", + ); + const title = titleEl?.textContent?.trim(); + const assessmentID = title ? api.storage.assessments?.[title] : undefined; + + const rawWeight = assessmentID + ? api.storage.weightings?.[assessmentID] + : undefined; + + const weightingUnavailable = rawWeight === "N/A"; + + const autoWeight = + rawWeight && rawWeight !== "processing" && rawWeight !== "N/A" + ? rawWeight + : undefined; + + const override = assessmentID + ? api.storage.weightingOverrides?.[assessmentID] + : undefined; + + const statusNote = !assessmentID + ? "" + : rawWeight === "processing" + ? "Weighting is still being detected." + : weightingUnavailable + ? "No weighting was found in the marksheet. Set one manually." + : "Overrides the auto-detected value."; + + sheet.innerHTML = ` + +
+

Weighting Override

+

+ Set the weighting for this assessment manually. + ${statusNote} +

+
+ + ${autoWeight != null ? `${autoWeight}%` : "none"} +
+
+ + +
+
+ +
+ ${!assessmentID ? `

Assessment not yet indexed — try refreshing.

` : ""} +
+ `; + + if (!assessmentID) return; + + const input = sheet.querySelector( + "#betterseqta-weight-override", + ) as HTMLInputElement; + const statusEl = sheet.querySelector( + ".betterseqta-save-status", + ) as HTMLElement; + + const save = () => { + const raw = input.value.trim(); + if (raw === "") { + const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides; + api.storage.weightingOverrides = rest; + } else { + const val = parseFloat(raw); + if (isNaN(val) || val < 0) { + input.style.borderColor = "rgba(255,80,80,0.6)"; + statusEl.textContent = "Invalid. Must be 0 or greater"; + statusEl.style.color = "rgba(255,80,80,0.8)"; + return; + } + input.style.borderColor = "rgba(128,128,128,0.3)"; + api.storage.weightingOverrides = { + ...api.storage.weightingOverrides, + [assessmentID]: String(val), + }; + } + statusEl.textContent = "Saved"; + statusEl.style.color = ""; + setTimeout(() => (statusEl.textContent = ""), 2000); + document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged")); + }; + + input.addEventListener("blur", save); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + input.blur(); + save(); + } + }); + input.addEventListener("input", () => { + input.style.borderColor = "rgba(128,128,128,0.3)"; + if (statusEl.textContent === "Invalid. Must be 0 or greater.") + statusEl.textContent = ""; + }); +} + +export function injectWeightingsTab(api: any) { + const tabList = document.querySelector( + '[class*="TabSet__tabs___"]', + ) as HTMLElement; + const container = document.querySelector( + '[class*="TabSet__tabContainer___"]', + ) as HTMLElement; + if (!tabList || !container) return; + if (tabList.querySelector(".betterseqta-weightings-tab")) return; + + const cls = resolveTabSetClasses(); + + const prefix = (tabList.querySelector("li") as HTMLElement).id.replace( + /-tab-\d+$/, + "", + ); + const newIndex = tabList.querySelectorAll("li").length; + + const newTab = document.createElement("li"); + newTab.id = `${prefix}-tab-${newIndex}`; + newTab.className = ""; + newTab.setAttribute("aria-selected", "false"); + newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`); + newTab.classList.add("betterseqta-weightings-tab"); + newTab.textContent = "Weightings"; + tabList.appendChild(newTab); + + const newSheet = document.createElement("div"); + newSheet.id = `${prefix}-tabsheet-${newIndex}`; + newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`); + newSheet.className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__hidden___"], + cls["TabSet__disappearToRight___"], + ].join(" "); + container.appendChild(newSheet); + + let populated = false; + newTab.addEventListener("click", () => { + if (!populated) { + buildWeightingsTabContent(api, newSheet); + populated = true; + } + }); + + const allTabs = Array.from(tabList.querySelectorAll("li")); + const allSheets = Array.from( + container.querySelectorAll('[class*="tabsheet"]'), + ); + + allTabs.forEach((tab, i) => { + tab.addEventListener("click", () => { + const currentIndex = allTabs.findIndex((t) => + t.className.includes("TabSet__selected___"), + ); + if (i === currentIndex) return; + const goingRight = i > currentIndex; + + allTabs.forEach((t) => { + t.className = ""; + t.setAttribute("aria-selected", "false"); + }); + + allSheets[currentIndex].className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__hidden___"], + goingRight + ? cls["TabSet__disappearToLeft___"] + : cls["TabSet__disappearToRight___"], + ].join(" "); + + allSheets[i].className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__selected___"], + goingRight + ? cls["TabSet__appearFromRight___"] + : cls["TabSet__appearFromLeft___"], + ].join(" "); + + tab.className = cls["TabSet__selected___"]; + tab.setAttribute("aria-selected", "true"); + }); + }); +} \ No newline at end of file