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) {