// ==UserScript== // @name PUA Academic Plan & Catalog Exporter v4.1 // @namespace local.pua.smartplanner // @version 4.1.0 // @description Reads the Academic Plan page first as the student-specific source of truth, then fetches and reconciles Catalog Details. // @match https://portal.pua.edu.eg/SelfService/Advising/AcademicPlan.aspx* // @match https://portal.pua.edu.eg/SelfService//Advising/AcademicPlan.aspx* // @grant none // @run-at document-idle // ==/UserScript== (() => { "use strict"; const COMPONENT_TYPES = new Set([ "Lecture", "Tutorial", "Practical", "Laboratory", "Lab", "Seminar", "Field", "Online", "Clinical", "Studio" ]); const FIELD_LABELS = new Set([ "Description", "Prerequisites", "Corequisites", "Fees", "Credits", "Credit Types" ]); const SPECIAL_REQUIREMENTS = new Set([ "PTEST", "PLACEMENT TEST" ]); const STATUS_PRIORITY = { COMPLETED: 50, BELOW_MIN: 40, IN_PROGRESS: 30, PLANNED: 20, NOT_COMPLETED: 10, UNKNOWN: 0 }; const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); function normalizeSpace(value) { return String(value ?? "") .replace(/\u00a0/g, " ") .replace(/[ \t]+/g, " ") .replace(/\s*\n\s*/g, "\n") .trim(); } function normalizeOneLine(value) { return normalizeSpace(value).replace(/\s*\n\s*/g, " ").trim(); } function normalizeCourseCode(value) { const decoded = decodeURIComponent(String(value ?? "").replace(/\+/g, " ")) .toUpperCase() .replace(/[-–—]+/g, " ") .replace(/\s+/g, " ") .trim(); if (/^(PTEST|PLACEMENT TEST)$/.test(decoded)) return decoded; const match = decoded.match(/([A-Z]{2,10})\s*(\d{1,3}[A-Z]?(?:_[A-Z0-9]+)?)/); return match ? `${match[1]} ${match[2]}` : decoded; } function normalizeComparable(value) { return normalizeOneLine(value) .toLowerCase() .replace(/&/g, "and") .replace(/[^a-z0-9\u0600-\u06ff]+/g, " ") .replace(/\s+/g, " ") .trim(); } function safeFilePart(value) { return String(value || "export") .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, "_") .slice(0, 100); } function downloadText(filename, text, mime = "application/json;charset=utf-8") { const blob = new Blob([text], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2500); } function downloadJson(filename, data) { downloadText(filename, JSON.stringify(data, null, 2)); } function csvEscape(value) { const text = value == null ? "" : String(value); return `"${text.replace(/"/g, '""')}"`; } function toCsv(rows) { return "\uFEFF" + rows.map(row => row.map(csvEscape).join(",")).join("\r\n"); } function elementSignal(element) { if (!element) return ""; const parts = [ element.textContent, element.getAttribute?.("title"), element.getAttribute?.("aria-label"), element.getAttribute?.("alt") ]; for (const child of element.querySelectorAll?.("img,[title],[aria-label],[alt]") || []) { parts.push( child.textContent, child.getAttribute?.("title"), child.getAttribute?.("aria-label"), child.getAttribute?.("alt") ); } return normalizeOneLine(parts.filter(Boolean).join(" ")); } function getNearestHeading(element) { const table = element?.closest?.("table"); let node = table || element; while (node) { let previous = node.previousElementSibling; while (previous) { if (/^H[1-6]$/.test(previous.tagName)) { return normalizeOneLine(previous.textContent); } const headings = [...(previous.querySelectorAll?.("h1,h2,h3,h4,h5,h6") || [])]; if (headings.length) return normalizeOneLine(headings[headings.length - 1].textContent); previous = previous.previousElementSibling; } node = node.parentElement; } return ""; } function statusFromTakenLink(link) { if (!link) return { status: "NOT_COMPLETED", confidence: "NO_STATUS_LINK", signal: "" }; const signal = elementSignal(link); const href = link.getAttribute("href") || ""; let code = ""; try { const url = new URL(href, location.href); code = String(url.searchParams.get("status") || "").toUpperCase(); } catch (_) { // Ignore malformed links and continue with text-based detection. } const text = `${signal} ${code}`.toLowerCase(); if (/completed|complete\b|\bstatus\s*c\b/.test(text) || code === "C") { return { status: "COMPLETED", confidence: "TAKEN_LINK", signal: `${signal} ${code}`.trim() }; } if (/below\s*min|failed|fail\b|\bstatus\s*x\b/.test(text) || code === "X") { return { status: "BELOW_MIN", confidence: "TAKEN_LINK", signal: `${signal} ${code}`.trim() }; } if (/in\s*progress|current|registered|\bstatus\s*i\b/.test(text) || ["I", "IP"].includes(code)) { return { status: "IN_PROGRESS", confidence: "TAKEN_LINK", signal: `${signal} ${code}`.trim() }; } if (/planned|pre-?registered/.test(text) || code === "P") { return { status: "PLANNED", confidence: "TAKEN_LINK", signal: `${signal} ${code}`.trim() }; } return { status: "UNKNOWN", confidence: "TAKEN_LINK_UNRECOGNIZED", signal: `${signal} ${code}`.trim() }; } function takenLinkEventCode(link) { if (!link) return ""; try { const url = new URL(link.href || link.getAttribute("href") || "", location.href); return normalizeCourseCode(url.searchParams.get("eventid") || ""); } catch (_) { return ""; } } function findTakenLinkForCatalogLink(catalogLink, expectedCourseCode) { const row = catalogLink.closest("tr"); if (!row) return null; const expected = normalizeCourseCode(expectedCourseCode); const candidateRows = [ row, row.previousElementSibling, row.nextElementSibling ].filter(Boolean); for (const candidateRow of candidateRows) { const links = [...candidateRow.querySelectorAll?.('a[href*="TakenCourseDetail.aspx"]') || []]; for (const link of links) { // Critical safeguard: PowerCampus often places the status row immediately // before the next course row. Never borrow a status from another course. if (takenLinkEventCode(link) === expected) return link; } } return null; } function parsePlanRow(catalogLink) { const row = catalogLink.closest("tr"); const cells = row ? [...row.cells] : []; const courseCell = catalogLink.closest("td,th"); const courseIndex = cells.indexOf(courseCell); const cellText = cells.map(cell => normalizeOneLine(cell.innerText || cell.textContent)); const code = normalizeCourseCode( new URL(catalogLink.href, location.href).searchParams.get("coursecode") || catalogLink.textContent ); const takenLink = findTakenLinkForCatalogLink(catalogLink, code); const statusInfo = statusFromTakenLink(takenLink); const name = courseIndex >= 0 ? (cellText[courseIndex + 1] || "") : ""; const subtype = courseIndex >= 0 ? (cellText[courseIndex + 2] || "") : ""; const creditsRaw = courseIndex >= 0 ? (cellText[courseIndex + 3] || "") : ""; const creditsNumber = Number.parseFloat(creditsRaw); const taken = {}; if (takenLink) { try { const takenUrl = new URL(takenLink.href, location.href); for (const key of ["year", "term", "session", "eventid", "subtype", "section", "status"]) { if (takenUrl.searchParams.has(key)) taken[key] = takenUrl.searchParams.get(key); } taken.url = takenUrl.href; } catch (_) { taken.url = takenLink.href; } } return { code, name, group: getNearestHeading(row || catalogLink), subtype, credits: Number.isFinite(creditsNumber) ? creditsNumber : null, status: statusInfo.status, statusConfidence: statusInfo.confidence, statusSignal: statusInfo.signal, taken, cells: cellText, rowText: normalizeOneLine(row?.innerText || row?.textContent || "") }; } function chooseOverallStatus(entries) { return [...entries] .sort((a, b) => (STATUS_PRIORITY[b.status] || 0) - (STATUS_PRIORITY[a.status] || 0))[0]?.status || "UNKNOWN"; } function collectExternalPlanCourses() { const links = [...document.querySelectorAll( 'a[href*="/Search/CatalogDetails.aspx?coursecode="], a[href*="CatalogDetails.aspx?coursecode="]' )]; const byCode = new Map(); for (const link of links) { const entry = parsePlanRow(link); if (!entry.code) continue; const catalogUrl = new URL(link.href, location.href).href; if (!byCode.has(entry.code)) { byCode.set(entry.code, { code: entry.code, catalogUrl, externalEntries: [] }); } const item = byCode.get(entry.code); const duplicate = item.externalEntries.some(existing => existing.group === entry.group && existing.rowText === entry.rowText && existing.status === entry.status ); if (!duplicate) item.externalEntries.push(entry); } const courses = [...byCode.values()]; for (const course of courses) { course.externalStatus = chooseOverallStatus(course.externalEntries); course.externalName = course.externalEntries.map(e => e.name).find(Boolean) || course.code; course.externalSubtypes = [...new Set(course.externalEntries.map(e => e.subtype).filter(Boolean))]; course.externalCredits = [...new Set(course.externalEntries.map(e => e.credits).filter(v => Number.isFinite(v)))]; course.externalGroups = [...new Set(course.externalEntries.map(e => e.group).filter(Boolean))]; } return courses; } function parsePlanSummary(pageText) { const text = normalizeOneLine(pageText); const headingMatch = text.match(/(20\d{2})\s*\/\s*(Fall|Spring|Summer)\s*-\s*([^\n]+?)(?=Courses:)/i); const coursesMatch = text.match(/Courses:\s*(\d+)\s*Min\s*\|\s*(\d+)\s*Max\s*\|\s*(\d+)\s*Complete\s*\|\s*(\d+)\s*Remaining/i); const creditsMatch = text.match(/Credits:\s*([\d.]+)\s*Min\s*\|\s*([\d.]+)\s*Max\s*\|\s*([\d.]+)\s*Complete\s*\|\s*([\d.]+)\s*Remaining/i); const gpaMatch = text.match(/GPA:\s*([\d.]+)\s*\(Min\s*([\d.]+)\)\s*\|\s*Overall\s*([\d.]+)\s*\(Min\s*([\d.]+)\)/i); return { curriculumYear: headingMatch ? Number(headingMatch[1]) : null, curriculumTerm: headingMatch?.[2] || "", curriculumName: headingMatch?.[3]?.trim() || "", courses: coursesMatch ? { min: Number(coursesMatch[1]), max: Number(coursesMatch[2]), complete: Number(coursesMatch[3]), remaining: Number(coursesMatch[4]) } : null, credits: creditsMatch ? { min: Number(creditsMatch[1]), max: Number(creditsMatch[2]), complete: Number(creditsMatch[3]), remaining: Number(creditsMatch[4]) } : null, gpa: gpaMatch ? { plan: Number(gpaMatch[1]), planMinimum: Number(gpaMatch[2]), overall: Number(gpaMatch[3]), overallMinimum: Number(gpaMatch[4]) } : null }; } function findStudentInfo() { const heading = [...document.querySelectorAll("h1,h2,h3")] .map(el => normalizeOneLine(el.textContent)) .find(text => /Academic Plan\s*-/i.test(text)) || ""; const nameMatch = heading.match(/Academic Plan\s*-\s*(.+)$/i); const currentUrl = new URL(location.href); return { name: nameMatch?.[1]?.trim() || "", studentId: currentUrl.searchParams.get("studentId") || "", pageTitle: document.title, sourceUrl: location.href }; } function directTableRows(table) { return [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tfoot > tr, :scope > tr")]; } function parseNotAssignedSummary(pageText) { const text = normalizeOneLine(pageText); const match = text.match( /Courses Not Assigned to this Academic Plan\s+Courses:\s*(\d+)\s*Complete\s*\|\s*(\d+)\s*In Progress/i ); return match ? { complete: Number(match[1]), inProgress: Number(match[2]), totalRowsExpected: Number(match[1]) + Number(match[2]) } : null; } function findNotAssignedRows() { const result = []; // Use only direct rows of the real data table. PowerCampus nests layout // tables, and recursive querySelectorAll("tr") duplicated each row in v3. const candidateTables = [...document.querySelectorAll("table")].filter(table => { const rows = directTableRows(table); return rows.some(row => { const headers = [...row.cells] .map(cell => normalizeOneLine(cell.textContent).toLowerCase()); return ( headers.some(value => value === "course" || value.includes("course")) && headers.some(value => value.includes("section")) && headers.some(value => value.includes("final grade")) && headers.some(value => value.includes("credits")) && headers.some(value => value.includes("taken")) ); }); }); for (const table of candidateTables) { const tableRows = directTableRows(table); const headerRowIndex = tableRows.findIndex(row => { const headers = [...row.cells] .map(cell => normalizeOneLine(cell.textContent).toLowerCase()); return ( headers.some(value => value === "course" || value.includes("course")) && headers.some(value => value.includes("section")) && headers.some(value => value.includes("final grade")) && headers.some(value => value.includes("taken")) ); }); if (headerRowIndex < 0) continue; const headers = [...tableRows[headerRowIndex].cells] .map(cell => normalizeOneLine(cell.textContent).toLowerCase()); const exactIndex = (...names) => headers.findIndex(header => names.some(name => header === name || header.replace(/\s+/g, " ") === name) ); const containsIndex = (...names) => headers.findIndex(header => names.some(name => header.includes(name)) ); const indexes = { status: exactIndex("status"), course: exactIndex("course"), name: exactIndex("name"), subtype: exactIndex("sub type", "subtype"), section: exactIndex("section"), finalGrade: exactIndex("final grade"), credits: exactIndex("credits"), taken: exactIndex("taken"), repeated: exactIndex("repeated") }; // Fallback only when exact header matching is unavailable. if (indexes.course < 0) indexes.course = containsIndex("course"); if (indexes.name < 0) indexes.name = containsIndex("name"); if (indexes.subtype < 0) indexes.subtype = containsIndex("sub type", "subtype"); if (indexes.section < 0) indexes.section = containsIndex("section"); if (indexes.finalGrade < 0) indexes.finalGrade = containsIndex("final grade"); if (indexes.credits < 0) indexes.credits = containsIndex("credits"); if (indexes.taken < 0) indexes.taken = containsIndex("taken"); if (indexes.repeated < 0) indexes.repeated = containsIndex("repeated"); if (indexes.status < 0) indexes.status = containsIndex("status"); if (indexes.course < 0 || indexes.subtype < 0 || indexes.taken < 0) continue; for (const row of tableRows.slice(headerRowIndex + 1)) { const cells = [...row.cells].map(cell => normalizeOneLine(cell.textContent)); if (!cells.length) continue; const code = normalizeCourseCode(cells[indexes.course] || ""); const subtype = cells[indexes.subtype] || ""; const taken = cells[indexes.taken] || ""; if (!/^[A-Z]{2,10}\s+\d{1,3}[A-Z]?(?:_[A-Z0-9]+)?$/.test(code)) continue; if (!COMPONENT_TYPES.has(subtype)) continue; if (!/\b20\d{2}\s*\/\s*(Fall|Spring|Summer)\b/i.test(taken)) continue; const creditText = indexes.credits >= 0 ? cells[indexes.credits] : ""; const creditNumber = Number.parseFloat(creditText); result.push({ status: indexes.status >= 0 ? cells[indexes.status] : "", code, name: indexes.name >= 0 ? cells[indexes.name] : "", subtype, section: indexes.section >= 0 ? cells[indexes.section] : "", finalGrade: indexes.finalGrade >= 0 ? cells[indexes.finalGrade] : "", credits: Number.isFinite(creditNumber) ? creditNumber : null, taken, repeated: indexes.repeated >= 0 ? cells[indexes.repeated] : "", rowText: normalizeOneLine(row.innerText || row.textContent || "") }); } } const seen = new Set(); return result.filter(item => { const key = [ item.code, item.subtype, item.section, item.finalGrade, item.credits, item.taken ].join("|"); if (seen.has(key)) return false; seen.add(key); return true; }); } function extractCatalogLines(doc) { const raw = String(doc.body?.innerText || doc.body?.textContent || "").replace(/\r/g, "\n"); let lines = raw .split(/\n+/) .map(line => normalizeOneLine(line)) .filter(Boolean); const catalogIndexes = lines .map((line, index) => /^Catalog Details$/i.test(line) ? index : -1) .filter(index => index >= 0); if (catalogIndexes.length) lines = lines.slice(catalogIndexes[catalogIndexes.length - 1] + 1); const stopIndex = lines.findIndex(line => /^Find Course Sections$/i.test(line) || /^PowerCampus/i.test(line)); if (stopIndex >= 0) lines = lines.slice(0, stopIndex); return lines; } function isComponentStart(lines, index) { return COMPONENT_TYPES.has(lines[index]) && lines[index + 1] === "Description"; } function parseRequirementAtom(text) { const cleaned = normalizeOneLine(text) .replace(/^\(+|\)+$/g, "") .replace(/\*+/g, "") .trim(); const codeMatch = cleaned.match(/^([A-Z]{2,10}\s*\d{1,3}[A-Z]?(?:_[A-Z0-9]+)?|PTEST|PLACEMENT TEST)/i); const subtypeMatch = cleaned.match(/\/\s*([^<>()]+?)(?=\s*<|$)/); const minGradeMatch = cleaned.match(/min\s+grade\s*=\s*([A-Z]+|\d+(?:\.\d+)?)/i); const minScoreMatch = cleaned.match(/min\s+score\s*=\s*(\d+(?:\.\d+)?)/i); const minCreditMatch = cleaned.match(/min\s+credit\s*=\s*(\d+(?:\.\d+)?)/i); return { type: "REQUIREMENT", raw: cleaned, courseCode: codeMatch ? normalizeCourseCode(codeMatch[1]) : "", subtype: subtypeMatch ? normalizeOneLine(subtypeMatch[1]) : "", minimumGrade: minGradeMatch?.[1] || "", minimumScore: minScoreMatch ? Number(minScoreMatch[1]) : null, minimumCredit: minCreditMatch ? Number(minCreditMatch[1]) : null }; } function tokenizeRequirementExpression(raw) { const text = normalizeOneLine(raw); const tokens = []; let buffer = ""; let angleDepth = 0; const flush = () => { const value = buffer.trim(); if (value) tokens.push({ type: "ATOM", value }); buffer = ""; }; for (let i = 0; i < text.length; i++) { const char = text[i]; if (char === "<") angleDepth += 1; if (char === ">" && angleDepth > 0) angleDepth -= 1; if (angleDepth === 0 && (char === "(" || char === ")")) { flush(); tokens.push({ type: char }); continue; } if (angleDepth === 0) { const rest = text.slice(i); const operator = rest.match(/^\s+(and|or)\s+/i); if (operator) { flush(); tokens.push({ type: operator[1].toUpperCase() }); i += operator[0].length - 1; continue; } } buffer += char; } flush(); return tokens; } function mergeLogicNode(type, left, right) { const children = []; if (left?.type === type) children.push(...left.children); else if (left) children.push(left); if (right?.type === type) children.push(...right.children); else if (right) children.push(right); return { type, children }; } function parseRequirementExpression(raw) { const text = normalizeOneLine(raw); if (!text || /^N\/A$/i.test(text)) { return { raw: text, logic: null, atoms: [], parseComplete: true }; } const tokens = tokenizeRequirementExpression(text); let position = 0; function parsePrimary() { const token = tokens[position]; if (!token) return null; if (token.type === "(") { position += 1; const node = parseOr(); if (tokens[position]?.type === ")") position += 1; return node; } if (token.type === "ATOM") { position += 1; return parseRequirementAtom(token.value); } return null; } function parseAnd() { let node = parsePrimary(); while (tokens[position]?.type === "AND") { position += 1; node = mergeLogicNode("ALL", node, parsePrimary()); } return node; } function parseOr() { let node = parseAnd(); while (tokens[position]?.type === "OR") { position += 1; node = mergeLogicNode("ANY", node, parseAnd()); } return node; } const logic = parseOr(); const atoms = []; (function collect(node) { if (!node) return; if (node.type === "REQUIREMENT") atoms.push(node); for (const child of node.children || []) collect(child); })(logic); return { raw: text, logic, atoms, parseComplete: position === tokens.length }; } function parseCatalogDocument(doc, expectedCourseCode, includeRawText) { const lines = extractCatalogLines(doc); const expected = normalizeCourseCode(expectedCourseCode); const titleIndex = lines.findIndex(line => normalizeCourseCode(line.split("-")[0]) === expected); const actualTitleIndex = titleIndex >= 0 ? titleIndex : 0; const titleLine = lines[actualTitleIndex] || ""; const titleMatch = titleLine.match(/^(.+?)\s*-\s*(.+)$/); const catalogCode = normalizeCourseCode(titleMatch?.[1] || expected); const catalogName = normalizeOneLine(titleMatch?.[2] || titleLine); const firstComponentIndex = lines.findIndex((_, index) => isComponentStart(lines, index)); const overviewLines = firstComponentIndex >= 0 ? lines.slice(actualTitleIndex + 1, firstComponentIndex) : lines.slice(actualTitleIndex + 1); let hoursSummary = ""; const descriptionLines = [...overviewLines]; if (descriptionLines[0] && /(hour|lecture|tutorial|practical|theoretical)/i.test(descriptionLines[0]) && /\d/.test(descriptionLines[0])) { hoursSummary = descriptionLines.shift(); } const components = []; if (firstComponentIndex >= 0) { let index = firstComponentIndex; while (index < lines.length) { if (!isComponentStart(lines, index)) { index += 1; continue; } const component = { type: lines[index], description: "", prerequisitesRaw: "", prerequisites: { raw: "", logic: null, atoms: [], parseComplete: true }, corequisitesRaw: "", corequisites: { raw: "", logic: null, atoms: [], parseComplete: true }, fees: "", credits: null, creditTypes: "" }; index += 1; while (index < lines.length && !isComponentStart(lines, index)) { const label = lines[index]; if (!FIELD_LABELS.has(label)) { index += 1; continue; } const values = []; index += 1; while ( index < lines.length && !FIELD_LABELS.has(lines[index]) && !isComponentStart(lines, index) ) { values.push(lines[index]); index += 1; } const value = normalizeOneLine(values.join(" ")); if (label === "Description") component.description = value; if (label === "Prerequisites") { component.prerequisitesRaw = value; component.prerequisites = parseRequirementExpression(value); } if (label === "Corequisites") { component.corequisitesRaw = value; component.corequisites = parseRequirementExpression(value); } if (label === "Fees") component.fees = value; if (label === "Credits") { const number = Number.parseFloat(value); component.credits = Number.isFinite(number) ? number : null; } if (label === "Credit Types") component.creditTypes = value; } components.push(component); } } const result = { code: catalogCode, name: catalogName, title: titleLine, hoursSummary, description: normalizeOneLine(descriptionLines.join(" ")), components }; if (includeRawText) result.rawText = lines.join("\n"); return result; } async function fetchCatalog(course, includeRawText) { const response = await fetch(course.catalogUrl, { method: "GET", credentials: "include", cache: "no-store", headers: { "Accept": "text/html,application/xhtml+xml" } }); const html = await response.text(); if (!response.ok) throw new Error(`HTTP ${response.status}`); if (/Log\s*In|Sign\s*In|Password/i.test(html) && !/Catalog Details/i.test(html)) { throw new Error("Session expired or the portal returned the login page."); } const doc = new DOMParser().parseFromString(html, "text/html"); return parseCatalogDocument(doc, course.code, includeRawText); } function namesLikelyMatch(a, b) { const left = normalizeComparable(a); const right = normalizeComparable(b); if (!left || !right) return true; if (left === right || left.includes(right) || right.includes(left)) return true; const leftTokens = new Set(left.split(" ").filter(token => token.length > 2)); const rightTokens = new Set(right.split(" ").filter(token => token.length > 2)); const overlap = [...leftTokens].filter(token => rightTokens.has(token)).length; return overlap >= Math.min(2, leftTokens.size, rightTokens.size); } function annotateRequirementMatches(catalog, externalCodeSet) { for (const component of catalog.components || []) { for (const field of [component.prerequisites, component.corequisites]) { for (const atom of field?.atoms || []) { const code = normalizeCourseCode(atom.courseCode); const specialRequirement = SPECIAL_REQUIREMENTS.has(code); atom.match = { normalizedCode: code, inExternalPlan: externalCodeSet.has(code), specialRequirement, managedBy: specialRequirement ? "UNIVERSITY" : "ACADEMIC_PLAN", advisorBlocking: !specialRequirement, classification: specialRequirement ? "UNIVERSITY_MANAGED_REQUIREMENT" : externalCodeSet.has(code) ? "COURSE_IN_CURRENT_PLAN" : "CATALOG_REFERENCE_OUTSIDE_CURRENT_PLAN" }; } } } } function reconcileCourse(course, externalCodeSet) { const issues = []; const catalog = course.catalog; if (!catalog) { return { sourcePrecedence: ["EXTERNAL_ACADEMIC_PLAN", "CATALOG_DETAILS"], effective: { code: course.code, name: course.externalName, status: course.externalStatus, groups: course.externalGroups, subtypes: course.externalSubtypes, credits: course.externalCredits[0] ?? null }, matchStatus: "CATALOG_FETCH_FAILED", issues: [{ severity: "ERROR", code: "NO_CATALOG", message: course.catalogError || "Catalog details unavailable." }] }; } annotateRequirementMatches(catalog, externalCodeSet); if (normalizeCourseCode(catalog.code) !== normalizeCourseCode(course.code)) { issues.push({ severity: "ERROR", code: "COURSE_CODE_MISMATCH", external: course.code, catalog: catalog.code }); } if (!namesLikelyMatch(course.externalName, catalog.name)) { issues.push({ severity: "WARNING", code: "COURSE_NAME_MISMATCH", external: course.externalName, catalog: catalog.name }); } const componentTypes = [...new Set((catalog.components || []).map(component => component.type))]; for (const subtype of course.externalSubtypes) { if (subtype && !componentTypes.some(type => type.toLowerCase() === subtype.toLowerCase())) { issues.push({ severity: "WARNING", code: "SUBTYPE_NOT_IN_CATALOG", externalSubtype: subtype, catalogTypes: componentTypes }); } } const preferredComponent = (catalog.components || []).find(component => course.externalSubtypes.some(subtype => subtype.toLowerCase() === component.type.toLowerCase()) && Number.isFinite(component.credits) && component.credits > 0 ) || (catalog.components || []).find(component => Number.isFinite(component.credits) && component.credits > 0) || (catalog.components || [])[0]; const catalogCredits = preferredComponent?.credits ?? null; for (const externalCredit of course.externalCredits) { if (Number.isFinite(catalogCredits) && Math.abs(externalCredit - catalogCredits) > 0.001) { issues.push({ severity: "WARNING", code: "CREDIT_MISMATCH", external: externalCredit, catalog: catalogCredits }); } } const uniqueComponentTypes = new Set(componentTypes.map(type => type.toLowerCase())); if (uniqueComponentTypes.size !== componentTypes.length) { issues.push({ severity: "ERROR", code: "DUPLICATE_COMPONENT_TYPES", catalogTypes: componentTypes }); } for (const component of catalog.components || []) { if (component.prerequisitesRaw && !component.prerequisites.parseComplete) { issues.push({ severity: "WARNING", code: "PREREQUISITE_PARSE_INCOMPLETE", component: component.type, raw: component.prerequisitesRaw }); } if (component.corequisitesRaw && !component.corequisites.parseComplete) { issues.push({ severity: "WARNING", code: "COREQUISITE_PARSE_INCOMPLETE", component: component.type, raw: component.corequisitesRaw }); } } const effectiveCredits = course.externalCredits[0] ?? catalogCredits; return { sourcePrecedence: [ "EXTERNAL_ACADEMIC_PLAN_FOR_STUDENT_STATUS_GROUP_AND_CURRICULUM_MEMBERSHIP", "CATALOG_DETAILS_FOR_DEFINITION_COMPONENTS_DESCRIPTION_AND_REQUIREMENTS" ], effective: { code: course.code, name: catalog.name || course.externalName, nameSources: { selected: catalog.name ? "CATALOG_DETAILS" : "EXTERNAL_ACADEMIC_PLAN", catalog: catalog.name || "", externalAcademicPlan: course.externalName || "" }, status: course.externalStatus, groups: course.externalGroups, subtypes: componentTypes.length ? componentTypes : course.externalSubtypes, credits: Number.isFinite(effectiveCredits) ? effectiveCredits : null, description: catalog.description, components: catalog.components }, matchStatus: issues.some(issue => issue.severity === "ERROR") ? "ERROR" : issues.length ? "MATCHED_WITH_WARNINGS" : "MATCHED", issues }; } function validateExternalPlan(summary, courses) { const counts = courses.reduce((acc, course) => { acc[course.externalStatus] = (acc[course.externalStatus] || 0) + 1; return acc; }, {}); const warnings = []; if (summary.courses?.complete != null && counts.COMPLETED !== summary.courses.complete) { warnings.push({ code: "COMPLETED_COUNT_MISMATCH", expectedFromPlanSummary: summary.courses.complete, detectedFromStatusLinks: counts.COMPLETED || 0, message: "The external plan summary and row-level Completed statuses do not match. Review the status-link parser or portal layout." }); } return { countsByStatus: counts, warnings }; } function buildCourseCsv(courses) { const rows = [[ "course_code", "effective_name", "external_status", "groups", "effective_credits", "catalog_component_types", "prerequisites_raw", "corequisites_raw", "match_status", "issue_codes", "catalog_url" ]]; for (const course of courses) { const components = course.catalog?.components || []; rows.push([ course.code, course.reconciliation?.effective?.name || course.externalName || "", course.externalStatus, course.externalGroups.join(" | "), course.reconciliation?.effective?.credits ?? "", components.map(component => component.type).join(" | "), components.map(component => component.prerequisitesRaw).filter(Boolean).join(" || "), components.map(component => component.corequisitesRaw).filter(Boolean).join(" || "), course.reconciliation?.matchStatus || "", (course.reconciliation?.issues || []).map(issue => issue.code).join(" | "), course.catalogUrl ]); } return toCsv(rows); } function buildValidationCsv(exportData) { const rows = [["scope", "course_code", "severity", "issue_code", "details"]]; for (const warning of exportData.validation.externalPlan.warnings || []) { rows.push(["EXTERNAL_PLAN", "", "WARNING", warning.code, JSON.stringify(warning)]); } for (const course of exportData.courses) { for (const issue of course.reconciliation?.issues || []) { rows.push(["COURSE_RECONCILIATION", course.code, issue.severity || "WARNING", issue.code, JSON.stringify(issue)]); } if (course.catalogError) { rows.push(["CATALOG_FETCH", course.code, "ERROR", "CATALOG_FETCH_FAILED", course.catalogError]); } } return toCsv(rows); } function addUi() { const panel = document.createElement("div"); panel.id = "pua-exporter-v2-panel"; panel.innerHTML = `
PUA Exporter v4.1
جاهز
`; Object.assign(panel.style, { position: "fixed", zIndex: "2147483647", right: "12px", bottom: "12px", width: "290px", padding: "12px", borderRadius: "12px", background: "#111827", color: "#fff", boxShadow: "0 8px 28px rgba(0,0,0,.35)", fontFamily: "Arial, sans-serif", direction: "rtl" }); const start = panel.querySelector("#pua-exporter-start"); Object.assign(start.style, { width: "100%", border: "0", borderRadius: "9px", padding: "11px", background: "#22c55e", color: "#052e16", fontWeight: "800", cursor: "pointer" }); const status = panel.querySelector("#pua-exporter-status"); Object.assign(status.style, { marginTop: "8px", fontSize: "12px", lineHeight: "1.55", maxHeight: "130px", overflow: "auto", whiteSpace: "pre-wrap" }); const downloads = panel.querySelector("#pua-exporter-downloads"); for (const button of downloads.querySelectorAll("button")) { Object.assign(button.style, { border: "1px solid #6b7280", borderRadius: "8px", padding: "7px 9px", background: "#1f2937", color: "#fff", cursor: "pointer", fontSize: "12px" }); } document.body.appendChild(panel); return { panel, start, status, downloads, includeRaw: panel.querySelector("#pua-exporter-raw") }; } let lastExport = null; let lastBaseName = "PUA_Academic_Plan"; async function runExport(ui) { ui.start.disabled = true; ui.start.style.opacity = "0.65"; ui.downloads.style.display = "none"; try { ui.status.textContent = "المرحلة 1/3: قراءة صفحة Academic Plan الخارجية..."; const student = findStudentInfo(); const pageText = normalizeSpace(document.body.innerText || document.body.textContent || ""); const planSummary = parsePlanSummary(pageText); const courses = collectExternalPlanCourses(); const statusCourseMismatches = []; for (const course of courses) { for (const entry of course.externalEntries || []) { const linkedCode = normalizeCourseCode(entry.taken?.eventid || ""); if (linkedCode && linkedCode !== course.code) { statusCourseMismatches.push({ courseCode: course.code, linkedEventId: linkedCode, linkedUrl: entry.taken?.url || "" }); } } } const notAssigned = findNotAssignedRows(); const notAssignedSummary = parseNotAssignedSummary(pageText); const notAssignedValidation = { summary: notAssignedSummary, detectedRows: notAssigned.length, warnings: [] }; if ( notAssignedSummary?.totalRowsExpected != null && notAssigned.length !== notAssignedSummary.totalRowsExpected ) { notAssignedValidation.warnings.push({ code: "NOT_ASSIGNED_ROW_COUNT_MISMATCH", expected: notAssignedSummary.totalRowsExpected, detected: notAssigned.length, message: "The Courses Not Assigned summary and parsed row count do not match." }); } if (!courses.length) { throw new Error("لم يتم العثور على روابط مواد في صفحة Academic Plan."); } const externalValidation = validateExternalPlan(planSummary, courses); const statusLine = Object.entries(externalValidation.countsByStatus) .map(([key, value]) => `${key}: ${value}`) .join(" | "); ui.status.textContent = [ "المرحلة 1/3 تمت.", `المواد الخارجية: ${courses.length}`, statusLine, `تحذيرات الخارجي: ${externalValidation.warnings.length}`, "المرحلة 2/3: قراءة Catalog Details..." ].join("\n"); const includeRawText = Boolean(ui.includeRaw.checked); const fetchErrors = []; for (let index = 0; index < courses.length; index++) { const course = courses[index]; ui.status.textContent = [ `الخارجي: ${courses.length} مادة`, `الداخلي ${index + 1}/${courses.length}: ${course.code}`, `أخطاء التحميل حتى الآن: ${fetchErrors.length}` ].join("\n"); try { course.catalog = await fetchCatalog(course, includeRawText); } catch (error) { course.catalogError = String(error?.message || error); fetchErrors.push({ code: course.code, error: course.catalogError }); } // Reduce load on the university server. await sleep(700); } ui.status.textContent = "المرحلة 3/3: مطابقة الخارجي مع الداخلي وبناء تقرير التحقق..."; const externalCodeSet = new Set(courses.map(course => normalizeCourseCode(course.code))); for (const course of courses) { course.reconciliation = reconcileCourse(course, externalCodeSet); } const reconciliationCounts = courses.reduce((acc, course) => { const key = course.reconciliation?.matchStatus || "UNKNOWN"; acc[key] = (acc[key] || 0) + 1; return acc; }, {}); const exportData = { schemaVersion: "pua-academic-plan-export-v4", exportedAt: new Date().toISOString(), extractionStrategy: { order: [ "1_EXTERNAL_ACADEMIC_PLAN", "2_INTERNAL_CATALOG_DETAILS", "3_RECONCILIATION" ], precedence: { studentStatus: "EXTERNAL_ACADEMIC_PLAN", curriculumMembership: "EXTERNAL_ACADEMIC_PLAN", requirementGroup: "EXTERNAL_ACADEMIC_PLAN", courseDefinition: "CATALOG_DETAILS", components: "CATALOG_DETAILS", description: "CATALOG_DETAILS", prerequisites: "CATALOG_DETAILS", corequisites: "CATALOG_DETAILS", effectiveCredits: "EXTERNAL_IF_PRESENT_OTHERWISE_CATALOG" } }, student, externalAcademicPlan: { sourceUrl: location.href, summary: planSummary, rawText: includeRawText ? pageText : undefined, coursesNotAssignedSummary: notAssignedSummary, coursesNotAssigned: notAssigned }, validation: { externalPlan: externalValidation, coursesNotAssigned: notAssignedValidation, catalogFetchErrors: fetchErrors, statusCourseMismatches, reconciliationCounts }, courses, notes: [ "The Academic Plan page is read before any catalog request.", "Student-specific completion status and curriculum membership come from the external Academic Plan page.", "Catalog Details supply definitions, components, descriptions, prerequisites and corequisites.", "The English zero-level course is taken from the student's external plan; the exporter does not substitute UGE 00 and UGE 00C.", "PTEST and Placement Test are university-managed requirements. They are exported for audit but are marked advisorBlocking=false.", "Catalog Details provide the canonical course name; the Academic Plan name is retained separately for reconciliation and audit.", "English offerings are handled by the advising planner: Fall levels 1/3/5 and Spring levels 0/2/4.", "Requirement expressions are preserved as raw text and parsed into an AND/OR logic tree.", "Courses Not Assigned rows are parsed only from the direct data table to prevent nested-table duplicates.", "Verify warnings in the validation report before importing the result into an advising engine." ] }; lastExport = exportData; const date = new Date().toISOString().slice(0, 10); lastBaseName = safeFilePart(`PUA_Academic_Plan_v4_${student.studentId || student.name || date}_${date}`); ui.downloads.style.display = "flex"; ui.status.textContent = [ "تمت القراءة والمطابقة.", `المواد: ${courses.length}`, `أخطاء تحميل Catalog: ${fetchErrors.length}`, ...Object.entries(reconciliationCounts).map(([key, value]) => `${key}: ${value}`), "استخدم أزرار التنزيل أدناه." ].join("\n"); } catch (error) { console.error(error); ui.status.textContent = `خطأ: ${error?.message || error}`; alert(`تعذر التصدير:\n${error?.message || error}`); } finally { ui.start.disabled = false; ui.start.style.opacity = "1"; } } const ui = addUi(); ui.start.addEventListener("click", () => runExport(ui)); ui.downloads.addEventListener("click", event => { const button = event.target.closest("button[data-download]"); if (!button || !lastExport) return; const type = button.dataset.download; if (type === "json") { downloadJson(`${lastBaseName}.json`, lastExport); } else if (type === "courses") { downloadText(`${lastBaseName}_courses.csv`, buildCourseCsv(lastExport.courses), "text/csv;charset=utf-8"); } else if (type === "validation") { downloadText(`${lastBaseName}_validation.csv`, buildValidationCsv(lastExport), "text/csv;charset=utf-8"); } }); })();