From aceefa16c030e50da5528dcf2398c8e4c2881081 Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Wed, 28 Jan 2026 13:25:58 +0800 Subject: [PATCH 01/10] feat: Assessments Average weightings parsing ONLY PARSING SIDE IS COMPLETE. Does not factor into the average yet. --- package.json | 1 + .../built-in/assessmentsAverage/index.ts | 95 ++++++++++++++++++- src/seqta/ui/AddBetterSEQTAElements.ts | 2 +- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6b1b7451..bed1ea31 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "mathjs": "^14.4.0", "million": "^3.1.11", "motion": "^12.4.12", + "pdfjs-dist": "^5.4.530", "postcss": "^8.5.3", "react": "17", "react-best-gradient-color-picker": "3.0.11", diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index d3ef0051..79890d5d 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -7,6 +7,16 @@ import { import { type Plugin } from "@/plugins/core/types"; import stringToHTML from "@/seqta/utils/stringToHTML"; import { waitForElm } from "@/seqta/utils/waitForElm"; +import ReactFiber from "@/seqta/utils/ReactFiber.ts"; +import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts"; +import * as pdfjs from "pdfjs-dist"; +pdfjs.GlobalWorkerOptions.workerSrc = + "https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.mjs"; + +// Storage +interface weightingsStorage { + weightings: Record; +} const settings = defineSettings({ lettergrade: booleanSetting({ @@ -23,7 +33,7 @@ class AssessmentsAveragePluginClass extends BasePlugin { const instance = new AssessmentsAveragePluginClass(); -const assessmentsAveragePlugin: Plugin = { +const assessmentsAveragePlugin: Plugin = { id: "assessments-average", name: "Assessment Averages", description: "Adds an average grade to the Assessments page", @@ -32,8 +42,13 @@ const assessmentsAveragePlugin: Plugin = { settings: instance.settings, run: async (api) => { + await api.storage.loaded; + + if (!api.storage.weightings) { + api.storage.weightings = {}; + } + api.seqta.onMount(".assessmentsWrapper", async () => { - // Wait for any assessment item to load first await waitForElm( "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", true, @@ -41,6 +56,8 @@ const assessmentsAveragePlugin: Plugin = { 1000, ); + await parseAssessments(api); + // Helper function to find actual class names by their base pattern const getClassByPattern = ( element: Element | Document, @@ -201,4 +218,78 @@ const assessmentsAveragePlugin: Plugin = { }, }; +async function extractPDFText(url: string): Promise { + const loadingTask = pdfjs.getDocument(url); + const pdf = await loadingTask.promise; + + let text = ""; + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const content = await page.getTextContent(); + text += content.items.map((item: any) => item.str).join(" ") + "\n"; + } + + return text; +} + +async function handleWeightings(mark: any, api: any) { + const assessmentID = mark.id; + const metaclassID = mark.metaclassID; + const userInfo = await getUserInfo(); + const userID = userInfo.id; + if (api.storage.weightings[assessmentID] != undefined) { + return; + } + + api.storage.weightings = { + ...api.storage.weightings, + [assessmentID]: "processing", + }; + + const filename = + "BetterSEQTA-" + String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); + + await fetch(`${location.origin}/seqta/student/print/assessment`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + fileName: filename, + id: assessmentID, + metaclass: metaclassID, + student: userID, + }), + }); + + const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; + const text = await extractPDFText(pdfUrl); + + // Use regex to find the line "Assessment weight: X" + const match = text.match(/Assessment weight:\s*(\d+\.?\d*)/i); + const weight = match ? match[1] : "N/A"; + + // Save it to storage + api.storage.weightings = { + ...api.storage.weightings, + [assessmentID]: weight, + }; + + console.log(`Assessment ID ${assessmentID} weight:`, weight); + + console.log(text); +} + +async function parseAssessments(api: any) { + const state = await ReactFiber.find( + "[class*='AssessmentList__items___']", + ).getState(); + + const marks = state["marks"]; + if (!marks) return; + + for (const mark of marks) { + await handleWeightings(mark, api); + } +} + export default assessmentsAveragePlugin; diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index e7b5df62..a285fe0b 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -14,7 +14,7 @@ let cachedUserInfo: any = null; let LightDarkModeSnakeEggButton = 0; -async function getUserInfo() { +export async function getUserInfo() { if (cachedUserInfo) return cachedUserInfo; try { From f1afa74ee64438900226b05b1735436899d7c404 Mon Sep 17 00:00:00 2001 From: StroepWafel <109832156+StroepWafel@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:36:16 +1030 Subject: [PATCH 02/10] Add average grade display and Fix CORS violation Add average grade display and also fix the CORS violation caused by pdfjs trying to load PDFs from URLs that Firefox extensions can't access. fixed by instead: - Fetching the PDF as an ArrayBuffer directly from the URL - Passing the ArrayBuffer to pdfjs using { data: arrayBuffer } instead of passing a URL --- .../built-in/assessmentsAverage/index.ts | 87 +++++++++++++++---- 1 file changed, 72 insertions(+), 15 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index 79890d5d..616667d5 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -125,10 +125,12 @@ const assessmentsAveragePlugin: Plugin = { ); if (!assessmentsList) return; - const gradeElements = document.querySelectorAll( - "[class*='Thermoscore__text___']", - ); - if (!gradeElements.length) return; + // Get marks from React state to match with DOM elements + const state = await ReactFiber.find( + "[class*='AssessmentList__items___']", + ).getState(); + const marks = state["marks"]; + if (!marks || !marks.length) return; // Parse and average grades const letterToNumber: Record = { @@ -162,19 +164,59 @@ const assessmentsAveragePlugin: Plugin = { return letterToNumber[str] ?? 0; } - let total = 0; + // Get all assessment items (excluding the average we might have added) + const assessmentItems = Array.from( + assessmentsList.querySelectorAll(`[class*='AssessmentItem__AssessmentItem___']`), + ).filter( + (item) => + !item.querySelector(`[class*='AssessmentItem__title___']`)?.textContent?.includes("Subject Average"), + ); + + // Match marks to assessment items and calculate weighted average + let weightedTotal = 0; + let totalWeight = 0; + let hasInaccurateWeighting = false; let count = 0; - gradeElements.forEach((el) => { - const grade = parseGrade(el.textContent || ""); - if (grade > 0) { - total += grade; - count++; + + for (let i = 0; i < marks.length && i < assessmentItems.length; i++) { + const mark = marks[i]; + const assessmentItem = assessmentItems[i]; + const gradeElement = assessmentItem.querySelector( + `[class*='Thermoscore__text___']`, + ); + + if (!gradeElement) continue; + + const grade = parseGrade(gradeElement.textContent || ""); + if (grade <= 0) continue; + + const assessmentID = String(mark.id); + const weighting = api.storage.weightings[assessmentID]; + + // Check if weighting is unavailable or still processing + if (!weighting || weighting === "N/A" || weighting === "processing") { + hasInaccurateWeighting = true; + // Fall back to equal weighting if unavailable + weightedTotal += grade; + totalWeight += 1; + } else { + const weight = parseFloat(weighting); + if (!isNaN(weight) && weight > 0) { + weightedTotal += grade * weight; + totalWeight += weight; + } else { + // Invalid weight, use equal weighting + weightedTotal += grade; + totalWeight += 1; + hasInaccurateWeighting = true; + } } - }); + count++; + } - if (!count) return; + if (!count || totalWeight === 0) return; - const avg = total / count; + const avg = weightedTotal / totalWeight; const rounded = Math.ceil(avg / 5) * 5; const numberToLetter = Object.entries(letterToNumber).reduce( (acc, [k, v]) => { @@ -195,6 +237,16 @@ const assessmentsAveragePlugin: Plugin = { ); if (existing?.textContent === "Subject Average") return; + // Build warning message if needed + let warningHTML = ""; + if (hasInaccurateWeighting) { + warningHTML = /* html */ ` +
+ ⚠ Some weightings unavailable +
+ `; + } + // Use the dynamic class names in the HTML template const averageElement = stringToHTML(/* html */ `
@@ -202,12 +254,13 @@ const assessmentsAveragePlugin: Plugin = {
Subject Average
+ ${warningHTML}
-
${display}
+
${display}
@@ -219,7 +272,11 @@ const assessmentsAveragePlugin: Plugin = { }; async function extractPDFText(url: string): Promise { - const loadingTask = pdfjs.getDocument(url); + // Fetch PDF as ArrayBuffer to avoid blob URL CSP issues in Firefox extensions + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + + const loadingTask = pdfjs.getDocument({ data: arrayBuffer }); const pdf = await loadingTask.promise; let text = ""; From f594ed4902db9ed17439f6af8ffe6baa0ec8768b Mon Sep 17 00:00:00 2001 From: StroepWafel <109832156+StroepWafel@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:19:08 +1030 Subject: [PATCH 03/10] i think i fixed it? --- .../built-in/assessmentsAverage/index.ts | 424 ++++++++++++++++-- 1 file changed, 385 insertions(+), 39 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index 616667d5..0326c6dc 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -48,6 +48,27 @@ const assessmentsAveragePlugin: Plugin = { api.storage.weightings = {}; } + // Clear any stuck "processing" states so they can retry + let hasStuckProcessing = false; + for (const key in api.storage.weightings) { + if (api.storage.weightings[key] === "processing") { + delete api.storage.weightings[key]; + hasStuckProcessing = true; + } + } + if (hasStuckProcessing) { + // Force update storage + api.storage.weightings = { ...api.storage.weightings }; + } + + // Expose globally for easy access in console: window.BetterSEQTAWeightings + (window as any).BetterSEQTAWeightings = api.storage.weightings; + + // Keep it updated when weightings change + api.storage.onChange('weightings', (newWeightings) => { + (window as any).BetterSEQTAWeightings = newWeightings; + }); + api.seqta.onMount(".assessmentsWrapper", async () => { await waitForElm( "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", @@ -271,23 +292,308 @@ const assessmentsAveragePlugin: Plugin = { }, }; -async function extractPDFText(url: string): Promise { - // Fetch PDF as ArrayBuffer to avoid blob URL CSP issues in Firefox extensions - const response = await fetch(url); - const arrayBuffer = await response.arrayBuffer(); +// Detect Firefox (has stricter CSP for blob URLs) +// Use userAgent instead of deprecated InstallTrigger +const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1 && + !navigator.userAgent.toLowerCase().includes('seamonkey') && + !navigator.userAgent.toLowerCase().includes('waterfox'); + +async function fetchPDFAsArrayBuffer(url: string): Promise { + // Detect if URL is a blob URL + const isBlobUrl = url.startsWith('blob:'); - const loadingTask = pdfjs.getDocument({ data: arrayBuffer }); - const pdf = await loadingTask.promise; - - let text = ""; - - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const content = await page.getTextContent(); - text += content.items.map((item: any) => item.str).join(" ") + "\n"; + // For Firefox, ALWAYS use page context to avoid any CSP issues + // For blob URLs in any browser, use page context + if (isBlobUrl || isFirefox) { + return new Promise((resolve, reject) => { + // Inject script into page context to fetch (bypasses Firefox CSP restrictions) + const script = document.createElement('script'); + const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`; + + // Escape URL for use in script + const escapedUrl = url.replace(/'/g, "\\'"); + + script.textContent = ` + (function() { + fetch('${escapedUrl}') + .then(response => { + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + return response.arrayBuffer(); + }) + .then(arrayBuffer => { + window.postMessage({ + type: '${requestId}', + success: true, + data: Array.from(new Uint8Array(arrayBuffer)) + }, '*'); + }) + .catch(error => { + window.postMessage({ + type: '${requestId}', + success: false, + error: error.message || String(error) + }, '*'); + }); + })(); + `; + + const messageHandler = (event: MessageEvent) => { + if (event.data?.type === requestId) { + window.removeEventListener('message', messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + + if (event.data.success) { + // Convert back to ArrayBuffer + const uint8Array = new Uint8Array(event.data.data); + resolve(uint8Array.buffer); + } else { + reject(new Error(event.data.error || 'Failed to fetch PDF')); + } + } + }; + + window.addEventListener('message', messageHandler); + (document.head || document.documentElement).appendChild(script); + + // Timeout after 30 seconds + setTimeout(() => { + window.removeEventListener('message', messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + reject(new Error('Timeout fetching PDF')); + }, 30000); + }); + } else { + // Regular URL - fetch normally, but check if response URL becomes blob + try { + const response = await fetch(url, { + credentials: 'include', + redirect: 'follow', + }); + + // Check if response URL is a blob URL (server might redirect to blob) + if (response.url && response.url.startsWith('blob:')) { + // Re-fetch using page context + return await fetchPDFAsArrayBuffer(response.url); + } + + if (!response.ok) { + throw new Error(`Failed to fetch PDF: ${response.status} ${response.statusText}`); + } + + return await response.arrayBuffer(); + } catch (error: any) { + // If error mentions blob or security, try using page context + if (error?.message?.includes('blob') || error?.message?.includes('Security') || error?.message?.includes('CSP')) { + // Force use page context + return await fetchPDFAsArrayBuffer(url); + } + throw error; + } } +} - return text; +async function extractPDFText(url: string): Promise { + // For Firefox, do everything in page context to avoid blob URL CSP issues + if (isFirefox) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; + + // Escape URL for use in script (handle both single and double quotes) + const escapedUrl = url.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); + + script.textContent = ` + (function() { + const requestId = '${requestId}'; + const url = '${escapedUrl}'; + + // Check if pdfjs is already loaded + if (window.pdfjsLib) { + extractPDF(); + } else { + // Load pdfjs in page context + const pdfjsScript = document.createElement('script'); + pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.mjs'; + pdfjsScript.type = 'module'; + + pdfjsScript.onload = function() { + extractPDF(); + }; + pdfjsScript.onerror = function() { + window.postMessage({ + type: requestId, + success: false, + error: 'Failed to load pdfjs library' + }, '*'); + }; + + document.head.appendChild(pdfjsScript); + } + + function extractPDF() { + try { + // Disable worker for Firefox to avoid blob URL CSP issues + // Set to empty string to disable worker completely + window.pdfjsLib.GlobalWorkerOptions.workerSrc = ''; + + // Use XMLHttpRequest instead of fetch for better blob URL handling + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + xhr.withCredentials = true; + + xhr.onload = function() { + if (xhr.status !== 200) { + window.postMessage({ + type: requestId, + success: false, + error: 'HTTP ' + xhr.status + ': ' + xhr.statusText + }, '*'); + return; + } + + try { + const arrayBuffer = xhr.response; + if (!arrayBuffer || arrayBuffer.byteLength === 0) { + throw new Error('PDF response is empty'); + } + + window.pdfjsLib.getDocument({ + data: arrayBuffer, + useSystemFonts: true, + verbosity: 0, + // Explicitly disable worker + useWorkerFetch: false, + isEvalSupported: false + }).promise + .then(pdf => { + const pagePromises = []; + for (let i = 1; i <= pdf.numPages; i++) { + pagePromises.push( + pdf.getPage(i).then(page => { + return page.getTextContent().then(content => { + return content.items.map(item => item.str).join(' '); + }); + }) + ); + } + return Promise.all(pagePromises); + }) + .then(pages => { + const text = pages.join('\\n'); + window.postMessage({ + type: requestId, + success: true, + text: text + }, '*'); + }) + .catch(error => { + window.postMessage({ + type: requestId, + success: false, + error: 'PDF parsing error: ' + (error.message || String(error)) + }, '*'); + }); + } catch (error) { + window.postMessage({ + type: requestId, + success: false, + error: 'ArrayBuffer error: ' + (error.message || String(error)) + }, '*'); + } + }; + + xhr.onerror = function() { + window.postMessage({ + type: requestId, + success: false, + error: 'Network error fetching PDF' + }, '*'); + }; + + xhr.ontimeout = function() { + window.postMessage({ + type: requestId, + success: false, + error: 'Timeout fetching PDF' + }, '*'); + }; + + xhr.timeout = 30000; + xhr.send(); + } catch (error) { + window.postMessage({ + type: requestId, + success: false, + error: 'Setup error: ' + (error.message || String(error)) + }, '*'); + } + } + })(); + `; + + const messageHandler = (event: MessageEvent) => { + if (event.data?.type === requestId) { + window.removeEventListener('message', messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + + if (event.data.success) { + resolve(event.data.text); + } else { + reject(new Error(event.data.error || 'Failed to extract PDF text')); + } + } + }; + + window.addEventListener('message', messageHandler); + (document.head || document.documentElement).appendChild(script); + + // Timeout after 60 seconds (PDF parsing can take time) + setTimeout(() => { + window.removeEventListener('message', messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + reject(new Error('Timeout extracting PDF text')); + }, 60000); + }); + } else { + // Chrome - use extension context + try { + const arrayBuffer = await fetchPDFAsArrayBuffer(url); + + if (arrayBuffer.byteLength === 0) { + throw new Error('PDF response is empty'); + } + + const loadingTask = pdfjs.getDocument({ + data: arrayBuffer, + useSystemFonts: true, + }); + + const pdf = await loadingTask.promise; + + let text = ""; + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const content = await page.getTextContent(); + text += content.items.map((item: any) => item.str).join(" ") + "\n"; + } + + return text; + } catch (error) { + throw error; + } + } } async function handleWeightings(mark: any, api: any) { @@ -295,45 +601,85 @@ async function handleWeightings(mark: any, api: any) { const metaclassID = mark.metaclassID; const userInfo = await getUserInfo(); const userID = userInfo.id; - if (api.storage.weightings[assessmentID] != undefined) { + + // Skip if already processed (not "processing") + if (api.storage.weightings[assessmentID] != undefined && api.storage.weightings[assessmentID] !== "processing") { return; } + // Set to processing api.storage.weightings = { ...api.storage.weightings, [assessmentID]: "processing", }; - const filename = - "BetterSEQTA-" + String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); + try { + const filename = + "BetterSEQTA-" + String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); - await fetch(`${location.origin}/seqta/student/print/assessment`, { - method: "POST", - headers: { "Content-Type": "application/json; charset=utf-8" }, - body: JSON.stringify({ - fileName: filename, - id: assessmentID, - metaclass: metaclassID, - student: userID, - }), - }); + const printResponse = await fetch(`${location.origin}/seqta/student/print/assessment`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + credentials: 'include', + body: JSON.stringify({ + fileName: filename, + id: assessmentID, + metaclass: metaclassID, + student: userID, + }), + }); - const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; - const text = await extractPDFText(pdfUrl); + if (!printResponse.ok) { + throw new Error(`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`); + } - // Use regex to find the line "Assessment weight: X" - const match = text.match(/Assessment weight:\s*(\d+\.?\d*)/i); - const weight = match ? match[1] : "N/A"; + // Wait a bit for the PDF to be generated + await new Promise(resolve => setTimeout(resolve, 1000)); - // Save it to storage - api.storage.weightings = { - ...api.storage.weightings, - [assessmentID]: weight, - }; + const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; + + // Check if URL is a blob URL (which extensions can't access) + if (pdfUrl.startsWith('blob:')) { + throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`); + } + + let text: string; + try { + // For Firefox, extractPDFText already handles everything in page context + // For Chrome, it uses extension context + text = await extractPDFText(pdfUrl); + } catch (error: any) { + // Handle CSP errors or other fetch issues + // Suppress Firefox blob URL CSP errors (they're warnings, not fatal) + if (isFirefox && (error?.message?.includes('blob') || error?.message?.includes('Security') || error?.message?.includes('CSP'))) { + // Try one more time with a longer delay in case PDF wasn't ready + await new Promise(resolve => setTimeout(resolve, 2000)); + try { + text = await extractPDFText(pdfUrl); + } catch (retryError: any) { + throw new Error(`PDF extraction failed after retry: ${retryError.message}`); + } + } else { + throw new Error(`PDF extraction failed: ${error.message}`); + } + } - console.log(`Assessment ID ${assessmentID} weight:`, weight); + // Use regex to find the line "Assessment weight: X" + const match = text.match(/Assessment weight:\s*(\d+\.?\d*)/i); + const weight = match ? match[1] : "N/A"; - console.log(text); + // Save it to storage + api.storage.weightings = { + ...api.storage.weightings, + [assessmentID]: weight, + }; + } catch (error: any) { + // Catch any error and set to N/A instead of leaving as "processing" + api.storage.weightings = { + ...api.storage.weightings, + [assessmentID]: "N/A", + }; + } } async function parseAssessments(api: any) { From 7a04b22b22052c778446f030757f8d703de22a6e Mon Sep 17 00:00:00 2001 From: StroepWafel <109832156+StroepWafel@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:21:32 +1030 Subject: [PATCH 04/10] Update index.ts --- src/plugins/built-in/assessmentsAverage/index.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index 0326c6dc..5166f625 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -57,18 +57,9 @@ const assessmentsAveragePlugin: Plugin = { } } if (hasStuckProcessing) { - // Force update storage api.storage.weightings = { ...api.storage.weightings }; } - // Expose globally for easy access in console: window.BetterSEQTAWeightings - (window as any).BetterSEQTAWeightings = api.storage.weightings; - - // Keep it updated when weightings change - api.storage.onChange('weightings', (newWeightings) => { - (window as any).BetterSEQTAWeightings = newWeightings; - }); - api.seqta.onMount(".assessmentsWrapper", async () => { await waitForElm( "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", @@ -649,10 +640,7 @@ async function handleWeightings(mark: any, api: any) { // For Chrome, it uses extension context text = await extractPDFText(pdfUrl); } catch (error: any) { - // Handle CSP errors or other fetch issues - // Suppress Firefox blob URL CSP errors (they're warnings, not fatal) if (isFirefox && (error?.message?.includes('blob') || error?.message?.includes('Security') || error?.message?.includes('CSP'))) { - // Try one more time with a longer delay in case PDF wasn't ready await new Promise(resolve => setTimeout(resolve, 2000)); try { text = await extractPDFText(pdfUrl); @@ -664,17 +652,14 @@ async function handleWeightings(mark: any, api: any) { } } - // Use regex to find the line "Assessment weight: X" const match = text.match(/Assessment weight:\s*(\d+\.?\d*)/i); const weight = match ? match[1] : "N/A"; - // Save it to storage api.storage.weightings = { ...api.storage.weightings, [assessmentID]: weight, }; } catch (error: any) { - // Catch any error and set to N/A instead of leaving as "processing" api.storage.weightings = { ...api.storage.weightings, [assessmentID]: "N/A", From 355c5f2d463d95454b919dd4f08556ed6f64409f Mon Sep 17 00:00:00 2001 From: StroepWafel <109832156+StroepWafel@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:38:20 +1030 Subject: [PATCH 05/10] fix try catch error --- src/plugins/built-in/assessmentsAverage/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index 5166f625..4b8297fb 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -582,6 +582,7 @@ async function extractPDFText(url: string): Promise { return text; } catch (error) { + console.log(error); throw error; } } From 391fcfb9dd79615fc2e5fcf7f024e9e839787f76 Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Wed, 28 Jan 2026 23:06:02 +0800 Subject: [PATCH 06/10] Various changes/fixes/polish - Adds weight label below each assessment - Correlates assessment name with its ID, so each dom element can be identified: {trimmed_title: assessmentID} - Slightly changed regex to be a little more permissive - Changed pdfjs src/type due to CORS issues on firefox - Permitted weightings to be zero, but not less than zero - Modified how assessment items are iterated through, as the previous approach assumed they're in the same order as they are in react - Changed parseAssessments() to immediately dispatch parsing for all pdfs asynchronously, as doing it serially is painstakingly slow - Discard useless decimals when displaying weight (.0) --- .../built-in/assessmentsAverage/index.ts | 107 +++++++++++++++--- 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index 5166f625..238d0ee3 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -16,6 +16,7 @@ pdfjs.GlobalWorkerOptions.workerSrc = // Storage interface weightingsStorage { weightings: Record; + assessments: Record; } const settings = defineSettings({ @@ -47,6 +48,9 @@ const assessmentsAveragePlugin: Plugin = { if (!api.storage.weightings) { api.storage.weightings = {}; } + if (!api.storage.assessments) { + api.storage.assessments = {}; + } // Clear any stuck "processing" states so they can retry let hasStuckProcessing = false; @@ -190,9 +194,8 @@ const assessmentsAveragePlugin: Plugin = { let hasInaccurateWeighting = false; let count = 0; - for (let i = 0; i < marks.length && i < assessmentItems.length; i++) { - const mark = marks[i]; - const assessmentItem = assessmentItems[i]; + // Iterate through assessments for processing + for (const assessmentItem of assessmentItems) { const gradeElement = assessmentItem.querySelector( `[class*='Thermoscore__text___']`, ); @@ -202,18 +205,86 @@ const assessmentsAveragePlugin: Plugin = { const grade = parseGrade(gradeElement.textContent || ""); if (grade <= 0) continue; - const assessmentID = String(mark.id); - const weighting = api.storage.weightings[assessmentID]; + const titleEl = assessmentItem.querySelector( + `[class*='AssessmentItem__title___']`, + ); + if (!titleEl) continue; + + const title = titleEl.textContent?.trim(); + if (!title) continue; + + // Get correlated assessment ID in order to fetch weightings + const assessmentID = api.storage.assessments?.[title]; + const weighting = assessmentID + ? api.storage.weightings?.[assessmentID] + : undefined; + + const statsContainer = assessmentItem.querySelector( + `[class*='AssessmentItem__stats___']`, + ) as HTMLElement; + + // Creates a weighting label next to the average score + if (statsContainer) { + // Only add label if it hasn't been added before + if (!statsContainer.querySelector(".betterseqta-weight-label")) { + const label = statsContainer.querySelector( + `[class*='Label__Label___']`, + ) as HTMLElement; + + if (label) { + // Clone average score node + const weightLabel = label.cloneNode(true) as HTMLElement; + + // Mark as added to prevent duplicates + weightLabel.classList.add("betterseqta-weight-label"); + + const innerTextDiv = weightLabel.querySelector( + `[class*='Label__innerText___']`, + ); + + // Repurpose for showing weight + if (innerTextDiv) innerTextDiv.textContent = "Weight"; + + const textNodes = Array.from(weightLabel.childNodes).filter( + (node) => node.nodeType === Node.TEXT_NODE, + ); + + // Set weight value, discarding useless decimals (.0) + if (textNodes.length) { + textNodes[0].textContent = + weighting && weighting !== "processing" + ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` + : "N/A"; + } + + // Set position opposite to the average score node + statsContainer.style.position = "relative"; + weightLabel.style.position = "absolute"; + weightLabel.style.right = "0"; + weightLabel.style.top = "50%"; + weightLabel.style.transform = "translateY(-50%)"; + + statsContainer.appendChild(weightLabel); + } + } + } // Check if weighting is unavailable or still processing - if (!weighting || weighting === "N/A" || weighting === "processing") { + if ( + weighting === null || + weighting === undefined || + weighting === "N/A" || + weighting === "processing" + ) { hasInaccurateWeighting = true; // Fall back to equal weighting if unavailable weightedTotal += grade; totalWeight += 1; } else { const weight = parseFloat(weighting); - if (!isNaN(weight) && weight > 0) { + + // If weight is a positive number, add to total + if (!isNaN(weight) && weight >= 0) { weightedTotal += grade * weight; totalWeight += weight; } else { @@ -410,8 +481,8 @@ async function extractPDFText(url: string): Promise { } else { // Load pdfjs in page context const pdfjsScript = document.createElement('script'); - pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.mjs'; - pdfjsScript.type = 'module'; + pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js'; + pdfjsScript.type = 'text/javascript'; pdfjsScript.onload = function() { extractPDF(); @@ -592,7 +663,8 @@ async function handleWeightings(mark: any, api: any) { const metaclassID = mark.metaclassID; const userInfo = await getUserInfo(); const userID = userInfo.id; - + const title = mark.title; + // Skip if already processed (not "processing") if (api.storage.weightings[assessmentID] != undefined && api.storage.weightings[assessmentID] !== "processing") { return; @@ -604,6 +676,12 @@ async function handleWeightings(mark: any, api: any) { [assessmentID]: "processing", }; + // Correlate assessment title with its ID + api.storage.assessments = { + ...api.storage.assessments, + [title.trim()]: assessmentID + } + try { const filename = "BetterSEQTA-" + String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); @@ -652,9 +730,11 @@ async function handleWeightings(mark: any, api: any) { } } - const match = text.match(/Assessment weight:\s*(\d+\.?\d*)/i); + // Match weighting from extracted text + const match = text.match(/weight:\s*(\d+\.?\d*)/i); const weight = match ? match[1] : "N/A"; + // Store and correlate weight with assessment ID api.storage.weightings = { ...api.storage.weightings, [assessmentID]: weight, @@ -675,9 +755,8 @@ async function parseAssessments(api: any) { const marks = state["marks"]; if (!marks) return; - for (const mark of marks) { - await handleWeightings(mark, api); - } + // Dispatch for all assessments asynchronously + await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); } export default assessmentsAveragePlugin; From 87fdda459aa4cea694ffb5a73846a9666f43935a Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Thu, 29 Jan 2026 00:07:36 +0800 Subject: [PATCH 07/10] Small refactor --- .../built-in/assessmentsAverage/index.ts | 522 +--------------- .../built-in/assessmentsAverage/utils.ts | 555 ++++++++++++++++++ 2 files changed, 560 insertions(+), 517 deletions(-) create mode 100644 src/plugins/built-in/assessmentsAverage/utils.ts diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index 61ed7ebf..40e090b7 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -8,10 +8,7 @@ import { type Plugin } from "@/plugins/core/types"; import stringToHTML from "@/seqta/utils/stringToHTML"; import { waitForElm } from "@/seqta/utils/waitForElm"; import ReactFiber from "@/seqta/utils/ReactFiber.ts"; -import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts"; -import * as pdfjs from "pdfjs-dist"; -pdfjs.GlobalWorkerOptions.workerSrc = - "https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.mjs"; +import { initStorage, clearStuck, getClassByPattern, parseGrade, createWeightLabel, parseAssessments, letterToNumber} from "./utils.ts"; // Storage interface weightingsStorage { @@ -43,26 +40,12 @@ const assessmentsAveragePlugin: Plugin = { settings: instance.settings, run: async (api) => { - await api.storage.loaded; - if (!api.storage.weightings) { - api.storage.weightings = {}; - } - if (!api.storage.assessments) { - api.storage.assessments = {}; - } + // Ensure storage is ready for use + await initStorage(api); // Clear any stuck "processing" states so they can retry - let hasStuckProcessing = false; - for (const key in api.storage.weightings) { - if (api.storage.weightings[key] === "processing") { - delete api.storage.weightings[key]; - hasStuckProcessing = true; - } - } - if (hasStuckProcessing) { - api.storage.weightings = { ...api.storage.weightings }; - } + clearStuck(api); api.seqta.onMount(".assessmentsWrapper", async () => { await waitForElm( @@ -74,19 +57,6 @@ const assessmentsAveragePlugin: Plugin = { await parseAssessments(api); - // Helper function to find actual class names by their base pattern - const getClassByPattern = ( - element: Element | Document, - basePattern: string, - ): string => { - // Find all classes on the element - const classes = Array.from(element.querySelectorAll("*")) - .flatMap((el) => Array.from(el.classList)) - .filter((className) => className.startsWith(basePattern)); - - return classes.length ? classes[0] : ""; - }; - // Find actual class names from the DOM const sampleAssessmentItem = document.querySelector( "[class*='AssessmentItem__AssessmentItem___']", @@ -149,36 +119,6 @@ const assessmentsAveragePlugin: Plugin = { if (!marks || !marks.length) return; // Parse and average grades - const letterToNumber: Record = { - "A+": 100, - A: 95, - "A-": 90, - "B+": 85, - B: 80, - "B-": 75, - "C+": 70, - C: 65, - "C-": 60, - "D+": 55, - D: 50, - "D-": 45, - "E+": 40, - E: 35, - "E-": 30, - F: 0, - }; - - function parseGrade(text: string): number { - const str = text.trim().toUpperCase(); - if (str.includes("/")) { - const [raw, max] = str.split("/").map((n) => parseFloat(n)); - return (raw / max) * 100; - } - if (str.includes("%")) { - return parseFloat(str.replace("%", "")) || 0; - } - return letterToNumber[str] ?? 0; - } // Get all assessment items (excluding the average we might have added) const assessmentItems = Array.from( @@ -219,55 +159,9 @@ const assessmentsAveragePlugin: Plugin = { ? api.storage.weightings?.[assessmentID] : undefined; - const statsContainer = assessmentItem.querySelector( - `[class*='AssessmentItem__stats___']`, - ) as HTMLElement; // Creates a weighting label next to the average score - if (statsContainer) { - // Only add label if it hasn't been added before - if (!statsContainer.querySelector(".betterseqta-weight-label")) { - const label = statsContainer.querySelector( - `[class*='Label__Label___']`, - ) as HTMLElement; - - if (label) { - // Clone average score node - const weightLabel = label.cloneNode(true) as HTMLElement; - - // Mark as added to prevent duplicates - weightLabel.classList.add("betterseqta-weight-label"); - - const innerTextDiv = weightLabel.querySelector( - `[class*='Label__innerText___']`, - ); - - // Repurpose for showing weight - if (innerTextDiv) innerTextDiv.textContent = "Weight"; - - const textNodes = Array.from(weightLabel.childNodes).filter( - (node) => node.nodeType === Node.TEXT_NODE, - ); - - // Set weight value, discarding useless decimals (.0) - if (textNodes.length) { - textNodes[0].textContent = - weighting && weighting !== "processing" - ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` - : "N/A"; - } - - // Set position opposite to the average score node - statsContainer.style.position = "relative"; - weightLabel.style.position = "absolute"; - weightLabel.style.right = "0"; - weightLabel.style.top = "50%"; - weightLabel.style.transform = "translateY(-50%)"; - - statsContainer.appendChild(weightLabel); - } - } - } + createWeightLabel(assessmentItem, weighting) // Check if weighting is unavailable or still processing if ( @@ -354,410 +248,4 @@ const assessmentsAveragePlugin: Plugin = { }, }; -// Detect Firefox (has stricter CSP for blob URLs) -// Use userAgent instead of deprecated InstallTrigger -const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1 && - !navigator.userAgent.toLowerCase().includes('seamonkey') && - !navigator.userAgent.toLowerCase().includes('waterfox'); - -async function fetchPDFAsArrayBuffer(url: string): Promise { - // Detect if URL is a blob URL - const isBlobUrl = url.startsWith('blob:'); - - // For Firefox, ALWAYS use page context to avoid any CSP issues - // For blob URLs in any browser, use page context - if (isBlobUrl || isFirefox) { - return new Promise((resolve, reject) => { - // Inject script into page context to fetch (bypasses Firefox CSP restrictions) - const script = document.createElement('script'); - const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`; - - // Escape URL for use in script - const escapedUrl = url.replace(/'/g, "\\'"); - - script.textContent = ` - (function() { - fetch('${escapedUrl}') - .then(response => { - if (!response.ok) { - throw new Error('HTTP ' + response.status + ': ' + response.statusText); - } - return response.arrayBuffer(); - }) - .then(arrayBuffer => { - window.postMessage({ - type: '${requestId}', - success: true, - data: Array.from(new Uint8Array(arrayBuffer)) - }, '*'); - }) - .catch(error => { - window.postMessage({ - type: '${requestId}', - success: false, - error: error.message || String(error) - }, '*'); - }); - })(); - `; - - const messageHandler = (event: MessageEvent) => { - if (event.data?.type === requestId) { - window.removeEventListener('message', messageHandler); - if (script.parentNode) { - script.parentNode.removeChild(script); - } - - if (event.data.success) { - // Convert back to ArrayBuffer - const uint8Array = new Uint8Array(event.data.data); - resolve(uint8Array.buffer); - } else { - reject(new Error(event.data.error || 'Failed to fetch PDF')); - } - } - }; - - window.addEventListener('message', messageHandler); - (document.head || document.documentElement).appendChild(script); - - // Timeout after 30 seconds - setTimeout(() => { - window.removeEventListener('message', messageHandler); - if (script.parentNode) { - script.parentNode.removeChild(script); - } - reject(new Error('Timeout fetching PDF')); - }, 30000); - }); - } else { - // Regular URL - fetch normally, but check if response URL becomes blob - try { - const response = await fetch(url, { - credentials: 'include', - redirect: 'follow', - }); - - // Check if response URL is a blob URL (server might redirect to blob) - if (response.url && response.url.startsWith('blob:')) { - // Re-fetch using page context - return await fetchPDFAsArrayBuffer(response.url); - } - - if (!response.ok) { - throw new Error(`Failed to fetch PDF: ${response.status} ${response.statusText}`); - } - - return await response.arrayBuffer(); - } catch (error: any) { - // If error mentions blob or security, try using page context - if (error?.message?.includes('blob') || error?.message?.includes('Security') || error?.message?.includes('CSP')) { - // Force use page context - return await fetchPDFAsArrayBuffer(url); - } - throw error; - } - } -} - -async function extractPDFText(url: string): Promise { - // For Firefox, do everything in page context to avoid blob URL CSP issues - if (isFirefox) { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; - - // Escape URL for use in script (handle both single and double quotes) - const escapedUrl = url.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); - - script.textContent = ` - (function() { - const requestId = '${requestId}'; - const url = '${escapedUrl}'; - - // Check if pdfjs is already loaded - if (window.pdfjsLib) { - extractPDF(); - } else { - // Load pdfjs in page context - const pdfjsScript = document.createElement('script'); - pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js'; - pdfjsScript.type = 'text/javascript'; - - pdfjsScript.onload = function() { - extractPDF(); - }; - pdfjsScript.onerror = function() { - window.postMessage({ - type: requestId, - success: false, - error: 'Failed to load pdfjs library' - }, '*'); - }; - - document.head.appendChild(pdfjsScript); - } - - function extractPDF() { - try { - // Disable worker for Firefox to avoid blob URL CSP issues - // Set to empty string to disable worker completely - window.pdfjsLib.GlobalWorkerOptions.workerSrc = ''; - - // Use XMLHttpRequest instead of fetch for better blob URL handling - const xhr = new XMLHttpRequest(); - xhr.open('GET', url, true); - xhr.responseType = 'arraybuffer'; - xhr.withCredentials = true; - - xhr.onload = function() { - if (xhr.status !== 200) { - window.postMessage({ - type: requestId, - success: false, - error: 'HTTP ' + xhr.status + ': ' + xhr.statusText - }, '*'); - return; - } - - try { - const arrayBuffer = xhr.response; - if (!arrayBuffer || arrayBuffer.byteLength === 0) { - throw new Error('PDF response is empty'); - } - - window.pdfjsLib.getDocument({ - data: arrayBuffer, - useSystemFonts: true, - verbosity: 0, - // Explicitly disable worker - useWorkerFetch: false, - isEvalSupported: false - }).promise - .then(pdf => { - const pagePromises = []; - for (let i = 1; i <= pdf.numPages; i++) { - pagePromises.push( - pdf.getPage(i).then(page => { - return page.getTextContent().then(content => { - return content.items.map(item => item.str).join(' '); - }); - }) - ); - } - return Promise.all(pagePromises); - }) - .then(pages => { - const text = pages.join('\\n'); - window.postMessage({ - type: requestId, - success: true, - text: text - }, '*'); - }) - .catch(error => { - window.postMessage({ - type: requestId, - success: false, - error: 'PDF parsing error: ' + (error.message || String(error)) - }, '*'); - }); - } catch (error) { - window.postMessage({ - type: requestId, - success: false, - error: 'ArrayBuffer error: ' + (error.message || String(error)) - }, '*'); - } - }; - - xhr.onerror = function() { - window.postMessage({ - type: requestId, - success: false, - error: 'Network error fetching PDF' - }, '*'); - }; - - xhr.ontimeout = function() { - window.postMessage({ - type: requestId, - success: false, - error: 'Timeout fetching PDF' - }, '*'); - }; - - xhr.timeout = 30000; - xhr.send(); - } catch (error) { - window.postMessage({ - type: requestId, - success: false, - error: 'Setup error: ' + (error.message || String(error)) - }, '*'); - } - } - })(); - `; - - const messageHandler = (event: MessageEvent) => { - if (event.data?.type === requestId) { - window.removeEventListener('message', messageHandler); - if (script.parentNode) { - script.parentNode.removeChild(script); - } - - if (event.data.success) { - resolve(event.data.text); - } else { - reject(new Error(event.data.error || 'Failed to extract PDF text')); - } - } - }; - - window.addEventListener('message', messageHandler); - (document.head || document.documentElement).appendChild(script); - - // Timeout after 60 seconds (PDF parsing can take time) - setTimeout(() => { - window.removeEventListener('message', messageHandler); - if (script.parentNode) { - script.parentNode.removeChild(script); - } - reject(new Error('Timeout extracting PDF text')); - }, 60000); - }); - } else { - // Chrome - use extension context - try { - const arrayBuffer = await fetchPDFAsArrayBuffer(url); - - if (arrayBuffer.byteLength === 0) { - throw new Error('PDF response is empty'); - } - - const loadingTask = pdfjs.getDocument({ - data: arrayBuffer, - useSystemFonts: true, - }); - - const pdf = await loadingTask.promise; - - let text = ""; - - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const content = await page.getTextContent(); - text += content.items.map((item: any) => item.str).join(" ") + "\n"; - } - - return text; - } catch (error) { - console.log(error); - throw error; - } - } -} - -async function handleWeightings(mark: any, api: any) { - const assessmentID = mark.id; - const metaclassID = mark.metaclassID; - const userInfo = await getUserInfo(); - const userID = userInfo.id; - const title = mark.title; - - // Skip if already processed (not "processing") - if (api.storage.weightings[assessmentID] != undefined && api.storage.weightings[assessmentID] !== "processing") { - return; - } - - // Set to processing - api.storage.weightings = { - ...api.storage.weightings, - [assessmentID]: "processing", - }; - - // Correlate assessment title with its ID - api.storage.assessments = { - ...api.storage.assessments, - [title.trim()]: assessmentID - } - - try { - const filename = - "BetterSEQTA-" + String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); - - const printResponse = await fetch(`${location.origin}/seqta/student/print/assessment`, { - method: "POST", - headers: { "Content-Type": "application/json; charset=utf-8" }, - credentials: 'include', - body: JSON.stringify({ - fileName: filename, - id: assessmentID, - metaclass: metaclassID, - student: userID, - }), - }); - - if (!printResponse.ok) { - throw new Error(`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`); - } - - // Wait a bit for the PDF to be generated - await new Promise(resolve => setTimeout(resolve, 1000)); - - const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; - - // Check if URL is a blob URL (which extensions can't access) - if (pdfUrl.startsWith('blob:')) { - throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`); - } - - let text: string; - try { - // For Firefox, extractPDFText already handles everything in page context - // For Chrome, it uses extension context - text = await extractPDFText(pdfUrl); - } catch (error: any) { - if (isFirefox && (error?.message?.includes('blob') || error?.message?.includes('Security') || error?.message?.includes('CSP'))) { - await new Promise(resolve => setTimeout(resolve, 2000)); - try { - text = await extractPDFText(pdfUrl); - } catch (retryError: any) { - throw new Error(`PDF extraction failed after retry: ${retryError.message}`); - } - } else { - throw new Error(`PDF extraction failed: ${error.message}`); - } - } - - // Match weighting from extracted text - const match = text.match(/weight:\s*(\d+\.?\d*)/i); - const weight = match ? match[1] : "N/A"; - - // Store and correlate weight with assessment ID - api.storage.weightings = { - ...api.storage.weightings, - [assessmentID]: weight, - }; - } catch (error: any) { - api.storage.weightings = { - ...api.storage.weightings, - [assessmentID]: "N/A", - }; - } -} - -async function parseAssessments(api: any) { - const state = await ReactFiber.find( - "[class*='AssessmentList__items___']", - ).getState(); - - const marks = state["marks"]; - if (!marks) return; - - // Dispatch for all assessments asynchronously - await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); -} - export default assessmentsAveragePlugin; diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts new file mode 100644 index 00000000..8d4c035e --- /dev/null +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -0,0 +1,555 @@ +import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts"; +import ReactFiber from "@/seqta/utils/ReactFiber.ts"; +import * as pdfjs from "pdfjs-dist"; +pdfjs.GlobalWorkerOptions.workerSrc = + "https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.mjs"; + +export async function initStorage(api: any) { + await api.storage.loaded; + + if (!api.storage.weightings) { + api.storage.weightings = {}; + } + if (!api.storage.assessments) { + api.storage.assessments = {}; + } +} + +export function clearStuck(api: any) { + let hasStuckProcessing = false; + for (const key in api.storage.weightings) { + if (api.storage.weightings[key] === "processing") { + delete api.storage.weightings[key]; + hasStuckProcessing = true; + } + } + if (hasStuckProcessing) { + api.storage.weightings = { ...api.storage.weightings }; + } +} + +// Helper function to find actual class names by their base pattern +export const getClassByPattern = ( + element: Element | Document, + basePattern: string, +): string => { + // Find all classes on the element + const classes = Array.from(element.querySelectorAll("*")) + .flatMap((el) => Array.from(el.classList)) + .filter((className) => className.startsWith(basePattern)); + + return classes.length ? classes[0] : ""; +}; + +export const letterToNumber: Record = { + "A+": 100, + A: 95, + "A-": 90, + "B+": 85, + B: 80, + "B-": 75, + "C+": 70, + C: 65, + "C-": 60, + "D+": 55, + D: 50, + "D-": 45, + "E+": 40, + E: 35, + "E-": 30, + F: 0, +}; + +export function parseGrade(text: string): number { + const str = text.trim().toUpperCase(); + if (str.includes("/")) { + const [raw, max] = str.split("/").map((n) => parseFloat(n)); + return (raw / max) * 100; + } + if (str.includes("%")) { + return parseFloat(str.replace("%", "")) || 0; + } + return letterToNumber[str] ?? 0; +} + +export function createWeightLabel(assessmentItem: Element, weighting: string | undefined) { + const statsContainer = assessmentItem.querySelector( + `[class*='AssessmentItem__stats___']`, + ) as HTMLElement; + + if (statsContainer) { + // Only add label if it hasn't been added before + if (!statsContainer.querySelector(".betterseqta-weight-label")) { + const label = statsContainer.querySelector( + `[class*='Label__Label___']`, + ) as HTMLElement; + + if (label) { + // Clone average score node + const weightLabel = label.cloneNode(true) as HTMLElement; + + // Mark as added to prevent duplicates + weightLabel.classList.add("betterseqta-weight-label"); + + const innerTextDiv = weightLabel.querySelector( + `[class*='Label__innerText___']`, + ); + + // Repurpose for showing weight + if (innerTextDiv) innerTextDiv.textContent = "Weight"; + + const textNodes = Array.from(weightLabel.childNodes).filter( + (node) => node.nodeType === Node.TEXT_NODE, + ); + + // Set weight value, discarding useless decimals (.0) + if (textNodes.length) { + textNodes[0].textContent = + weighting && weighting !== "processing" + ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` + : "N/A"; + } + + // Set position opposite to the average score node + statsContainer.style.position = "relative"; + weightLabel.style.position = "absolute"; + weightLabel.style.right = "0"; + weightLabel.style.top = "50%"; + weightLabel.style.transform = "translateY(-50%)"; + + statsContainer.appendChild(weightLabel); + } + } + } +} + +// Detect Firefox (has stricter CSP for blob URLs) +// Use userAgent instead of deprecated InstallTrigger +export const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1 && + !navigator.userAgent.toLowerCase().includes('seamonkey') && + !navigator.userAgent.toLowerCase().includes('waterfox'); + +async function fetchPDFAsArrayBuffer(url: string): Promise { + // Detect if URL is a blob URL + const isBlobUrl = url.startsWith("blob:"); + + // For Firefox, ALWAYS use page context to avoid any CSP issues + // For blob URLs in any browser, use page context + if (isBlobUrl || isFirefox) { + return new Promise((resolve, reject) => { + // Inject script into page context to fetch (bypasses Firefox CSP restrictions) + const script = document.createElement("script"); + const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`; + + // Escape URL for use in script + const escapedUrl = url.replace(/'/g, "\\'"); + + script.textContent = ` + (function() { + fetch('${escapedUrl}') + .then(response => { + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + return response.arrayBuffer(); + }) + .then(arrayBuffer => { + window.postMessage({ + type: '${requestId}', + success: true, + data: Array.from(new Uint8Array(arrayBuffer)) + }, '*'); + }) + .catch(error => { + window.postMessage({ + type: '${requestId}', + success: false, + error: error.message || String(error) + }, '*'); + }); + })(); + `; + + const messageHandler = (event: MessageEvent) => { + if (event.data?.type === requestId) { + window.removeEventListener("message", messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + + if (event.data.success) { + // Convert back to ArrayBuffer + const uint8Array = new Uint8Array(event.data.data); + resolve(uint8Array.buffer); + } else { + reject(new Error(event.data.error || "Failed to fetch PDF")); + } + } + }; + + window.addEventListener("message", messageHandler); + (document.head || document.documentElement).appendChild(script); + + // Timeout after 30 seconds + setTimeout(() => { + window.removeEventListener("message", messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + reject(new Error("Timeout fetching PDF")); + }, 30000); + }); + } else { + // Regular URL - fetch normally, but check if response URL becomes blob + try { + const response = await fetch(url, { + credentials: "include", + redirect: "follow", + }); + + // Check if response URL is a blob URL (server might redirect to blob) + if (response.url && response.url.startsWith("blob:")) { + // Re-fetch using page context + return await fetchPDFAsArrayBuffer(response.url); + } + + if (!response.ok) { + throw new Error( + `Failed to fetch PDF: ${response.status} ${response.statusText}`, + ); + } + + return await response.arrayBuffer(); + } catch (error: any) { + // If error mentions blob or security, try using page context + if ( + error?.message?.includes("blob") || + error?.message?.includes("Security") || + error?.message?.includes("CSP") + ) { + // Force use page context + return await fetchPDFAsArrayBuffer(url); + } + throw error; + } + } +} + +export async function extractPDFText(url: string): Promise { + // For Firefox, do everything in page context to avoid blob URL CSP issues + if (isFirefox) { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; + + // Escape URL for use in script (handle both single and double quotes) + const escapedUrl = url + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/"/g, '\\"'); + + script.textContent = ` + (function() { + const requestId = '${requestId}'; + const url = '${escapedUrl}'; + + // Check if pdfjs is already loaded + if (window.pdfjsLib) { + extractPDF(); + } else { + // Load pdfjs in page context + const pdfjsScript = document.createElement('script'); + pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js'; + pdfjsScript.type = 'text/javascript'; + + pdfjsScript.onload = function() { + extractPDF(); + }; + pdfjsScript.onerror = function() { + window.postMessage({ + type: requestId, + success: false, + error: 'Failed to load pdfjs library' + }, '*'); + }; + + document.head.appendChild(pdfjsScript); + } + + function extractPDF() { + try { + // Disable worker for Firefox to avoid blob URL CSP issues + // Set to empty string to disable worker completely + window.pdfjsLib.GlobalWorkerOptions.workerSrc = ''; + + // Use XMLHttpRequest instead of fetch for better blob URL handling + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + xhr.withCredentials = true; + + xhr.onload = function() { + if (xhr.status !== 200) { + window.postMessage({ + type: requestId, + success: false, + error: 'HTTP ' + xhr.status + ': ' + xhr.statusText + }, '*'); + return; + } + + try { + const arrayBuffer = xhr.response; + if (!arrayBuffer || arrayBuffer.byteLength === 0) { + throw new Error('PDF response is empty'); + } + + window.pdfjsLib.getDocument({ + data: arrayBuffer, + useSystemFonts: true, + verbosity: 0, + // Explicitly disable worker + useWorkerFetch: false, + isEvalSupported: false + }).promise + .then(pdf => { + const pagePromises = []; + for (let i = 1; i <= pdf.numPages; i++) { + pagePromises.push( + pdf.getPage(i).then(page => { + return page.getTextContent().then(content => { + return content.items.map(item => item.str).join(' '); + }); + }) + ); + } + return Promise.all(pagePromises); + }) + .then(pages => { + const text = pages.join('\\n'); + window.postMessage({ + type: requestId, + success: true, + text: text + }, '*'); + }) + .catch(error => { + window.postMessage({ + type: requestId, + success: false, + error: 'PDF parsing error: ' + (error.message || String(error)) + }, '*'); + }); + } catch (error) { + window.postMessage({ + type: requestId, + success: false, + error: 'ArrayBuffer error: ' + (error.message || String(error)) + }, '*'); + } + }; + + xhr.onerror = function() { + window.postMessage({ + type: requestId, + success: false, + error: 'Network error fetching PDF' + }, '*'); + }; + + xhr.ontimeout = function() { + window.postMessage({ + type: requestId, + success: false, + error: 'Timeout fetching PDF' + }, '*'); + }; + + xhr.timeout = 30000; + xhr.send(); + } catch (error) { + window.postMessage({ + type: requestId, + success: false, + error: 'Setup error: ' + (error.message || String(error)) + }, '*'); + } + } + })(); + `; + + const messageHandler = (event: MessageEvent) => { + if (event.data?.type === requestId) { + window.removeEventListener("message", messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + + if (event.data.success) { + resolve(event.data.text); + } else { + reject(new Error(event.data.error || "Failed to extract PDF text")); + } + } + }; + + window.addEventListener("message", messageHandler); + (document.head || document.documentElement).appendChild(script); + + // Timeout after 60 seconds (PDF parsing can take time) + setTimeout(() => { + window.removeEventListener("message", messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + reject(new Error("Timeout extracting PDF text")); + }, 60000); + }); + } else { + // Chrome - use extension context + try { + const arrayBuffer = await fetchPDFAsArrayBuffer(url); + + if (arrayBuffer.byteLength === 0) { + throw new Error("PDF response is empty"); + } + + const loadingTask = pdfjs.getDocument({ + data: arrayBuffer, + useSystemFonts: true, + }); + + const pdf = await loadingTask.promise; + + let text = ""; + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const content = await page.getTextContent(); + text += content.items.map((item: any) => item.str).join(" ") + "\n"; + } + + return text; + } catch (error) { + console.log(error); + throw error; + } + } +} + +async function handleWeightings(mark: any, api: any) { + const assessmentID = mark.id; + const metaclassID = mark.metaclassID; + const userInfo = await getUserInfo(); + const userID = userInfo.id; + const title = mark.title; + + // Skip if already processed (not "processing") + if ( + api.storage.weightings[assessmentID] != undefined && + api.storage.weightings[assessmentID] !== "processing" + ) { + return; + } + + // Set to processing + api.storage.weightings = { + ...api.storage.weightings, + [assessmentID]: "processing", + }; + + // Correlate assessment title with its ID + api.storage.assessments = { + ...api.storage.assessments, + [title.trim()]: assessmentID, + }; + + try { + const filename = + "BetterSEQTA-" + + String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); + + const printResponse = await fetch( + `${location.origin}/seqta/student/print/assessment`, + { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + credentials: "include", + body: JSON.stringify({ + fileName: filename, + id: assessmentID, + metaclass: metaclassID, + student: userID, + }), + }, + ); + + if (!printResponse.ok) { + throw new Error( + `Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`, + ); + } + + // Wait a bit for the PDF to be generated + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; + + // Check if URL is a blob URL (which extensions can't access) + if (pdfUrl.startsWith("blob:")) { + throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`); + } + + let text: string; + try { + // For Firefox, extractPDFText already handles everything in page context + // For Chrome, it uses extension context + text = await extractPDFText(pdfUrl); + } catch (error: any) { + if ( + isFirefox && + (error?.message?.includes("blob") || + error?.message?.includes("Security") || + error?.message?.includes("CSP")) + ) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + try { + text = await extractPDFText(pdfUrl); + } catch (retryError: any) { + throw new Error( + `PDF extraction failed after retry: ${retryError.message}`, + ); + } + } else { + throw new Error(`PDF extraction failed: ${error.message}`); + } + } + + // Match weighting from extracted text + const match = text.match(/weight:\s*(\d+\.?\d*)/i); + const weight = match ? match[1] : "N/A"; + + // Store and correlate weight with assessment ID + api.storage.weightings = { + ...api.storage.weightings, + [assessmentID]: weight, + }; + } catch (error: any) { + api.storage.weightings = { + ...api.storage.weightings, + [assessmentID]: "N/A", + }; + } +} + +export async function parseAssessments(api: any) { + const state = await ReactFiber.find( + "[class*='AssessmentList__items___']", + ).getState(); + + const marks = state["marks"]; + if (!marks) return; + + // Dispatch for all assessments asynchronously + await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); +} From 9ad90e9416542ded5faf14043b5c5bb4ceb882fd Mon Sep 17 00:00:00 2001 From: codefactor-io Date: Wed, 28 Jan 2026 16:08:12 +0000 Subject: [PATCH 08/10] [CodeFactor] Apply fixes to commit 87fdda4 --- src/plugins/built-in/assessmentsAverage/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index 40e090b7..af9d915d 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -8,7 +8,7 @@ import { type Plugin } from "@/plugins/core/types"; import stringToHTML from "@/seqta/utils/stringToHTML"; import { waitForElm } from "@/seqta/utils/waitForElm"; import ReactFiber from "@/seqta/utils/ReactFiber.ts"; -import { initStorage, clearStuck, getClassByPattern, parseGrade, createWeightLabel, parseAssessments, letterToNumber} from "./utils.ts"; +import { clearStuck, createWeightLabel, getClassByPattern, initStorage, letterToNumber, parseAssessments, parseGrade} from "./utils.ts"; // Storage interface weightingsStorage { From cc3f06b38339c2f726ba11861d1e9ba4821977aa Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Thu, 29 Jan 2026 14:50:48 +0800 Subject: [PATCH 09/10] Small refactor (hopefully appease CodeFactor?) --- .../built-in/assessmentsAverage/index.ts | 71 ++---------------- .../built-in/assessmentsAverage/utils.ts | 74 ++++++++++++++++++- 2 files changed, 80 insertions(+), 65 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index af9d915d..be3b6bd4 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -8,7 +8,7 @@ import { type Plugin } from "@/plugins/core/types"; import stringToHTML from "@/seqta/utils/stringToHTML"; import { waitForElm } from "@/seqta/utils/waitForElm"; import ReactFiber from "@/seqta/utils/ReactFiber.ts"; -import { clearStuck, createWeightLabel, getClassByPattern, initStorage, letterToNumber, parseAssessments, parseGrade} from "./utils.ts"; +import { clearStuck, getClassByPattern, initStorage, letterToNumber, parseAssessments, processAssessments} from "./utils.ts"; // Storage interface weightingsStorage { @@ -128,68 +128,13 @@ const assessmentsAveragePlugin: Plugin = { !item.querySelector(`[class*='AssessmentItem__title___']`)?.textContent?.includes("Subject Average"), ); - // Match marks to assessment items and calculate weighted average - let weightedTotal = 0; - let totalWeight = 0; - let hasInaccurateWeighting = false; - let count = 0; - - // Iterate through assessments for processing - for (const assessmentItem of assessmentItems) { - const gradeElement = assessmentItem.querySelector( - `[class*='Thermoscore__text___']`, - ); - - if (!gradeElement) continue; - - const grade = parseGrade(gradeElement.textContent || ""); - if (grade <= 0) continue; - - const titleEl = assessmentItem.querySelector( - `[class*='AssessmentItem__title___']`, - ); - if (!titleEl) continue; - - const title = titleEl.textContent?.trim(); - if (!title) continue; - - // Get correlated assessment ID in order to fetch weightings - const assessmentID = api.storage.assessments?.[title]; - const weighting = assessmentID - ? api.storage.weightings?.[assessmentID] - : undefined; - - - // Creates a weighting label next to the average score - createWeightLabel(assessmentItem, weighting) - - // Check if weighting is unavailable or still processing - if ( - weighting === null || - weighting === undefined || - weighting === "N/A" || - weighting === "processing" - ) { - hasInaccurateWeighting = true; - // Fall back to equal weighting if unavailable - weightedTotal += grade; - totalWeight += 1; - } else { - const weight = parseFloat(weighting); - - // If weight is a positive number, add to total - if (!isNaN(weight) && weight >= 0) { - weightedTotal += grade * weight; - totalWeight += weight; - } else { - // Invalid weight, use equal weighting - weightedTotal += grade; - totalWeight += 1; - hasInaccurateWeighting = true; - } - } - count++; - } + // Tally up weightedTotal, totalWeight, count, determine if weighting is accurate, and display a weight label per assessment + const { + weightedTotal, + totalWeight, + hasInaccurateWeighting, + count, + } = await processAssessments(api, assessmentItems); if (!count || totalWeight === 0) return; diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index 8d4c035e..320dd3a9 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -60,7 +60,7 @@ export const letterToNumber: Record = { F: 0, }; -export function parseGrade(text: string): number { +function parseGrade(text: string): number { const str = text.trim().toUpperCase(); if (str.includes("/")) { const [raw, max] = str.split("/").map((n) => parseFloat(n)); @@ -72,7 +72,7 @@ export function parseGrade(text: string): number { return letterToNumber[str] ?? 0; } -export function createWeightLabel(assessmentItem: Element, weighting: string | undefined) { +function createWeightLabel(assessmentItem: Element, weighting: string | undefined) { const statsContainer = assessmentItem.querySelector( `[class*='AssessmentItem__stats___']`, ) as HTMLElement; @@ -553,3 +553,73 @@ export async function parseAssessments(api: any) { // Dispatch for all assessments asynchronously await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); } + +// Tally up weightedTotal, totalWeight, count, determine if weighting is accurate, and display a weight label per assessment +export async function processAssessments(api: any, assessmentItems: Element[]) { + let weightedTotal = 0; + let totalWeight = 0; + let hasInaccurateWeighting = false; + let count = 0; + + for (const assessmentItem of assessmentItems) { + const gradeElement = assessmentItem.querySelector( + `[class*='Thermoscore__text___']`, + ); + + if (!gradeElement) continue; + + const grade = parseGrade(gradeElement.textContent || ""); + if (grade <= 0) continue; + + const titleEl = assessmentItem.querySelector( + `[class*='AssessmentItem__title___']`, + ); + if (!titleEl) continue; + + const title = titleEl.textContent?.trim(); + if (!title) continue; + + // Get correlated assessment ID in order to fetch weightings + const assessmentID = api.storage.assessments?.[title]; + const weighting = assessmentID + ? api.storage.weightings?.[assessmentID] + : undefined; + + // Creates a weighting label next to the average score + createWeightLabel(assessmentItem, weighting); + + // Check if weighting is unavailable or still processing + if ( + weighting === null || + weighting === undefined || + weighting === "N/A" || + weighting === "processing" + ) { + hasInaccurateWeighting = true; + // Fall back to equal weighting if unavailable + weightedTotal += grade; + totalWeight += 1; + } else { + const weight = parseFloat(weighting); + + // If weight is a positive number, add to total + if (!isNaN(weight) && weight >= 0) { + weightedTotal += grade * weight; + totalWeight += weight; + } else { + // Invalid weight, use equal weighting + weightedTotal += grade; + totalWeight += 1; + hasInaccurateWeighting = true; + } + } + count++; + } + + return { + weightedTotal, + totalWeight, + hasInaccurateWeighting, + count, + }; +} \ No newline at end of file From 3aef2312d09d697d452c28b5ef254e5ff4d8b832 Mon Sep 17 00:00:00 2001 From: SethBurkart123 Date: Fri, 30 Jan 2026 16:07:12 +1100 Subject: [PATCH 10/10] feat: cleanup and comment removal --- bun.lock | 27 + src/SEQTA.ts | 37 +- .../built-in/assessmentsAverage/index.ts | 53 +- .../built-in/assessmentsAverage/utils.ts | 515 ++++++++---------- src/seqta/ui/AddBetterSEQTAElements.ts | 198 +++---- src/seqta/utils/Loaders/LoadHomePage.ts | 507 +++++++---------- 6 files changed, 573 insertions(+), 764 deletions(-) diff --git a/bun.lock b/bun.lock index 1fd74ab4..fefefbae 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "mathjs": "^14.4.0", "million": "^3.1.11", "motion": "^12.4.12", + "pdfjs-dist": "^5.4.530", "postcss": "^8.5.3", "react": "17", "react-best-gradient-color-picker": "3.0.11", @@ -265,6 +266,30 @@ "@msgpack/msgpack": ["@msgpack/msgpack@3.1.2", "", {}, "sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ=="], + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.89", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k29cR/Zl20WLYM7M8YePevRu2VQRaKcRedYr1V/8FFHkyIQ8kShEV+MPoPGi+znvmd17Eqjy2Pk2F2kpM2umVg=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.89", "", { "os": "darwin", "cpu": "x64" }, "sha512-iUragqhBrA5FqU13pkhYBDbUD1WEAIlT8R2+fj6xHICY2nemzwMUI8OENDhRh7zuL06YDcRwENbjAVxOmaX9jg=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.89", "", { "os": "linux", "cpu": "arm" }, "sha512-y3SM9sfDWasY58ftoaI09YBFm35Ig8tosZqgahLJ2WGqawCusGNPV9P0/4PsrLOCZqGg629WxexQMY25n7zcvA=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.89", "", { "os": "linux", "cpu": "arm64" }, "sha512-NEoF9y8xq5fX8HG8aZunBom1ILdTwt7ayBzSBIwrmitk7snj4W6Fz/yN/ZOmlM1iyzHDNX5Xn0n+VgWCF8BEdA=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.89", "", { "os": "linux", "cpu": "arm64" }, "sha512-UQQkIEzV12/l60j1ziMjZ+mtodICNUbrd205uAhbyTw0t60CrC/EsKb5/aJWGq1wM0agvcgZV72JJCKfLS6+4w=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.89", "", { "os": "linux", "cpu": "none" }, "sha512-1/VmEoFaIO6ONeeEMGoWF17wOYZOl5hxDC1ios2Bkz/oQjbJJ8DY/X22vWTmvuUKWWhBVlo63pxLGZbjJU/heA=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.89", "", { "os": "linux", "cpu": "x64" }, "sha512-ebLuqkCuaPIkKgKH9q4+pqWi1tkPOfiTk5PM1LKR1tB9iO9sFNVSIgwEp+SJreTSbA2DK5rW8lQXiN78SjtcvA=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.89", "", { "os": "linux", "cpu": "x64" }, "sha512-w+5qxHzplvA4BkHhCaizNMLLXiI+CfP84YhpHm/PqMub4u8J0uOAv+aaGv40rYEYra5hHRWr9LUd6cfW32o9/A=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.89", "", { "os": "win32", "cpu": "arm64" }, "sha512-DmyXa5lJHcjOsDC78BM3bnEECqbK3xASVMrKfvtT/7S7Z8NGQOugvu+L7b41V6cexCd34mBWgMOsjoEBceeB1Q=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -1133,6 +1158,8 @@ "pathval": ["pathval@1.1.1", "", {}, "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ=="], + "pdfjs-dist": ["pdfjs-dist@5.4.530", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.84" } }, "sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/src/SEQTA.ts b/src/SEQTA.ts index 9d4dc8c7..e64cd716 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -25,37 +25,20 @@ if (document.childNodes[1]) { init(); } -/** - * Initializes BetterSEQTA+ on a SEQTA page. - * - * This function performs the following steps: - * 1. Verifies that the current page is a SEQTA page. - * 2. Injects CSS styles for document loading. - * 3. Changes the page's favicon. - * 4. Initializes the extension's settings state. - * 5. Sets default storage if settings are not already defined. - * 6. Calls the main function to apply core BetterSEQTA+ modifications. - * 7. Initializes legacy and new plugins if the extension is enabled. - * 8. Logs success or error messages during initialization. - */ async function init() { - const hasSEQTATitle = document.title.includes("SEQTA Learn"); - - if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) { - // Verify we are on a SEQTA page + if (hasSEQTAText && document.title.includes("SEQTA Learn") && !IsSEQTAPage) { IsSEQTAPage = true; console.info("[BetterSEQTA+] Verified SEQTA Page"); - const documentLoadStyle = document.createElement("style"); - documentLoadStyle.textContent = documentLoadCSS; - document.head.appendChild(documentLoadStyle); + const style = document.createElement("style"); + style.textContent = documentLoadCSS; + document.head.appendChild(style); - const icons = - document.querySelectorAll('link[rel*="icon"]'); - - icons.forEach((link) => { - link.href = icon48; - }); + document + .querySelectorAll('link[rel*="icon"]') + .forEach((link) => { + link.href = icon48; + }); try { await initializeSettingsState(); @@ -80,7 +63,7 @@ async function init() { console.info( "[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.", ); - } catch (error: any) { + } catch (error) { console.error(error); } } diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index be3b6bd4..6db54831 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -8,9 +8,15 @@ import { type Plugin } from "@/plugins/core/types"; import stringToHTML from "@/seqta/utils/stringToHTML"; import { waitForElm } from "@/seqta/utils/waitForElm"; import ReactFiber from "@/seqta/utils/ReactFiber.ts"; -import { clearStuck, getClassByPattern, initStorage, letterToNumber, parseAssessments, processAssessments} from "./utils.ts"; +import { + clearStuck, + getClassByPattern, + initStorage, + letterToNumber, + parseAssessments, + processAssessments, +} from "./utils.ts"; -// Storage interface weightingsStorage { weightings: Record; assessments: Record; @@ -40,11 +46,7 @@ const assessmentsAveragePlugin: Plugin = { settings: instance.settings, run: async (api) => { - - // Ensure storage is ready for use await initStorage(api); - - // Clear any stuck "processing" states so they can retry clearStuck(api); api.seqta.onMount(".assessmentsWrapper", async () => { @@ -57,13 +59,11 @@ const assessmentsAveragePlugin: Plugin = { await parseAssessments(api); - // Find actual class names from the DOM const sampleAssessmentItem = document.querySelector( "[class*='AssessmentItem__AssessmentItem___']", ); if (!sampleAssessmentItem) return; - // Extract all necessary class patterns from a sample assessment item const assessmentItemClass = Array.from(sampleAssessmentItem.classList).find((c) => c.startsWith("AssessmentItem__AssessmentItem___"), @@ -86,7 +86,6 @@ const assessmentsAveragePlugin: Plugin = { "AssessmentItem__title___", ); - // Get Thermoscore classes const thermoscoreElement = document.querySelector( "[class*='Thermoscore__Thermoscore___']", ); @@ -105,36 +104,30 @@ const assessmentsAveragePlugin: Plugin = { "Thermoscore__text___", ); - // Find assessment list const assessmentsList = document.querySelector( "#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']", ); if (!assessmentsList) return; - // Get marks from React state to match with DOM elements const state = await ReactFiber.find( "[class*='AssessmentList__items___']", ).getState(); const marks = state["marks"]; if (!marks || !marks.length) return; - // Parse and average grades - - // Get all assessment items (excluding the average we might have added) const assessmentItems = Array.from( - assessmentsList.querySelectorAll(`[class*='AssessmentItem__AssessmentItem___']`), + assessmentsList.querySelectorAll( + `[class*='AssessmentItem__AssessmentItem___']`, + ), ).filter( (item) => - !item.querySelector(`[class*='AssessmentItem__title___']`)?.textContent?.includes("Subject Average"), + !item + .querySelector(`[class*='AssessmentItem__title___']`) + ?.textContent?.includes("Subject Average"), ); - // Tally up weightedTotal, totalWeight, count, determine if weighting is accurate, and display a weight label per assessment - const { - weightedTotal, - totalWeight, - hasInaccurateWeighting, - count, - } = await processAssessments(api, assessmentItems); + const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = + await processAssessments(api, assessmentItems); if (!count || totalWeight === 0) return; @@ -153,13 +146,11 @@ const assessmentsAveragePlugin: Plugin = { ? letterAvg : `${avg.toFixed(2)}%`; - // Prevent duplicate const existing = assessmentsList.querySelector( `[class*='AssessmentItem__title___']`, ); if (existing?.textContent === "Subject Average") return; - // Build warning message if needed let warningHTML = ""; if (hasInaccurateWeighting) { warningHTML = /* html */ ` @@ -169,8 +160,8 @@ const assessmentsAveragePlugin: Plugin = { `; } - // Use the dynamic class names in the HTML template - const averageElement = stringToHTML(/* html */ ` + assessmentsList.insertBefore( + stringToHTML(/* html */ `
@@ -182,13 +173,13 @@ const assessmentsAveragePlugin: Plugin = {
-
${display}
+
${display}
- `).firstChild; - - assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild); + `).firstChild!, + assessmentsList.firstChild, + ); }); }, }; diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index 320dd3a9..bb93c090 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -33,7 +33,6 @@ export const getClassByPattern = ( element: Element | Document, basePattern: string, ): string => { - // Find all classes on the element const classes = Array.from(element.querySelectorAll("*")) .flatMap((el) => Array.from(el.classList)) .filter((className) => className.startsWith(basePattern)); @@ -72,76 +71,66 @@ function parseGrade(text: string): number { return letterToNumber[str] ?? 0; } -function createWeightLabel(assessmentItem: Element, weighting: string | undefined) { +function createWeightLabel( + assessmentItem: Element, + weighting: string | undefined, +) { const statsContainer = assessmentItem.querySelector( `[class*='AssessmentItem__stats___']`, ) as HTMLElement; - if (statsContainer) { - // Only add label if it hasn't been added before - if (!statsContainer.querySelector(".betterseqta-weight-label")) { - const label = statsContainer.querySelector( - `[class*='Label__Label___']`, - ) as HTMLElement; + if ( + !statsContainer || + statsContainer.querySelector(".betterseqta-weight-label") + ) + return; - if (label) { - // Clone average score node - const weightLabel = label.cloneNode(true) as HTMLElement; + const label = statsContainer.querySelector( + `[class*='Label__Label___']`, + ) as HTMLElement; - // Mark as added to prevent duplicates - weightLabel.classList.add("betterseqta-weight-label"); + if (!label) return; - const innerTextDiv = weightLabel.querySelector( - `[class*='Label__innerText___']`, - ); + const weightLabel = label.cloneNode(true) as HTMLElement; + weightLabel.classList.add("betterseqta-weight-label"); - // Repurpose for showing weight - if (innerTextDiv) innerTextDiv.textContent = "Weight"; + const innerTextDiv = weightLabel.querySelector( + `[class*='Label__innerText___']`, + ); + if (innerTextDiv) innerTextDiv.textContent = "Weight"; - const textNodes = Array.from(weightLabel.childNodes).filter( - (node) => node.nodeType === Node.TEXT_NODE, - ); + const textNodes = Array.from(weightLabel.childNodes).filter( + (node) => node.nodeType === Node.TEXT_NODE, + ); - // Set weight value, discarding useless decimals (.0) - if (textNodes.length) { - textNodes[0].textContent = - weighting && weighting !== "processing" - ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` - : "N/A"; - } - - // Set position opposite to the average score node - statsContainer.style.position = "relative"; - weightLabel.style.position = "absolute"; - weightLabel.style.right = "0"; - weightLabel.style.top = "50%"; - weightLabel.style.transform = "translateY(-50%)"; - - statsContainer.appendChild(weightLabel); - } - } + if (textNodes.length) { + textNodes[0].textContent = + weighting && weighting !== "processing" + ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` + : "N/A"; } + + statsContainer.style.position = "relative"; + weightLabel.style.position = "absolute"; + weightLabel.style.right = "0"; + weightLabel.style.top = "50%"; + weightLabel.style.transform = "translateY(-50%)"; + + statsContainer.appendChild(weightLabel); } -// Detect Firefox (has stricter CSP for blob URLs) -// Use userAgent instead of deprecated InstallTrigger -export const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1 && - !navigator.userAgent.toLowerCase().includes('seamonkey') && - !navigator.userAgent.toLowerCase().includes('waterfox'); +export const isFirefox = + navigator.userAgent.toLowerCase().indexOf("firefox") > -1 && + !navigator.userAgent.toLowerCase().includes("seamonkey") && + !navigator.userAgent.toLowerCase().includes("waterfox"); async function fetchPDFAsArrayBuffer(url: string): Promise { - // Detect if URL is a blob URL const isBlobUrl = url.startsWith("blob:"); - // For Firefox, ALWAYS use page context to avoid any CSP issues - // For blob URLs in any browser, use page context if (isBlobUrl || isFirefox) { return new Promise((resolve, reject) => { - // Inject script into page context to fetch (bypasses Firefox CSP restrictions) const script = document.createElement("script"); const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`; - - // Escape URL for use in script const escapedUrl = url.replace(/'/g, "\\'"); script.textContent = ` @@ -178,9 +167,7 @@ async function fetchPDFAsArrayBuffer(url: string): Promise { } if (event.data.success) { - // Convert back to ArrayBuffer - const uint8Array = new Uint8Array(event.data.data); - resolve(uint8Array.buffer); + resolve(new Uint8Array(event.data.data).buffer); } else { reject(new Error(event.data.error || "Failed to fetch PDF")); } @@ -190,7 +177,6 @@ async function fetchPDFAsArrayBuffer(url: string): Promise { window.addEventListener("message", messageHandler); (document.head || document.documentElement).appendChild(script); - // Timeout after 30 seconds setTimeout(() => { window.removeEventListener("message", messageHandler); if (script.parentNode) { @@ -199,241 +185,226 @@ async function fetchPDFAsArrayBuffer(url: string): Promise { reject(new Error("Timeout fetching PDF")); }, 30000); }); - } else { - // Regular URL - fetch normally, but check if response URL becomes blob - try { - const response = await fetch(url, { - credentials: "include", - redirect: "follow", - }); + } - // Check if response URL is a blob URL (server might redirect to blob) - if (response.url && response.url.startsWith("blob:")) { - // Re-fetch using page context - return await fetchPDFAsArrayBuffer(response.url); - } + try { + const response = await fetch(url, { + credentials: "include", + redirect: "follow", + }); - if (!response.ok) { - throw new Error( - `Failed to fetch PDF: ${response.status} ${response.statusText}`, - ); - } - - return await response.arrayBuffer(); - } catch (error: any) { - // If error mentions blob or security, try using page context - if ( - error?.message?.includes("blob") || - error?.message?.includes("Security") || - error?.message?.includes("CSP") - ) { - // Force use page context - return await fetchPDFAsArrayBuffer(url); - } - throw error; + if (response.url && response.url.startsWith("blob:")) { + return await fetchPDFAsArrayBuffer(response.url); } + + if (!response.ok) { + throw new Error( + `Failed to fetch PDF: ${response.status} ${response.statusText}`, + ); + } + + return await response.arrayBuffer(); + } catch (error: any) { + if ( + error?.message?.includes("blob") || + error?.message?.includes("Security") || + error?.message?.includes("CSP") + ) { + return await fetchPDFAsArrayBuffer(url); + } + throw error; } } export async function extractPDFText(url: string): Promise { - // For Firefox, do everything in page context to avoid blob URL CSP issues - if (isFirefox) { - return new Promise((resolve, reject) => { - const script = document.createElement("script"); - const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; + try { + if (isFirefox) { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; - // Escape URL for use in script (handle both single and double quotes) - const escapedUrl = url - .replace(/\\/g, "\\\\") - .replace(/'/g, "\\'") - .replace(/"/g, '\\"'); + const escapedUrl = url + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/"/g, '\\"'); - script.textContent = ` - (function() { - const requestId = '${requestId}'; - const url = '${escapedUrl}'; - - // Check if pdfjs is already loaded - if (window.pdfjsLib) { - extractPDF(); - } else { - // Load pdfjs in page context - const pdfjsScript = document.createElement('script'); - pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js'; - pdfjsScript.type = 'text/javascript'; + script.textContent = ` + (function() { + const requestId = '${requestId}'; + const url = '${escapedUrl}'; - pdfjsScript.onload = function() { + if (window.pdfjsLib) { extractPDF(); - }; - pdfjsScript.onerror = function() { - window.postMessage({ - type: requestId, - success: false, - error: 'Failed to load pdfjs library' - }, '*'); - }; + } else { + const pdfjsScript = document.createElement('script'); + pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js'; + pdfjsScript.type = 'text/javascript'; + + pdfjsScript.onload = function() { + extractPDF(); + }; + pdfjsScript.onerror = function() { + window.postMessage({ + type: requestId, + success: false, + error: 'Failed to load pdfjs library' + }, '*'); + }; + + document.head.appendChild(pdfjsScript); + } - document.head.appendChild(pdfjsScript); - } - - function extractPDF() { - try { - // Disable worker for Firefox to avoid blob URL CSP issues - // Set to empty string to disable worker completely - window.pdfjsLib.GlobalWorkerOptions.workerSrc = ''; - - // Use XMLHttpRequest instead of fetch for better blob URL handling - const xhr = new XMLHttpRequest(); - xhr.open('GET', url, true); - xhr.responseType = 'arraybuffer'; - xhr.withCredentials = true; - - xhr.onload = function() { - if (xhr.status !== 200) { - window.postMessage({ - type: requestId, - success: false, - error: 'HTTP ' + xhr.status + ': ' + xhr.statusText - }, '*'); - return; - } + function extractPDF() { + try { + window.pdfjsLib.GlobalWorkerOptions.workerSrc = ''; - try { - const arrayBuffer = xhr.response; - if (!arrayBuffer || arrayBuffer.byteLength === 0) { - throw new Error('PDF response is empty'); + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + xhr.withCredentials = true; + + xhr.onload = function() { + if (xhr.status !== 200) { + window.postMessage({ + type: requestId, + success: false, + error: 'HTTP ' + xhr.status + ': ' + xhr.statusText + }, '*'); + return; } - window.pdfjsLib.getDocument({ - data: arrayBuffer, - useSystemFonts: true, - verbosity: 0, - // Explicitly disable worker - useWorkerFetch: false, - isEvalSupported: false - }).promise - .then(pdf => { - const pagePromises = []; - for (let i = 1; i <= pdf.numPages; i++) { - pagePromises.push( - pdf.getPage(i).then(page => { - return page.getTextContent().then(content => { - return content.items.map(item => item.str).join(' '); - }); - }) - ); - } - return Promise.all(pagePromises); - }) - .then(pages => { - const text = pages.join('\\n'); - window.postMessage({ - type: requestId, - success: true, - text: text - }, '*'); - }) - .catch(error => { - window.postMessage({ - type: requestId, - success: false, - error: 'PDF parsing error: ' + (error.message || String(error)) - }, '*'); - }); - } catch (error) { + try { + const arrayBuffer = xhr.response; + if (!arrayBuffer || arrayBuffer.byteLength === 0) { + throw new Error('PDF response is empty'); + } + + window.pdfjsLib.getDocument({ + data: arrayBuffer, + useSystemFonts: true, + verbosity: 0, + useWorkerFetch: false, + isEvalSupported: false + }).promise + .then(pdf => { + const pagePromises = []; + for (let i = 1; i <= pdf.numPages; i++) { + pagePromises.push( + pdf.getPage(i).then(page => { + return page.getTextContent().then(content => { + return content.items.map(item => item.str).join(' '); + }); + }) + ); + } + return Promise.all(pagePromises); + }) + .then(pages => { + const text = pages.join('\\n'); + window.postMessage({ + type: requestId, + success: true, + text: text + }, '*'); + }) + .catch(error => { + window.postMessage({ + type: requestId, + success: false, + error: 'PDF parsing error: ' + (error.message || String(error)) + }, '*'); + }); + } catch (error) { + window.postMessage({ + type: requestId, + success: false, + error: 'ArrayBuffer error: ' + (error.message || String(error)) + }, '*'); + } + }; + + xhr.onerror = function() { window.postMessage({ type: requestId, success: false, - error: 'ArrayBuffer error: ' + (error.message || String(error)) + error: 'Network error fetching PDF' }, '*'); - } - }; - - xhr.onerror = function() { + }; + + xhr.ontimeout = function() { + window.postMessage({ + type: requestId, + success: false, + error: 'Timeout fetching PDF' + }, '*'); + }; + + xhr.timeout = 30000; + xhr.send(); + } catch (error) { window.postMessage({ type: requestId, success: false, - error: 'Network error fetching PDF' + error: 'Setup error: ' + (error.message || String(error)) }, '*'); - }; - - xhr.ontimeout = function() { - window.postMessage({ - type: requestId, - success: false, - error: 'Timeout fetching PDF' - }, '*'); - }; - - xhr.timeout = 30000; - xhr.send(); - } catch (error) { - window.postMessage({ - type: requestId, - success: false, - error: 'Setup error: ' + (error.message || String(error)) - }, '*'); + } + } + })(); + `; + + const messageHandler = (event: MessageEvent) => { + if (event.data?.type === requestId) { + window.removeEventListener("message", messageHandler); + if (script.parentNode) { + script.parentNode.removeChild(script); + } + + if (event.data.success) { + resolve(event.data.text); + } else { + reject( + new Error(event.data.error || "Failed to extract PDF text"), + ); } } - })(); - `; + }; - const messageHandler = (event: MessageEvent) => { - if (event.data?.type === requestId) { + window.addEventListener("message", messageHandler); + (document.head || document.documentElement).appendChild(script); + + setTimeout(() => { window.removeEventListener("message", messageHandler); if (script.parentNode) { script.parentNode.removeChild(script); } - - if (event.data.success) { - resolve(event.data.text); - } else { - reject(new Error(event.data.error || "Failed to extract PDF text")); - } - } - }; - - window.addEventListener("message", messageHandler); - (document.head || document.documentElement).appendChild(script); - - // Timeout after 60 seconds (PDF parsing can take time) - setTimeout(() => { - window.removeEventListener("message", messageHandler); - if (script.parentNode) { - script.parentNode.removeChild(script); - } - reject(new Error("Timeout extracting PDF text")); - }, 60000); - }); - } else { - // Chrome - use extension context - try { - const arrayBuffer = await fetchPDFAsArrayBuffer(url); - - if (arrayBuffer.byteLength === 0) { - throw new Error("PDF response is empty"); - } - - const loadingTask = pdfjs.getDocument({ - data: arrayBuffer, - useSystemFonts: true, + reject(new Error("Timeout extracting PDF text")); + }, 60000); }); - - const pdf = await loadingTask.promise; - - let text = ""; - - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const content = await page.getTextContent(); - text += content.items.map((item: any) => item.str).join(" ") + "\n"; - } - - return text; - } catch (error) { - console.log(error); - throw error; } + + const arrayBuffer = await fetchPDFAsArrayBuffer(url); + + if (arrayBuffer.byteLength === 0) { + throw new Error("PDF response is empty"); + } + + const pdf = await pdfjs.getDocument({ + data: arrayBuffer, + useSystemFonts: true, + }).promise; + + let text = ""; + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const content = await page.getTextContent(); + text += content.items.map((item: any) => item.str).join(" ") + "\n"; + } + + return text; + } catch (error) { + console.error("[BetterSEQTA+] Failed to extract PDF text:", error); + throw error; } } @@ -444,7 +415,6 @@ async function handleWeightings(mark: any, api: any) { const userID = userInfo.id; const title = mark.title; - // Skip if already processed (not "processing") if ( api.storage.weightings[assessmentID] != undefined && api.storage.weightings[assessmentID] !== "processing" @@ -452,13 +422,11 @@ async function handleWeightings(mark: any, api: any) { return; } - // Set to processing api.storage.weightings = { ...api.storage.weightings, [assessmentID]: "processing", }; - // Correlate assessment title with its ID api.storage.assessments = { ...api.storage.assessments, [title.trim()]: assessmentID, @@ -490,20 +458,16 @@ async function handleWeightings(mark: any, api: any) { ); } - // Wait a bit for the PDF to be generated await new Promise((resolve) => setTimeout(resolve, 1000)); const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; - // Check if URL is a blob URL (which extensions can't access) if (pdfUrl.startsWith("blob:")) { throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`); } let text: string; try { - // For Firefox, extractPDFText already handles everything in page context - // For Chrome, it uses extension context text = await extractPDFText(pdfUrl); } catch (error: any) { if ( @@ -513,26 +477,17 @@ async function handleWeightings(mark: any, api: any) { error?.message?.includes("CSP")) ) { await new Promise((resolve) => setTimeout(resolve, 2000)); - try { - text = await extractPDFText(pdfUrl); - } catch (retryError: any) { - throw new Error( - `PDF extraction failed after retry: ${retryError.message}`, - ); - } + text = await extractPDFText(pdfUrl); } else { throw new Error(`PDF extraction failed: ${error.message}`); } } - // Match weighting from extracted text const match = text.match(/weight:\s*(\d+\.?\d*)/i); - const weight = match ? match[1] : "N/A"; - // Store and correlate weight with assessment ID api.storage.weightings = { ...api.storage.weightings, - [assessmentID]: weight, + [assessmentID]: match ? match[1] : "N/A", }; } catch (error: any) { api.storage.weightings = { @@ -550,11 +505,9 @@ export async function parseAssessments(api: any) { const marks = state["marks"]; if (!marks) return; - // Dispatch for all assessments asynchronously await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); } -// Tally up weightedTotal, totalWeight, count, determine if weighting is accurate, and display a weight label per assessment export async function processAssessments(api: any, assessmentItems: Element[]) { let weightedTotal = 0; let totalWeight = 0; @@ -579,16 +532,13 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { const title = titleEl.textContent?.trim(); if (!title) continue; - // Get correlated assessment ID in order to fetch weightings const assessmentID = api.storage.assessments?.[title]; const weighting = assessmentID ? api.storage.weightings?.[assessmentID] : undefined; - // Creates a weighting label next to the average score createWeightLabel(assessmentItem, weighting); - // Check if weighting is unavailable or still processing if ( weighting === null || weighting === undefined || @@ -596,18 +546,15 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { weighting === "processing" ) { hasInaccurateWeighting = true; - // Fall back to equal weighting if unavailable weightedTotal += grade; totalWeight += 1; } else { const weight = parseFloat(weighting); - // If weight is a positive number, add to total if (!isNaN(weight) && weight >= 0) { weightedTotal += grade * weight; totalWeight += weight; } else { - // Invalid weight, use equal weighting weightedTotal += grade; totalWeight += 1; hasInaccurateWeighting = true; @@ -622,4 +569,4 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { hasInaccurateWeighting, count, }; -} \ No newline at end of file +} diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index a285fe0b..d7c23d26 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -30,11 +30,10 @@ export async function getUserInfo() { }), }); - const responseData = await response.json(); - cachedUserInfo = responseData.payload; + cachedUserInfo = (await response.json()).payload; return cachedUserInfo; } catch (error) { - console.error("Error fetching user info:", error); + console.error("[BetterSEQTA+] Failed to get user info:", error); throw error; } } @@ -61,7 +60,7 @@ export async function AddBetterSEQTAElements() { handleStudentData(), ]); } catch (error) { - console.error("Error initializing UI elements:", error); + console.error("[BetterSEQTA+] Failed to initialize UI elements:", error); } setupEventListeners(); @@ -80,20 +79,18 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) { div.classList.add("titlebar"); container.append(div); - const NewButton = stringToHTML( - /* html */`
  • ` + fragment.appendChild( + stringToHTML( + /* html */ `
  • `, + ).firstChild!, ); - if (NewButton.firstChild) { - fragment.appendChild(NewButton.firstChild); - } } async function handleUserInfo() { try { - const info = await getUserInfo(); - updateUserInfo(info); + updateUserInfo(await getUserInfo()); } catch (error) { - console.error("Error fetching and processing student data:", error); + console.error("[BetterSEQTA+] Failed to handle user info:", error); } } @@ -117,30 +114,32 @@ function updateUserInfo(info: { }) { const titlebar = document.getElementsByClassName("titlebar")[0]; - const userInfo = stringToHTML(/* html */ ` -
    - -
    -
    - `).firstChild; - titlebar.append(userInfo!); - - const userinfo = stringToHTML(/* html */ ` -
    -
    -
    -

    -

    ${info.userDesc}

    -
    -

    ${info.meta.code} // ${info.meta.governmentID}

    + titlebar.append( + stringToHTML(/* html */ ` +
    + +
    -
    - `).firstChild; - titlebar.append(userinfo!); + `).firstChild!, + ); - var logoutbutton = document.getElementsByClassName("logout")[0]; - var userInfosvgdiv = document.getElementById("logouttooltip")!; - userInfosvgdiv.appendChild(logoutbutton); + titlebar.append( + stringToHTML(/* html */ ` +
    +
    +
    +

    +

    ${info.userDesc}

    +
    +

    ${info.meta.code} // ${info.meta.governmentID}

    +
    +
    + `).firstChild!, + ); + + document + .getElementById("logouttooltip")! + .appendChild(document.getElementsByClassName("logout")[0]); } async function handleStudentData() { @@ -156,48 +155,40 @@ async function handleStudentData() { }, ); - const responseData = await response.json(); - let students = responseData.payload; - await updateStudentInfo(students); + await updateStudentInfo((await response.json()).payload); } catch (error) { - console.error("Error fetching and processing student data:", error); + console.error("[BetterSEQTA+] Failed to handle student data:", error); } } async function updateStudentInfo(students: any) { const info = await getUserInfo(); - var index = students.findIndex(function (person: any) { - return ( + const index = students.findIndex( + (person: any) => person.firstname == info.userDesc.split(" ")[0] && - person.surname == info.userDesc.split(" ")[1] - ); - }); + person.surname == info.userDesc.split(" ")[1], + ); - const houseelement = document.getElementsByClassName("userInfohouse")[0] as HTMLElement; - - // Fallback to N/A - let text = 'N/A'; + const houseelement = document.getElementsByClassName( + "userInfohouse", + )[0] as HTMLElement; const student = students[index] ?? {}; + let text = "N/A"; - // If student has a house, prefer to show year + house. If no year, only show house. if (student.house) { text = `${student.year ?? ""}${student.house}`; - // If house_colour exists, compute colour if (student.house_colour) { houseelement.style.background = student.house_colour; - try { const colorresult = GetThresholdOfColor(student.house_colour); houseelement.style.color = colorresult && colorresult > 300 ? "black" : "white"; - - } catch (err) { - // Colour calculation failed, no text colour set + } catch { + // Invalid color format, leave text color as default } } } else if (student.year) { - // No house, only year will be shown text = student.year; } @@ -205,15 +196,13 @@ async function updateStudentInfo(students: any) { } function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) { - const NewsButtonStr = - '
  • '; - const NewsButton = stringToHTML(NewsButtonStr); + fragment.appendChild( + stringToHTML( + '
  • ', + ).firstChild!, + ); - if (NewsButton.firstChild) { - fragment.appendChild(NewsButton.firstChild); - } - - let iconCover = document.createElement("div"); + const iconCover = document.createElement("div"); iconCover.classList.add("icon-cover"); iconCover.id = "icon-cover"; menu.appendChild(iconCover); @@ -252,46 +241,42 @@ function setupEventListeners() { } async function createSettingsButton() { - let SettingsButton = stringToHTML(/* html */ ` - - `); - let ContentDiv = document.getElementById("content"); - ContentDiv!.append(SettingsButton.firstChild!); + document.getElementById("content")!.append( + stringToHTML(/* html */ ` + + `).firstChild!, + ); } function GetLightDarkModeString() { - if (settingsState.DarkMode) { - return "Switch to light theme"; - } else { - return "Switch to dark theme"; - } + return settingsState.DarkMode + ? "Switch to light theme" + : "Switch to dark theme"; } async function addDarkLightToggle() { - const tooltipString = GetLightDarkModeString(); const SUN_ICON_SVG = /* html */ ``; const MOON_ICON_SVG = /* html */ ``; - - const initialSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG; - const LightDarkModeButton = stringToHTML(/* html */ ` - - `); - - let ContentDiv = document.getElementById("content"); - ContentDiv!.append(LightDarkModeButton.firstChild!); + document.getElementById("content")!.append( + stringToHTML(/* html */ ` + + `).firstChild!, + ); updateAllColors(); - const lightDarkModeButtonElement = document.getElementById("LightDarkModeButton")!; + const lightDarkModeButtonElement = document.getElementById( + "LightDarkModeButton", + )!; lightDarkModeButtonElement.addEventListener("click", async () => { const darklightText = document.getElementById("darklighttooliptext"); @@ -303,7 +288,6 @@ async function addDarkLightToggle() { LightDarkModeSnakeEggButton = 0; } - if ( settingsState.originalDarkMode !== undefined && settingsState.selectedTheme @@ -314,38 +298,24 @@ async function addDarkLightToggle() { return; } - if (!document.startViewTransition || !settingsState.animations || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - settingsState.DarkMode = !settingsState.DarkMode; - updateAllColors(); - - const newSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG; - const svgElement = lightDarkModeButtonElement.querySelector("svg"); - if (svgElement) svgElement.innerHTML = newSvgContent; - darklightText!.innerText = GetLightDarkModeString(); - return; - } - settingsState.DarkMode = !settingsState.DarkMode; - - updateAllColors(); - - const newSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG; - const svgElement = lightDarkModeButtonElement.querySelector("svg"); - if (svgElement) svgElement.innerHTML = newSvgContent; - + updateAllColors(); + + const svgElement = lightDarkModeButtonElement.querySelector("svg")!; + svgElement.innerHTML = settingsState.DarkMode + ? SUN_ICON_SVG + : MOON_ICON_SVG; darklightText!.innerText = GetLightDarkModeString(); }); } function customizeMenuToggle() { - const menuToggle = document.getElementById("menuToggle"); - if (menuToggle) { - menuToggle.innerHTML = ""; - } + const menuToggle = document.getElementById("menuToggle")!; + menuToggle.innerHTML = ""; for (let i = 0; i < 3; i++) { const line = document.createElement("div"); line.className = "hamburger-line"; - menuToggle!.appendChild(line); + menuToggle.appendChild(line); } } diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index 9f4e576c..967caeb2 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -30,20 +30,17 @@ export async function loadHomePage() { element?.classList.add("active"); const main = document.getElementById("main"); - if (!main) { - console.error("[BetterSEQTA+] Main element not found."); - return; - } - - const homeRoot = stringToHTML(`
    `); + if (!main) return; main.innerHTML = ""; - main.appendChild(homeRoot?.firstChild!); + main.appendChild( + stringToHTML(`
    `).firstChild!, + ); const homeContainer = document.getElementById("home-root"); if (!homeContainer) return; - const skeletonStructure = stringToHTML(/* html */` + const skeletonStructure = stringToHTML(/* html */ `
    @@ -101,25 +98,16 @@ export async function loadHomePage() { renderShortcuts(); - const date = new Date(); - const TodayFormatted = formatDate(date); + const TodayFormatted = formatDate(new Date()); - const [assessmentsPromise, classesPromise, prefsPromise] = [ + const [assessments, classes, prefs] = await Promise.all([ GetUpcomingAssessments(), - GetActiveClasses(), - fetch(`${location.origin}/seqta/student/load/prefs?`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ asArray: true, request: "userPrefs" }), }).then((res) => res.json()), - ]; - - const [assessments, classes, prefs] = await Promise.all([ - assessmentsPromise, - classesPromise, - prefsPromise, ]); callHomeTimetable(TodayFormatted, true); @@ -159,20 +147,20 @@ export async function loadHomePage() { } async function GetUpcomingAssessments() { - let func = fetch( - `${location.origin}/seqta/student/assessment/list/upcoming?`, - { + try { + return fetch(`${location.origin}/seqta/student/assessment/list/upcoming?`, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify({ student: 69 }), - }, - ); - - return func - .then((result) => result.json()) - .then((response) => response.payload); + }) + .then((result) => result.json()) + .then((response) => response.payload); + } catch (error) { + console.error("[BetterSEQTA+] Failed to get upcoming assessments:", error); + return []; + } } function setupTimetableListeners() { @@ -230,15 +218,10 @@ async function GetActiveClasses() { body: JSON.stringify({}), }, ); - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - const data = await response.json(); - return data.payload; + return (await response.json()).payload; } catch (error) { - console.error("Oops! There was a problem fetching active classes:", error); + console.error("[BetterSEQTA+] Failed to get active classes:", error); + return []; } } @@ -248,28 +231,25 @@ function setupNotices(labelArray: string[], date: string) { ) as HTMLInputElement; const fetchNotices = async (date: string) => { - let data; + try { + const data = settingsState.mockNotices + ? getMockNotices() + : await ( + await fetch(`${location.origin}/seqta/student/load/notices?`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ date }), + }) + ).json(); - if (settingsState.mockNotices) { - data = getMockNotices(); - } else { - const response = await fetch( - `${location.origin}/seqta/student/load/notices?`, - { - method: "POST", - headers: { "Content-Type": "application/json; charset=utf-8" }, - body: JSON.stringify({ date }), - }, - ); - data = await response.json(); + processNotices(data, labelArray); + } catch (error) { + console.error("[BetterSEQTA+] Failed to fetch notices:", error); } - - processNotices(data, labelArray); }; const debouncedInputChange = debounce((e: Event) => { - const target = e.target as HTMLInputElement; - fetchNotices(target.value); + fetchNotices((e.target as HTMLInputElement).value); }, 250); dateControl?.addEventListener("input", debouncedInputChange); @@ -290,16 +270,8 @@ function debounce any>( } function comparedate(obj1: any, obj2: any) { - if (obj1.date < obj2.date) { - return -1; - } - if (obj1.date > obj2.date) { - return 1; - } - return 0; + return obj1.date < obj2.date ? -1 : obj1.date > obj2.date ? 1 : 0; } - - function processNotices(response: any, labelArray: string[]) { const NoticeContainer = document.getElementById("notice-container"); if (!NoticeContainer) return; @@ -343,14 +315,14 @@ function processNoticeColor(colour: string): string | undefined { } function createNoticeElement(notice: any, colour: string | undefined): Node { - const textPreview = notice.contents - .replace(/<[^>]*>/g, "") - .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") - .replace(/\s+/g, " ") - .trim() - .substring(0, 150) - + (notice.contents.length > 150 ? "..." : ""); - + const textPreview = + notice.contents + .replace(/<[^>]*>/g, "") + .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") + .replace(/\s+/g, " ") + .trim() + .substring(0, 150) + (notice.contents.length > 150 ? "..." : ""); + const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const htmlContent = ` @@ -369,12 +341,10 @@ function createNoticeElement(notice: any, colour: string | undefined): Node {
    `; const element = stringToHTML(htmlContent).firstChild as HTMLElement; - if (element) { - element.addEventListener("click", () => - openNoticeModal(notice, colour, element), - ); - } - return element!; + element.addEventListener("click", () => + openNoticeModal(notice, colour, element), + ); + return element; } function openNoticeModal( @@ -386,15 +356,11 @@ function openNoticeModal( .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/ +/, " "); - const existingModal = document.getElementById("notice-modal"); - if (existingModal) { - existingModal.remove(); - } + document.getElementById("notice-modal")?.remove(); const sourceRect = sourceElement.getBoundingClientRect(); let scrollY = Math.round(window.scrollY); let scrollX = Math.round(window.scrollX); - let sourceLeft = sourceRect.left; let sourceTop = sourceRect.top; let sourceWidth = sourceRect.width; @@ -476,7 +442,6 @@ function openNoticeModal( let targetHeight = Math.round( Math.min(Math.max(measuredHeight, 200), viewportHeight * 0.85), ); - let targetLeft = Math.round((viewportWidth - targetWidth) / 2); let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY; @@ -585,13 +550,10 @@ function openNoticeModal( const newTargetWidth = Math.round( Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40), ); - - // Just measure the existing modal content const currentHeight = unifiedContent.getBoundingClientRect().height; const newTargetHeight = Math.round( Math.min(Math.max(currentHeight, 200), newViewportHeight * 0.85), ); - const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2); const newTargetTop = Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY; @@ -656,116 +618,92 @@ function callHomeTimetable(date: string, change?: any) { xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8"); xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (loadingTimeout) { - clearTimeout(loadingTimeout); - loadingTimeout = null; - } + if (xhr.readyState !== 4) return; - const DayContainer = document.getElementById("day-container")!; + if (loadingTimeout) { + clearTimeout(loadingTimeout); + loadingTimeout = null; + } - try { - var serverResponse = JSON.parse(xhr.response); - let lessonArray: Array = []; + const DayContainer = document.getElementById("day-container")!; - if (serverResponse.payload.items.length > 0) { - if (DayContainer.innerText || change) { - for (let i = 0; i < serverResponse.payload.items.length; i++) { - lessonArray.push(serverResponse.payload.items[i]); + var serverResponse = JSON.parse(xhr.response); + let lessonArray: Array = []; + + if (serverResponse.payload.items.length > 0) { + if (DayContainer.innerText || change) { + for (let i = 0; i < serverResponse.payload.items.length; i++) { + lessonArray.push(serverResponse.payload.items[i]); + } + lessonArray.sort(function (a, b) { + return a.from.localeCompare(b.from); + }); + + GetLessonColours().then((colours) => { + for (let i = 0; i < lessonArray.length; i++) { + let subjectname = + lessonArray[i].type == "tutorial" + ? `timetable.tutor.${lessonArray[i].tutorID}` + : `timetable.subject.colour.${lessonArray[i].code}`; + let subject = colours.find( + (element: any) => element.name === subjectname, + ); + + if (!subject) { + lessonArray[i].colour = "--item-colour: #8e8e8e;"; + } else { + lessonArray[i].colour = `--item-colour: ${subject.value};`; + if (GetThresholdOfColor(subject.value) > 300) { + lessonArray[i].invert = true; + } } - lessonArray.sort(function (a, b) { - return a.from.localeCompare(b.from); - }); - GetLessonColours().then((colours) => { - let subjects = colours; - for (let i = 0; i < lessonArray.length; i++) { - - let subjectname = ((lessonArray[i].type == "tutorial") ? `timetable.tutor.${lessonArray[i].tutorID}` : `timetable.subject.colour.${lessonArray[i].code}`); + lessonArray[i].from = lessonArray[i].from.substring(0, 5); + lessonArray[i].until = lessonArray[i].until.substring(0, 5); - let subject = subjects.find( - (element: any) => element.name === subjectname, - ); - if (!subject) { - lessonArray[i].colour = "--item-colour: #8e8e8e;"; - } else { - lessonArray[i].colour = `--item-colour: ${subject.value};`; - let result = GetThresholdOfColor(subject.value); + if (settingsState.timeFormat === "12") { + lessonArray[i].from = convertTo12HourFormat(lessonArray[i].from); + lessonArray[i].until = convertTo12HourFormat( + lessonArray[i].until, + ); + } - if (result > 300) { - lessonArray[i].invert = true; - } - } - - lessonArray[i].from = lessonArray[i].from.substring(0, 5); - lessonArray[i].until = lessonArray[i].until.substring(0, 5); - - if (settingsState.timeFormat === "12") { - lessonArray[i].from = convertTo12HourFormat( - lessonArray[i].from, - ); - lessonArray[i].until = convertTo12HourFormat( - lessonArray[i].until, - ); - } - - lessonArray[i].attendanceTitle = CheckUnmarkedAttendance( - lessonArray[i].attendance, - ); - } - - DayContainer.innerText = ""; - for (let i = 0; i < lessonArray.length; i++) { - var div = makeLessonDiv(lessonArray[i], i + 1); - - if (lessonArray[i].invert) { - const div1 = div.firstChild! as HTMLElement; - div1.classList.add("day-inverted"); - } - - DayContainer.append(div.firstChild as HTMLElement); - } - - DayContainer.classList.remove("loading"); - - const today = new Date(); - if (currentSelectedDate.getDate() == today.getDate()) { - for (let i = 0; i < lessonArray.length; i++) { - CheckCurrentLesson(lessonArray[i], i + 1); - } - - CheckCurrentLessonAll(lessonArray); - } - }); + lessonArray[i].attendanceTitle = CheckUnmarkedAttendance( + lessonArray[i].attendance, + ); + } + + DayContainer.innerText = ""; + for (let i = 0; i < lessonArray.length; i++) { + const div = makeLessonDiv(lessonArray[i], i + 1); + if (lessonArray[i].invert) { + (div.firstChild! as HTMLElement).classList.add("day-inverted"); + } + DayContainer.append(div.firstChild as HTMLElement); } - } else { - DayContainer.innerHTML = ""; - var dummyDay = document.createElement("div"); - dummyDay.classList.add("day-empty"); - let img = document.createElement("img"); - img.src = browser.runtime.getURL(LogoLight); - let text = document.createElement("p"); - text.innerText = "No lessons available."; - dummyDay.append(img); - dummyDay.append(text); - DayContainer.append(dummyDay); DayContainer.classList.remove("loading"); - } - } catch (error) { - console.error("Error loading timetable data:", error); - DayContainer.classList.remove("loading"); - - DayContainer.innerHTML = ""; - const errorDiv = document.createElement("div"); - errorDiv.classList.add("day-empty"); - errorDiv.innerHTML = ` - -

    Error loading lessons. Please try again.

    - `; - DayContainer.append(errorDiv); + const today = new Date(); + if (currentSelectedDate.getDate() == today.getDate()) { + for (let i = 0; i < lessonArray.length; i++) { + CheckCurrentLesson(lessonArray[i], i + 1); + } + CheckCurrentLessonAll(lessonArray); + } + }); } + } else { + DayContainer.innerHTML = ""; + const dummyDay = document.createElement("div"); + dummyDay.classList.add("day-empty"); + const img = document.createElement("img"); + img.src = browser.runtime.getURL(LogoLight); + const text = document.createElement("p"); + text.innerText = "No lessons available."; + dummyDay.append(img, text); + DayContainer.append(dummyDay); + DayContainer.classList.remove("loading"); } }; xhr.send( @@ -855,8 +793,6 @@ async function CheckCurrentLesson(lesson: any, num: number) { } function makeLessonDiv(lesson: any, num: number) { - if (!lesson) throw new Error("No lesson provided."); - const { code, colour, @@ -869,14 +805,14 @@ function makeLessonDiv(lesson: any, num: number) { programmeID, metaID, assessments, - type + type, } = lesson; let lessonString = `
    -

    ${(type == "class") ? description : (type == "tutorial") ? "Tutorial" : "Unknown"}

    +

    ${type == "class" ? description : type == "tutorial" ? "Tutorial" : "Unknown"}

    ${staff || "Unknown"}

    -

    ${(type == "class") ? room : (type == "tutorial") ? "N/A" : "Unknown"}

    +

    ${type == "class" ? room : type == "tutorial" ? "N/A" : "Unknown"}

    ${from || "Unknown"} - ${until || "Unknown"}

    ${attendanceTitle || "Unknown"}
    `; @@ -922,64 +858,48 @@ function buildAssessmentURL(programmeID: any, metaID: any, itemID = "") { } function CheckUnmarkedAttendance(lessonattendance: any) { - if (lessonattendance) { - var lesson = lessonattendance.label; - } else { - lesson = " "; - } - return lesson; + return lessonattendance ? lessonattendance.label : " "; } async function CreateUpcomingSection(assessments: any, activeSubjects: any) { - let upcomingitemcontainer = document.querySelector("#upcoming-items"); - let overdueDates = []; - let upcomingDates = {}; - - var Today = new Date(); + const upcomingitemcontainer = document.querySelector("#upcoming-items"); + const overdueDates = []; + const upcomingDates = {}; + const Today = new Date(); for (let i = 0; i < assessments.length; i++) { - const assessment = assessments[i]; - let assessmentdue = new Date(assessment.due); - - CheckSpecialDay(Today, assessmentdue); - if (assessmentdue < Today) { - if (!CheckSpecialDay(Today, assessmentdue)) { - overdueDates.push(assessment); - assessments.splice(i, 1); - i--; - } + const assessmentdue = new Date(assessments[i].due); + if (assessmentdue < Today && !CheckSpecialDay(Today, assessmentdue)) { + overdueDates.push(assessments[i]); + assessments.splice(i, 1); + i--; } } - var TomorrowDate = new Date(); - TomorrowDate.setDate(TomorrowDate.getDate() + 1); - const colours = await GetLessonColours(); - let subjects = colours; for (let i = 0; i < assessments.length; i++) { - let subjectname = `timetable.subject.colour.${assessments[i].code}`; - - let subject = subjects.find((element: any) => element.name === subjectname); - + const subject = colours.find( + (element: any) => + element.name === `timetable.subject.colour.${assessments[i].code}`, + ); if (!subject) { assessments[i].colour = "--item-colour: #8e8e8e;"; } else { assessments[i].colour = `--item-colour: ${subject.value};`; - GetThresholdOfColor(subject.value); } } for (let i = 0; i < activeSubjects.length; i++) { const element = activeSubjects[i]; - let subjectname = `timetable.subject.colour.${element.code}`; - let colour = colours.find((element: any) => element.name === subjectname); + const colour = colours.find( + (c: any) => c.name === `timetable.subject.colour.${element.code}`, + ); if (!colour) { element.colour = "--item-colour: #8e8e8e;"; } else { element.colour = `--item-colour: ${colour.value};`; - let result = GetThresholdOfColor(colour.value); - if (result > 300) { + if (GetThresholdOfColor(colour.value) > 300) { element.invert = true; } } @@ -987,52 +907,35 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) { CreateFilters(activeSubjects); - let type; - let class_; - for (let i = 0; i < assessments.length; i++) { const element: any = assessments[i]; if (!upcomingDates[element.due as keyof typeof upcomingDates]) { - let dateObj: any = new Object(); - dateObj.div = CreateElement( - (type = "div"), - (class_ = "upcoming-date-container"), - ); - dateObj.assessments = []; + const dateObj: any = { + div: CreateElement("div", "upcoming-date-container"), + assessments: [], + }; (upcomingDates[element.due as keyof typeof upcomingDates] as any) = dateObj; } - let assessmentDateDiv = + const assessmentDateDiv = upcomingDates[element.due as keyof typeof upcomingDates]; - if (assessmentDateDiv) { (assessmentDateDiv as any).assessments.push(element); } } for (var date in upcomingDates) { - let assessmentdue = new Date( + const assessmentdue = new Date( ( upcomingDates[date as keyof typeof upcomingDates] as any ).assessments[0].due, ); - let specialcase = CheckSpecialDay(Today, assessmentdue); - let assessmentDate; - - if (specialcase) { - let datecase: string = specialcase!; - assessmentDate = createAssessmentDateDiv( - date, - upcomingDates[date as keyof typeof upcomingDates], - - datecase, - ); - } else { - assessmentDate = createAssessmentDateDiv( - date, - upcomingDates[date as keyof typeof upcomingDates], - ); - } + const specialcase = CheckSpecialDay(Today, assessmentdue); + const assessmentDate = createAssessmentDateDiv( + date, + upcomingDates[date as keyof typeof upcomingDates], + specialcase, + ); if (specialcase === "Yesterday") { upcomingitemcontainer!.insertBefore( @@ -1044,7 +947,7 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) { } } FilterUpcomingAssessments(settingsState.subjectfilters); - + if (assessments.length === 0) { upcomingitemcontainer!.innerHTML = `
    @@ -1055,77 +958,68 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) { } function createAssessmentDateDiv(date: string, value: any, datecase?: any) { - var options = { + const options = { weekday: "long" as "long", month: "long" as "long", day: "numeric" as "numeric", }; const FormattedDate = new Date(date); - const assessments = value.assessments; const container = value.div; - let DateTitleDiv = document.createElement("div"); + const DateTitleDiv = document.createElement("div"); DateTitleDiv.classList.add("upcoming-date-title"); if (datecase) { - let datetitle = document.createElement("h5"); + const datetitle = document.createElement("h5"); datetitle.classList.add("upcoming-special-day"); datetitle.innerText = datecase; DateTitleDiv.append(datetitle); container.setAttribute("data-day", datecase); } - let DateTitle = document.createElement("h5"); + const DateTitle = document.createElement("h5"); DateTitle.innerText = FormattedDate.toLocaleDateString("en-AU", options); DateTitleDiv.append(DateTitle); - container.append(DateTitleDiv); - let assessmentContainer = document.createElement("div"); + const assessmentContainer = document.createElement("div"); assessmentContainer.classList.add("upcoming-date-assessments"); for (let i = 0; i < assessments.length; i++) { const element = assessments[i]; - let item = document.createElement("div"); + const item = document.createElement("div"); item.classList.add("upcoming-assessment"); item.setAttribute("data-subject", element.code); item.id = `assessment${element.id}`; - item.style.cssText = element.colour; - let titlediv = document.createElement("div"); + const titlediv = document.createElement("div"); titlediv.classList.add("upcoming-subject-title"); - - let titlesvg = + titlediv.append( stringToHTML(` - `).firstChild; - titlediv.append(titlesvg!); + `).firstChild!, + ); - let detailsdiv = document.createElement("div"); + const detailsdiv = document.createElement("div"); detailsdiv.classList.add("upcoming-details"); - let detailstitle = document.createElement("h5"); + const detailstitle = document.createElement("h5"); detailstitle.innerText = `${element.subject} assessment`; - let subject = document.createElement("p"); + const subject = document.createElement("p"); subject.innerText = element.title; subject.classList.add("upcoming-assessment-title"); subject.onclick = function () { document.querySelector("#menu ul")!.classList.add("noscroll"); location.href = `../#?page=/assessments/${element.programmeID}:${element.metaclassID}&item=${element.id}`; }; - detailsdiv.append(detailstitle); - detailsdiv.append(subject); - - item.append(titlediv); - item.append(detailsdiv); + detailsdiv.append(detailstitle, subject); + item.append(titlediv, detailsdiv); assessmentContainer.append(item); fetch(`${location.origin}/seqta/student/assessment/submissions/get`, { method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - }, + headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ assessment: element.id, metaclass: element.metaclassID, @@ -1136,8 +1030,7 @@ function createAssessmentDateDiv(date: string, value: any, datecase?: any) { .then((response) => { if (response.payload.length > 0) { const assessment = document.querySelector(`#assessment${element.id}`); - - let submittedtext = document.createElement("div"); + const submittedtext = document.createElement("div"); submittedtext.classList.add("upcoming-submittedtext"); submittedtext.innerText = "Submitted"; assessment!.append(submittedtext); @@ -1175,36 +1068,37 @@ function CheckSpecialDay(date1: Date, date2: Date) { } async function GetLessonColours() { - let func = fetch(`${location.origin}/seqta/student/load/prefs?`, { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ request: "userPrefs", asArray: true, user: 69 }), - }); - return func - .then((result) => result.json()) - .then((response) => response.payload); + try { + return fetch(`${location.origin}/seqta/student/load/prefs?`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ request: "userPrefs", asArray: true, user: 69 }), + }) + .then((result) => result.json()) + .then((response) => response.payload); + } catch (error) { + console.error("[BetterSEQTA+] Failed to get lesson colours:", error); + return []; + } } function CreateFilters(subjects: any) { - let filteroptions = settingsState.subjectfilters; + const filteroptions = settingsState.subjectfilters; + const filterdiv = document.querySelector("#upcoming-filters"); - let filterdiv = document.querySelector("#upcoming-filters"); for (let i = 0; i < subjects.length; i++) { const element = subjects[i]; - if (!Object.prototype.hasOwnProperty.call(filteroptions, element.code)) { filteroptions[element.code] = true; settingsState.subjectfilters = filteroptions; } - let elementdiv = CreateSubjectFilter( - element.code, - element.colour, - filteroptions[element.code], + filterdiv!.append( + CreateSubjectFilter( + element.code, + element.colour, + filteroptions[element.code], + ), ); - - filterdiv!.append(elementdiv); } } @@ -1213,23 +1107,20 @@ function CreateSubjectFilter( itemcolour: string, checked: any, ) { - let label = CreateElement("label", "upcoming-checkbox-container"); + const label = CreateElement("label", "upcoming-checkbox-container"); label.innerText = subjectcode; - let input1 = CreateElement("input"); - const input = input1 as HTMLInputElement; + const input = CreateElement("input") as HTMLInputElement; input.type = "checkbox"; input.checked = checked; input.id = `filter-${subjectcode}`; label.style.cssText = itemcolour; - let span = CreateElement("span", "upcoming-checkmark"); - label.append(input); - label.append(span); + const span = CreateElement("span", "upcoming-checkmark"); + label.append(input, span); input.addEventListener("change", function (change) { - let filters = settingsState.subjectfilters; - let id = (change.target as HTMLInputElement)!.id.split("-")[1]; - filters[id] = (change.target as HTMLInputElement)!.checked; - + const filters = settingsState.subjectfilters; + const id = (change.target as HTMLInputElement).id.split("-")[1]; + filters[id] = (change.target as HTMLInputElement).checked; settingsState.subjectfilters = filters; });