fix: fix dev command not loading assets and scripts + fix assement averages

This commit is contained in:
2026-07-19 09:34:59 +09:30
parent c015c8a162
commit cf6e797510
9 changed files with 276 additions and 167 deletions
+16
View File
@@ -10,6 +10,22 @@ declare module "*?inlineWorker" {
export default value; 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" { declare module "*.png?base64" {
const value: string; const value: string;
export default value; export default value;
+23
View File
@@ -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",
);
});
});
+10
View File
@@ -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(/^\/+/, ""));
}
@@ -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();
});
});
@@ -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;
}
+169 -156
View File
@@ -11,6 +11,9 @@ import {
getPdfjsPageContextUrls, getPdfjsPageContextUrls,
} from "@/lib/pdfjsExtension.ts"; } from "@/lib/pdfjsExtension.ts";
import * as pdfjs from "pdfjs-dist"; import * as pdfjs from "pdfjs-dist";
import { extractWeightFromCoversheetText } from "./extractWeightFromCoversheetText";
export { extractWeightFromCoversheetText };
ensurePdfjsWorker(); ensurePdfjsWorker();
@@ -552,135 +555,67 @@ export async function extractPDFText(url: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
script.type = "module";
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
const escapedUrl = escJsSingleQuoted(url); const escapedUrl = escJsSingleQuoted(url);
// Import the legacy build in page context so it can set
// globalThis.pdfjsLib, then parse the coversheet PDF.
script.textContent = ` script.textContent = `
(function() { const requestId = '${requestId}';
const requestId = '${requestId}'; const pageOrigin = '${escapedOrigin}';
const pageOrigin = '${escapedOrigin}'; const url = '${escapedUrl}';
const url = '${escapedUrl}'; const pdfWorkerSrc = '${pdfWorkerInj}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
if (window.pdfjsLib) { function postResult(payload) {
extractPDF(); 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 { } else {
const pdfjsScript = document.createElement('script'); pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
pdfjsScript.src = pdfLibSrc;
pdfjsScript.type = 'module';
pdfjsScript.onload = function() { const response = await fetch(url, {
extractPDF(); credentials: 'include',
}; redirect: 'follow',
pdfjsScript.onerror = function() { });
window.postMessage({ if (!response.ok) {
type: requestId, throw new Error('HTTP ' + response.status + ': ' + response.statusText);
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);
} }
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) => { const messageHandler = (event: MessageEvent) => {
@@ -723,6 +658,9 @@ export async function extractPDFText(url: string): Promise<string> {
const pdf = await pdfjs.getDocument({ const pdf = await pdfjs.getDocument({
data: arrayBuffer, data: arrayBuffer,
useSystemFonts: true, useSystemFonts: true,
verbosity: 0,
useWorkerFetch: false,
isEvalSupported: false,
}).promise; }).promise;
let text = ""; let text = "";
@@ -740,6 +678,85 @@ export async function extractPDFText(url: string): Promise<string> {
} }
} }
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<string> {
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<string> {
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) { async function handleWeightings(mark: any, api: any) {
const assessmentID = assessmentIdKey(mark); const assessmentID = assessmentIdKey(mark);
const metaclassID = mark.metaclassID; const metaclassID = mark.metaclassID;
@@ -749,13 +766,25 @@ async function handleWeightings(mark: any, api: any) {
| WeightingEntry | WeightingEntry
| undefined; | 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 &&
existing.weight !== "processing" && existing.weight !== "processing" &&
existing.fingerprint === fingerprint && existing.weight !== "N/A" &&
existing.pluginVersion === WEIGHTING_SCHEMA_VERSION; !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 // 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 // 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 // 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. // refetch for the same id while this one is still in flight.
const placeholder: WeightingEntry = const placeholder: WeightingEntry =
existing && existing.weight !== "processing" existing && hasNumericWeight
? { ? {
...existing, ...existing,
fingerprint, fingerprint,
@@ -807,34 +836,13 @@ async function handleWeightings(mark: any, api: any) {
const userInfo = await getUserInfo(); const userInfo = await getUserInfo();
const userID = userInfo.id; const userID = userInfo.id;
const filename = const reportFile = await requestStudentAssessmentPdf({
"BetterSEQTA-" + assessmentID,
String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); metaclassID,
studentID: 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,
}),
},
);
if (!printResponse.ok) {
throw new Error(
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
pdfUrl = getStudentAssessmentReportUrl(reportFile);
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
} }
if (pdfUrl.startsWith("blob:")) { if (pdfUrl.startsWith("blob:")) {
@@ -843,32 +851,37 @@ async function handleWeightings(mark: any, api: any) {
let text: string; let text: string;
try { try {
text = await extractPDFText(pdfUrl); text = await extractPDFTextWithRetry(pdfUrl);
} catch (error: any) { } catch (error: any) {
if ( if (
isFirefox && isFirefox &&
(error?.message?.includes("blob") || (error?.message?.includes("blob") ||
error?.message?.includes("Security") || error?.message?.includes("Security") ||
error?.message?.includes("CSP")) error?.message?.includes("CSP") ||
error?.message?.includes("empty"))
) { ) {
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
text = await extractPDFText(pdfUrl); text = await extractPDFTextWithRetry(pdfUrl, 2, 2000);
} else { } else {
throw new Error(`PDF extraction failed: ${error.message}`); 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 = {
...api.storage.weightings, ...api.storage.weightings,
[assessmentID]: { [assessmentID]: {
weight: match ? match[1] : "N/A", weight: weight ?? "N/A",
fingerprint, fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION, pluginVersion: WEIGHTING_SCHEMA_VERSION,
}, },
}; };
} catch (error: any) { } catch (error: any) {
console.error(
`[BetterSEQTA+] Weighting fetch failed for assessment ${assessmentID}:`,
error,
);
api.storage.weightings = { api.storage.weightings = {
...api.storage.weightings, ...api.storage.weightings,
[assessmentID]: { [assessmentID]: {
+3 -3
View File
@@ -5,8 +5,8 @@ import browser from "webextension-polyfill";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
// UI and theme management // UI and theme management
import pageState from "@/pageState.js?url"; import pageState from "@/pageState.js?script&iife";
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl";
import { installSeqtaMenuColourPatch } from "@/seqta/utils/patchSeqtaMenuUpdateColours"; import { installSeqtaMenuColourPatch } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
import { installThemeImagePagePatch } from "@/seqta/utils/patchThemeImagesPageContext"; import { installThemeImagePagePatch } from "@/seqta/utils/patchThemeImagesPageContext";
@@ -40,6 +40,6 @@ export async function main() {
function injectPageState() { function injectPageState() {
const mainScript = document.createElement("script"); const mainScript = document.createElement("script");
mainScript.src = resolveExtensionAssetUrl(pageState); mainScript.src = extensionPageScriptUrl(pageState);
document.head.appendChild(mainScript); document.head.appendChild(mainScript);
} }
@@ -3,8 +3,8 @@
* (injected script) plus Coloris / overlay cleanup in the content script. * (injected script) plus Coloris / overlay cleanup in the content script.
*/ */
import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?url"; import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?script&iife";
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl";
import { verboseInfo } from "@/utils/verboseLog"; import { verboseInfo } from "@/utils/verboseLog";
const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader"; const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader";
@@ -128,7 +128,7 @@ export function installSeqtaMenuColourPatch(): void {
const script = document.createElement("script"); const script = document.createElement("script");
script.id = PAGE_PATCH_LOADER_ID; script.id = PAGE_PATCH_LOADER_ID;
script.src = resolveExtensionAssetUrl(patchScript); script.src = extensionPageScriptUrl(patchScript);
script.addEventListener("load", () => script.remove()); script.addEventListener("load", () => script.remove());
(document.documentElement || document.head).appendChild(script); (document.documentElement || document.head).appendChild(script);
} }
@@ -2,8 +2,8 @@
* Bridge theme CSS and decorative images into PAGE JavaScript context. * Bridge theme CSS and decorative images into PAGE JavaScript context.
*/ */
import patchScript from "@/seqta/utils/themeImagePagePatch.js?url"; import patchScript from "@/seqta/utils/themeImagePagePatch.js?script&iife";
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { extensionPageScriptUrl } from "@/lib/extensionPageScriptUrl";
import { blobToBase64Data } from "@/plugins/built-in/themes/themeImageUrl"; import { blobToBase64Data } from "@/plugins/built-in/themes/themeImageUrl";
const PAGE_PATCH_LOADER_ID = "bsplus-theme-image-page-patch-loader"; const PAGE_PATCH_LOADER_ID = "bsplus-theme-image-page-patch-loader";
@@ -23,7 +23,7 @@ export function installThemeImagePagePatch(): void {
const script = document.createElement("script"); const script = document.createElement("script");
script.id = PAGE_PATCH_LOADER_ID; script.id = PAGE_PATCH_LOADER_ID;
script.src = resolveExtensionAssetUrl(patchScript); script.src = extensionPageScriptUrl(patchScript);
script.addEventListener("load", () => script.remove()); script.addEventListener("load", () => script.remove());
(document.documentElement || document.head).appendChild(script); (document.documentElement || document.head).appendChild(script);
} }