diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 92f92efd..33871e2e 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -10,6 +10,22 @@ declare module "*?inlineWorker" { export default value; } +/** CRXJS dynamic content / main-world script path (relative to extension root). */ +declare module "*?script" { + const path: string; + export default path; +} + +declare module "*?script&iife" { + const path: string; + export default path; +} + +declare module "*?script&module" { + const path: string; + export default path; +} + declare module "*.png?base64" { const value: string; export default value; diff --git a/src/lib/extensionPageScriptUrl.test.ts b/src/lib/extensionPageScriptUrl.test.ts new file mode 100644 index 00000000..bcd7726f --- /dev/null +++ b/src/lib/extensionPageScriptUrl.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +jest.mock("webextension-polyfill", () => ({ + __esModule: true, + default: { + runtime: { + getURL: (path: string) => `chrome-extension://testid/${path}`, + }, + }, +})); + +import { extensionPageScriptUrl } from "./extensionPageScriptUrl"; + +describe("extensionPageScriptUrl", () => { + it("prefixes chrome.runtime.getURL and strips a leading slash", () => { + expect(extensionPageScriptUrl("assets/pageState.js")).toBe( + "chrome-extension://testid/assets/pageState.js", + ); + expect(extensionPageScriptUrl("/assets/pageState.js")).toBe( + "chrome-extension://testid/assets/pageState.js", + ); + }); +}); diff --git a/src/lib/extensionPageScriptUrl.ts b/src/lib/extensionPageScriptUrl.ts new file mode 100644 index 00000000..fbc48195 --- /dev/null +++ b/src/lib/extensionPageScriptUrl.ts @@ -0,0 +1,10 @@ +import browser from "webextension-polyfill"; + +/** + * Resolve a CRXJS `?script` / `?script&iife` import to a web-accessible + * chrome-extension:// URL. Works in both `vite build` and `vite dev` + * (plain `?url` imports 404 in CRXJS serve because they are not packaged). + */ +export function extensionPageScriptUrl(scriptPath: string): string { + return browser.runtime.getURL(scriptPath.replace(/^\/+/, "")); +} diff --git a/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.test.ts b/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.test.ts new file mode 100644 index 00000000..0e0e6f23 --- /dev/null +++ b/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.test.ts @@ -0,0 +1,31 @@ +import { extractWeightFromCoversheetText } from "./extractWeightFromCoversheetText"; + +describe("extractWeightFromCoversheetText", () => { + it("matches Weight: N", () => { + expect(extractWeightFromCoversheetText("Due date ... Weight: 20 Subject")).toBe( + "20", + ); + }); + + it("matches Weighting: N%", () => { + expect( + extractWeightFromCoversheetText("Assessment Weighting: 12.5% of semester"), + ).toBe("12.5"); + }); + + it("matches Assessment weight: N", () => { + expect( + extractWeightFromCoversheetText("Assessment weight: 15\nCriteria"), + ).toBe("15"); + }); + + it("matches Weight of N%", () => { + expect(extractWeightFromCoversheetText("This task has a weight of 10%")).toBe( + "10", + ); + }); + + it("returns null when no weighting is present", () => { + expect(extractWeightFromCoversheetText("No marks available yet")).toBeNull(); + }); +}); diff --git a/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.ts b/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.ts new file mode 100644 index 00000000..0946fcec --- /dev/null +++ b/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.ts @@ -0,0 +1,16 @@ +/** Pull a numeric weighting from coversheet / report PDF text. */ +export function extractWeightFromCoversheetText(text: string): string | null { + const patterns = [ + /weightings?\s*:\s*(\d+(?:\.\d+)?)\s*%?/i, + /weight\s*:\s*(\d+(?:\.\d+)?)\s*%?/i, + /assessment\s+weight(?:ing)?\s*:\s*(\d+(?:\.\d+)?)\s*%?/i, + /weight(?:ing)?\s+(?:of\s+)?(\d+(?:\.\d+)?)\s*%/i, + ]; + + for (const pattern of patterns) { + const match = text.match(pattern); + if (match?.[1]) return match[1]; + } + + return null; +} diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index e17c35b3..39eda214 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -11,6 +11,9 @@ import { getPdfjsPageContextUrls, } from "@/lib/pdfjsExtension.ts"; import * as pdfjs from "pdfjs-dist"; +import { extractWeightFromCoversheetText } from "./extractWeightFromCoversheetText"; + +export { extractWeightFromCoversheetText }; ensurePdfjsWorker(); @@ -552,135 +555,67 @@ export async function extractPDFText(url: string): Promise { return new Promise((resolve, reject) => { const script = document.createElement("script"); + script.type = "module"; const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; const escapedUrl = escJsSingleQuoted(url); + // Import the legacy build in page context so it can set + // globalThis.pdfjsLib, then parse the coversheet PDF. script.textContent = ` - (function() { - const requestId = '${requestId}'; - const pageOrigin = '${escapedOrigin}'; - const url = '${escapedUrl}'; - const pdfLibSrc = '${pdfLibInj}'; - const pdfWorkerSrc = '${pdfWorkerInj}'; - - if (window.pdfjsLib) { - extractPDF(); + const requestId = '${requestId}'; + const pageOrigin = '${escapedOrigin}'; + const url = '${escapedUrl}'; + const pdfWorkerSrc = '${pdfWorkerInj}'; + + function postResult(payload) { + window.postMessage({ type: requestId, ...payload }, pageOrigin); + } + + try { + await import('${pdfLibInj}'); + const pdfjsLib = globalThis.pdfjsLib; + if (!pdfjsLib?.getDocument) { + postResult({ success: false, error: 'pdfjsLib missing after import' }); } else { - const pdfjsScript = document.createElement('script'); - pdfjsScript.src = pdfLibSrc; - pdfjsScript.type = 'module'; - - pdfjsScript.onload = function() { - extractPDF(); - }; - pdfjsScript.onerror = function() { - window.postMessage({ - type: requestId, - success: false, - error: 'Failed to load pdfjs library' - }, pageOrigin); - }; - - document.head.appendChild(pdfjsScript); - } - - function extractPDF() { - try { - window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc; - - 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 - }, pageOrigin); - 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, - 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 - }, pageOrigin); - }) - .catch(error => { - window.postMessage({ - type: requestId, - success: false, - error: 'PDF parsing error: ' + (error.message || String(error)) - }, pageOrigin); - }); - } catch (error) { - window.postMessage({ - type: requestId, - success: false, - error: 'ArrayBuffer error: ' + (error.message || String(error)) - }, pageOrigin); - } - }; - - xhr.onerror = function() { - window.postMessage({ - type: requestId, - success: false, - error: 'Network error fetching PDF' - }, pageOrigin); - }; - - xhr.ontimeout = function() { - window.postMessage({ - type: requestId, - success: false, - error: 'Timeout fetching PDF' - }, pageOrigin); - }; - - xhr.timeout = 30000; - xhr.send(); - } catch (error) { - window.postMessage({ - type: requestId, - success: false, - error: 'Setup error: ' + (error.message || String(error)) - }, pageOrigin); + pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc; + + const response = await fetch(url, { + credentials: 'include', + redirect: 'follow', + }); + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); } + + const arrayBuffer = await response.arrayBuffer(); + if (!arrayBuffer || arrayBuffer.byteLength === 0) { + throw new Error('PDF response is empty'); + } + + const pdf = await pdfjsLib.getDocument({ + data: arrayBuffer, + useSystemFonts: true, + verbosity: 0, + useWorkerFetch: false, + isEvalSupported: false, + }).promise; + + const pages = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const content = await page.getTextContent(); + pages.push(content.items.map((item) => item.str).join(' ')); + } + + postResult({ success: true, text: pages.join('\\n') }); } - })(); + } catch (error) { + postResult({ + success: false, + error: 'PDF extraction error: ' + (error?.message || String(error)), + }); + } `; const messageHandler = (event: MessageEvent) => { @@ -723,6 +658,9 @@ export async function extractPDFText(url: string): Promise { const pdf = await pdfjs.getDocument({ data: arrayBuffer, useSystemFonts: true, + verbosity: 0, + useWorkerFetch: false, + isEvalSupported: false, }).promise; let text = ""; @@ -740,6 +678,85 @@ export async function extractPDFText(url: string): Promise { } } +function randomStudentPdfFileName(): string { + // Matches SEQTA Learn coversheet tokens, e.g. "mrr158ct.pdf". + return `${Math.random().toString(36).slice(2, 10)}.pdf`; +} + +async function requestStudentAssessmentPdf(params: { + assessmentID: string | number; + metaclassID: string | number; + studentID: string | number; +}): Promise { + const fileName = randomStudentPdfFileName(); + + 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({ + id: Number(params.assessmentID), + metaclass: Number(params.metaclassID), + student: Number(params.studentID), + fileName, + }), + }, + ); + + if (!printResponse.ok) { + throw new Error( + `Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`, + ); + } + + const data = (await printResponse.json()) as { + payload?: { file?: string }; + status?: string | number; + }; + + const resolved = data.payload?.file; + if (!resolved) { + throw new Error( + `Print assessment response missing payload.file (status=${String(data.status)})`, + ); + } + + return resolved; +} + +function getStudentAssessmentReportUrl(fileName: string): string { + const params = new URLSearchParams({ + type: "generated_report", + file: fileName, + }); + return `${location.origin}/seqta/student/load/file?${params.toString()}`; +} + +async function extractPDFTextWithRetry( + url: string, + attempts = 3, + delayMs = 1500, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await extractPDFText(url); + } catch (error) { + lastError = error; + const message = error instanceof Error ? error.message : String(error); + const retryable = + message.includes("404") || + message.includes("empty") || + message.includes("Failed to fetch PDF"); + if (!retryable || attempt === attempts - 1) throw error; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + throw lastError; +} + async function handleWeightings(mark: any, api: any) { const assessmentID = assessmentIdKey(mark); const metaclassID = mark.metaclassID; @@ -749,13 +766,25 @@ async function handleWeightings(mark: any, api: any) { | WeightingEntry | undefined; - const isFresh = + // Skip only when we already have a real numeric weight for this fingerprint. + // "N/A" / "processing" / in-flight refreshing must not permanently block retries. + const hasNumericWeight = existing && existing.weight !== "processing" && - existing.fingerprint === fingerprint && - existing.pluginVersion === WEIGHTING_SCHEMA_VERSION; + existing.weight !== "N/A" && + !Number.isNaN(parseFloat(existing.weight)); - if (isFresh) return; + const inFlightSameFingerprint = + existing && + existing.fingerprint === fingerprint && + (existing.weight === "processing" || existing.refreshing); + + const isFresh = + Boolean(hasNumericWeight) && + existing?.fingerprint === fingerprint && + existing?.pluginVersion === WEIGHTING_SCHEMA_VERSION; + + if (isFresh || inFlightSameFingerprint) return; // If we have a previous usable value, keep showing it while we refetch // by marking the entry as refreshing instead of wiping it. We claim the @@ -763,7 +792,7 @@ async function handleWeightings(mark: any, api: any) { // pass (e.g. a fast re-mount of the wrapper) doesn't kick off a duplicate // refetch for the same id while this one is still in flight. const placeholder: WeightingEntry = - existing && existing.weight !== "processing" + existing && hasNumericWeight ? { ...existing, fingerprint, @@ -807,34 +836,13 @@ async function handleWeightings(mark: any, api: any) { const userInfo = await getUserInfo(); const userID = userInfo.id; - 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}`, - ); - } - + const reportFile = await requestStudentAssessmentPdf({ + assessmentID, + metaclassID, + studentID: userID, + }); await new Promise((resolve) => setTimeout(resolve, 1000)); - - pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; + pdfUrl = getStudentAssessmentReportUrl(reportFile); } if (pdfUrl.startsWith("blob:")) { @@ -843,32 +851,37 @@ async function handleWeightings(mark: any, api: any) { let text: string; try { - text = await extractPDFText(pdfUrl); + text = await extractPDFTextWithRetry(pdfUrl); } catch (error: any) { if ( isFirefox && (error?.message?.includes("blob") || error?.message?.includes("Security") || - error?.message?.includes("CSP")) + error?.message?.includes("CSP") || + error?.message?.includes("empty")) ) { await new Promise((resolve) => setTimeout(resolve, 2000)); - text = await extractPDFText(pdfUrl); + text = await extractPDFTextWithRetry(pdfUrl, 2, 2000); } else { throw new Error(`PDF extraction failed: ${error.message}`); } } - const match = text.match(/weight:\s*(\d+\.?\d*)/i); + const weight = extractWeightFromCoversheetText(text); api.storage.weightings = { ...api.storage.weightings, [assessmentID]: { - weight: match ? match[1] : "N/A", + weight: weight ?? "N/A", fingerprint, pluginVersion: WEIGHTING_SCHEMA_VERSION, }, }; } catch (error: any) { + console.error( + `[BetterSEQTA+] Weighting fetch failed for assessment ${assessmentID}:`, + error, + ); api.storage.weightings = { ...api.storage.weightings, [assessmentID]: { diff --git a/src/seqta/main.ts b/src/seqta/main.ts index 56b77c15..1ed7f9ee 100644 --- a/src/seqta/main.ts +++ b/src/seqta/main.ts @@ -5,8 +5,8 @@ import browser from "webextension-polyfill"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; // UI and theme management -import pageState from "@/pageState.js?url"; -import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; +import pageState from "@/pageState.js?script&iife"; +import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl"; import { installSeqtaMenuColourPatch } from "@/seqta/utils/patchSeqtaMenuUpdateColours"; import { installThemeImagePagePatch } from "@/seqta/utils/patchThemeImagesPageContext"; @@ -40,6 +40,6 @@ export async function main() { function injectPageState() { const mainScript = document.createElement("script"); - mainScript.src = resolveExtensionAssetUrl(pageState); + mainScript.src = extensionPageScriptUrl(pageState); document.head.appendChild(mainScript); } diff --git a/src/seqta/utils/patchSeqtaMenuUpdateColours.ts b/src/seqta/utils/patchSeqtaMenuUpdateColours.ts index ec5c7cf4..3b4142c0 100644 --- a/src/seqta/utils/patchSeqtaMenuUpdateColours.ts +++ b/src/seqta/utils/patchSeqtaMenuUpdateColours.ts @@ -3,8 +3,8 @@ * (injected script) plus Coloris / overlay cleanup in the content script. */ -import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?url"; -import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; +import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?script&iife"; +import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl"; import { verboseInfo } from "@/utils/verboseLog"; const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader"; @@ -128,7 +128,7 @@ export function installSeqtaMenuColourPatch(): void { const script = document.createElement("script"); script.id = PAGE_PATCH_LOADER_ID; - script.src = resolveExtensionAssetUrl(patchScript); + script.src = extensionPageScriptUrl(patchScript); script.addEventListener("load", () => script.remove()); (document.documentElement || document.head).appendChild(script); } diff --git a/src/seqta/utils/patchThemeImagesPageContext.ts b/src/seqta/utils/patchThemeImagesPageContext.ts index 1ee6fd35..4d28c7d2 100644 --- a/src/seqta/utils/patchThemeImagesPageContext.ts +++ b/src/seqta/utils/patchThemeImagesPageContext.ts @@ -2,8 +2,8 @@ * Bridge theme CSS and decorative images into PAGE JavaScript context. */ -import patchScript from "@/seqta/utils/themeImagePagePatch.js?url"; -import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; +import patchScript from "@/seqta/utils/themeImagePagePatch.js?script&iife"; +import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl"; import { blobToBase64Data } from "@/plugins/built-in/themes/themeImageUrl"; const PAGE_PATCH_LOADER_ID = "bsplus-theme-image-page-patch-loader"; @@ -23,7 +23,7 @@ export function installThemeImagePagePatch(): void { const script = document.createElement("script"); script.id = PAGE_PATCH_LOADER_ID; - script.src = resolveExtensionAssetUrl(patchScript); + script.src = extensionPageScriptUrl(patchScript); script.addEventListener("load", () => script.remove()); (document.documentElement || document.head).appendChild(script); }