feat: on-page Download PDF opens Letter-size printable Study Load; drop pdf-lib/background path

This commit is contained in:
adrianbonpin
2026-08-18 19:28:44 +08:00
parent d6bacef1ec
commit cb2beecc0e
12 changed files with 287 additions and 480 deletions
-47
View File
@@ -1,47 +0,0 @@
'use strict';
function errMsg(err) {
return String((err && err.message) || err);
}
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === 'scrape-study-load') {
browser.tabs.query({ active: true, currentWindow: true })
.then((tabs) => {
const tab = tabs[0];
if (!tab || tab.id == null) throw new Error('No active tab.');
return browser.tabs.sendMessage(tab.id, { type: 'scrape-study-load' });
})
.then((data) => sendResponse(data))
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
return true;
}
if (msg && msg.type === 'generate-pdf') {
generatePdf(msg.data)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
return true;
}
});
async function generatePdf(data) {
const bytes = await ISMIStools.buildStudyLoadPdf(data);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const name = (data.header && data.header.fullName) || 'Student';
const filename = 'Study Load - ' + name + '.pdf';
try {
const id = await browser.downloads.download({ url, filename, saveAs: true });
const onChanged = (delta) => {
if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) {
browser.downloads.onChanged.removeListener(onChanged);
URL.revokeObjectURL(url);
}
};
browser.downloads.onChanged.addListener(onChanged);
} catch (err) {
URL.revokeObjectURL(url);
throw err;
}
}
+76 -9
View File
@@ -123,22 +123,89 @@
header = {}; header = {};
} }
const totalUnits = extractTotalUnits(document.body.innerText); return { header, courses };
return { header, courses, totalUnits };
} }
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { // ---- On-page Download PDF button ----
if (msg && msg.type === 'scrape-study-load') { const BTN_ID = 'ismisHelperDownloadBtn';
scrapeStudyLoad()
.then((data) => sendResponse({ ok: true, data })) function showToast(msg, isError) {
.catch((err) => sendResponse({ ok: false, error: String((err && err.message) || err) })); if (window.toastr) {
return true; // async response if (isError) window.toastr.error(msg);
else window.toastr.success(msg);
} else if (isError) {
alert(msg);
} }
}); }
function findTotalUnitsP() {
const ps = document.querySelectorAll('#StudyLoad p');
for (const p of ps) {
if (/Total Units/i.test(p.textContent || '')) return p;
}
return null;
}
async function onDownloadPdf() {
const btn = document.getElementById(BTN_ID);
try {
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Opening...';
const { header, courses } = await scrapeStudyLoad();
const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included);
const html = ISMIStools.buildStudyLoadHtml({ header, courses: included, totalUnits });
const win = window.open('', '_blank');
if (!win) {
showToast('Popup blocked — allow popups for this site, then try again.', true);
return;
}
win.document.write(html);
win.document.close();
showToast('Opened printable Study Load.');
} catch (err) {
showToast('Error: ' + ((err && err.message) || err), true);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
}
}
function injectDownloadButton() {
if (document.getElementById(BTN_ID)) return;
const unitsP = findTotalUnitsP();
if (!unitsP) return;
const btn = document.createElement('button');
btn.id = BTN_ID;
btn.type = 'button';
btn.className = 'btn green pull-right';
btn.style.marginTop = '10px';
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
btn.addEventListener('click', onDownloadPdf);
unitsP.insertAdjacentElement('afterend', btn);
}
function tryInjectButton() {
if (document.getElementById(BTN_ID)) return;
if (document.querySelector('#StudyLoad table.table-forum')) injectDownloadButton();
}
function watchStudyLoad() {
tryInjectButton();
const observer = new MutationObserver((mutations) => {
const inStudyLoad = mutations.some(
(m) => m.target && m.target.closest && m.target.closest('#StudyLoad')
);
if (inStudyLoad) tryInjectButton();
});
observer.observe(document.body, { childList: true, subtree: true });
}
(async function init() { (async function init() {
if (!(await isEnabled())) return; if (!(await isEnabled())) return;
watchPrivacyModal(); watchPrivacyModal();
if (/\/Account\/Login/i.test(location.href)) autoLogin(); if (/\/Account\/Login/i.test(location.href)) autoLogin();
if (document.getElementById('StudyLoad')) watchStudyLoad();
})(); })();
})(); })();
@@ -11,40 +11,42 @@ student portal experience. It provides three features:
1. **Auto-login** — auto-fill and auto-submit the login form from a saved profile. 1. **Auto-login** — auto-fill and auto-submit the login form from a saved profile.
2. **Auto-accept Privacy Policy** — dismiss the recurring privacy-policy modal. 2. **Auto-accept Privacy Policy** — dismiss the recurring privacy-policy modal.
3. **Study Load PDF** — generate a PDF of the student's study load, replicating the 3. **Printable Study Load** — an on-page "Download PDF" button on the Study Load page
official ISMIS document, including course descriptions looked up from the that opens a print-ready Letter-size document replicating the official ISMIS Study
Offered Courses endpoint. Load (with course descriptions looked up from the Offered Courses endpoint),
triggering the browser print dialog so the user can print or "Save as PDF".
A single **master toggle** disables all extension behavior on the portal. A single **master toggle** disables all extension behavior on the portal.
## Architecture ## Architecture
Single MV3 extension, plain JavaScript, no bundler. Single MV3 extension, plain JavaScript, no bundler. No background script — all logic
runs in the content script (scraping and print-document building happen in-page).
``` ```
ismis-helper/ ismis-helper/
├── manifest.json # MV3 manifest, permissions, content script, popup ├── manifest.json # MV3 manifest, permissions, content script, popup
├── background.js # service worker: message routing + PDF generation ├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, button, print flow
├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, scraping ├── parsers.js # UMD: table/profile/offered-courses parsing + filtering
├── popup.html # popup UI ├── printdoc.js # UMD: buildStudyLoadHtml (Letter-size printable document)
├── popup.html # popup UI (master toggle + profiles)
├── popup.js # popup logic ├── popup.js # popup logic
├── popup.css # popup styles ├── popup.css # popup styles
├── libs/pdf-lib.min.js # vendored pdf-lib library
└── icons/ # extension icons (16, 32, 48, 128) └── icons/ # extension icons (16, 32, 48, 128)
``` ```
### Permissions (manifest.json) ### Permissions (manifest.json)
- `storage` — persist profiles and the master toggle. - `storage` — persist profiles and the master toggle.
- `downloads` — save the generated Study Load PDF. - `host_permissions` — `https://ismis.usc.edu.ph/*` for the content script.
- `activeTab` — access the current tab for scraping. - No background, no `downloads` permission (the print flow uses `window.open` +
- Content script matches `https://ismis.usc.edu.ph/*`. the browser's print dialog).
## Component 1 — Auto-login ## Component 1 — Auto-login
- Content script runs on `https://ismis.usc.edu.ph/Account/Login`. - Content script runs on `https://ismis.usc.edu.ph/Account/Login`.
- Reads the active (first enabled) profile from `browser.storage.local`. - Reads the active (enabled) profile from `browser.storage.local`.
- Locates the username and password input fields (by common `name`/`id` values such - Locates the username and password input fields (`name`/`id` = `Username` /
as `UserName`, `Password`, `username`, `password`). `Password`, with fallback variants).
- Fills both fields, then submits the form (auto-submit). - Fills both fields, then submits the form (auto-submit).
- If no profile is enabled, or the master toggle is off, does nothing. - If no profile is enabled, or the master toggle is off, does nothing.
@@ -62,68 +64,74 @@ ismis-helper/
- Stored in `browser.storage.local` under key `profiles` as an array of: - Stored in `browser.storage.local` under key `profiles` as an array of:
`{ id, name, username, password, enabled }`. `{ id, name, username, password, enabled }`.
- Popup provides add, edit, delete, and enable/disable per profile. - Popup provides add, edit, delete, and enable/disable per profile.
- The first profile with `enabled === true` is used for auto-login. - Only one profile can be enabled at a time (enabling one disables the others);
that profile is used for auto-login.
- Master toggle stored under key `enabled` (boolean). - Master toggle stored under key `enabled` (boolean).
## Component 4 — Study Load PDF ## Component 4 — Printable Study Load
### On-page button
- The content script watches the Study Load page (`#StudyLoad`). When the course
table appears (after the user selects academic period/year and searches), it
injects a **"Download PDF"** button below the `12.00 Units / Total Units: 18.00`
footer, styled like the page's buttons (`btn green pull-right`).
### Scrape ### Scrape
- On the Study Load page, parse the course table with columns: - Parse the course table (columns: Group #, Course Code, Schedule, Faculty, Units,
**Group #, Course Code, Schedule, Faculty, Units, Status**. Status) via `parseStudyLoadTable`.
- Read the total-units footer (e.g. `12.00 Units / Total Units: 18.00`).
- **Header info** (full name, student ID, program, year level, semester) is scraped - **Header info** (full name, student ID, program, year level, semester) is scraped
from the **My Profile** page (`/Manage/Student`), which exposes: Full Name, from the **My Profile** page (`/Manage/Student`).
Student ID, Program, Year Level, and current semester. If the profile page is not
available, the header fields are entered manually in the popup before generating ### Filtering
the PDF. - **DISSOLVED courses are excluded** (`filterStudyLoadCourses`).
- Total units = sum of the included courses' units (`sumUnits`).
### Description lookup ### Description lookup
- For each unique course code, POST to - For each unique course code, POST to
`https://ismis.usc.edu.ph/CourseSchedule/CourseScheduleOffered` with form data: `https://ismis.usc.edu.ph/CourseSchedule/CourseScheduleOffered` with the
- `Courses=<course code>` `__RequestVerificationToken` and selected `AcademicPeriod`/`AcademicYear`, then
- `AcademicPeriod=<selected value>` parse the returned table for the **Course Description**.
- `AcademicYear=<selected value>` - If lookup fails for a course, the description is left blank and generation
- `__RequestVerificationToken=<token from page>` continues.
- plus the `AjaxFormVM.*` fields observed in the HAR capture.
- Parse the returned HTML table to extract the **Course Description** for the course.
- If lookup fails for a course, leave the description blank and continue.
### Manual fallback ### Print document
- If scraping finds no table, the popup shows a manual entry form for the same fields - `buildStudyLoadHtml` (printdoc.js) produces a self-contained HTML document at the
(including optional course descriptions). official dimensions (**Letter, 8.5in × 11in** via `@page`), replicating the official
layout: title block (STUDENT'S STUDY LOAD, semester, date range, date enrolled),
### PDF generation student info row (ID, name, program, year level), the course table (GROUP, COURSE,
- `background.js` uses **pdf-lib** to build the PDF replicating the official layout: COURSE DESCRIPTION, TEACHER, UNITS, NOTE — NOTE is the status), and a footer
- Header block: semester, "STUDENT'S STUDY LOAD", date range, date enrolled, (total units, "Printed by: …").
student ID, name, program, year level. - The button opens this document in a new window and auto-triggers
- Course table: Course, Course Description, Units, Teacher, Group, Note `window.print()`, so the user prints or "Save as PDF" at the official dimensions.
(status: REGULAR/PARALLEL/DISSOLVED), Schedule. - All scraped/entered values are HTML-escaped.
- Footer: total units, printed-by line.
- Downloads the PDF via the `downloads` API.
## Data flow ## Data flow
``` ```
popup ──message──▶ background ──message──▶ content (scrape + description lookup) Study Load page ──▶ content script (scrape + filter + description lookup)
▲ │ │
└────────────── data back ◀────────────────────┘ ▼
│ buildStudyLoadHtml (printdoc.js)
▼ │
pdf-lib → download ▼
window.open + window.print() → print / Save as PDF (Letter)
``` ```
## Error handling ## Error handling
- Master toggle off → no extension behavior on the portal. - Master toggle off → no extension behavior on the portal.
- No enabled profile → no auto-login. - No enabled profile → no auto-login.
- Description lookup fails for a course → blank description, PDF still generated. - Description lookup fails for a course → blank description, document still opens.
- Scrape fails entirely → manual entry form in the popup. - No enrolled (non-DISSOLVED) courses → error toast, no window opened.
- Login fields not found → log a warning, no crash. - Popup blocked → toast explaining popups must be allowed for the site.
## Testing ## Testing
- Node `node:test` suites: parsers (table/profile/offered-courses/filter/sum) and
printdoc (page size, layout, escaping, auto-print).
- Load via `about:debugging` → "Load Temporary Add-on". - Load via `about:debugging` → "Load Temporary Add-on".
- Verify auto-login on the login page. - Verify auto-login on the login page.
- Verify privacy modal auto-accept. - Verify privacy modal auto-accept.
- Verify profile add/edit/delete/toggle and master toggle. - Verify profile add/edit/delete/toggle and master toggle.
- Verify Study Load PDF generation: scrape, description lookup, manual fallback. - Verify the on-page Download PDF button: appears below the total-units footer,
excludes DISSOLVED courses, and opens the Letter-size printable document.
-16
View File
File diff suppressed because one or more lines are too long
+4 -7
View File
@@ -1,17 +1,14 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "ISMIS Helper", "name": "ISMIS Helper",
"version": "1.0.0", "version": "1.1.0",
"description": "Auto-login, auto-accept privacy policy, and Study Load PDF for the USC ISMIS portal.", "description": "Auto-login, auto-accept privacy policy, and printable Study Load for the USC ISMIS portal.",
"permissions": ["storage", "downloads", "activeTab"], "permissions": ["storage"],
"host_permissions": ["https://ismis.usc.edu.ph/*"], "host_permissions": ["https://ismis.usc.edu.ph/*"],
"background": {
"scripts": ["libs/pdf-lib.min.js", "parsers.js", "pdf.js", "background.js"]
},
"content_scripts": [ "content_scripts": [
{ {
"matches": ["https://ismis.usc.edu.ph/*"], "matches": ["https://ismis.usc.edu.ph/*"],
"js": ["parsers.js", "content.js"], "js": ["parsers.js", "printdoc.js", "content.js"],
"run_at": "document_idle" "run_at": "document_idle"
} }
], ],
-146
View File
@@ -1,146 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
function getPDFLib() {
return (typeof self !== 'undefined' && self.PDFLib) ||
(typeof globalThis !== 'undefined' && globalThis.PDFLib);
}
function wrapText(text, font, size, maxWidth) {
const words = String(text).split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
line = candidate;
} else {
if (line) lines.push(line);
line = word;
}
}
if (line) lines.push(line);
return lines.length ? lines : [''];
}
async function buildStudyLoadPdf(data) {
const PDFLib = getPDFLib();
const { PDFDocument, StandardFonts, rgb } = PDFLib;
const header = data.header || {};
const courses = data.courses || [];
const doc = await PDFDocument.create();
let page = doc.addPage([612, 792]); // Letter
const font = doc.embedStandardFont(StandardFonts.Helvetica);
const bold = doc.embedStandardFont(StandardFonts.HelveticaBold);
const margin = 50;
const contentWidth = 612 - margin * 2;
let y = 792 - 50;
const black = rgb(0, 0, 0);
const gray = rgb(0.3, 0.3, 0.3);
function center(text, size, fontToUse, dy) {
const w = fontToUse.widthOfTextAtSize(text, size);
page.drawText(text, { x: (612 - w) / 2, y: y, size, font: fontToUse, color: black });
y -= dy;
}
function left(text, size, fontToUse, x, dy) {
page.drawText(text, { x, y, size, font: fontToUse, color: black });
y -= dy;
}
// Header
center(header.semester || '', 14, bold, 20);
center("STUDENT'S STUDY LOAD", 16, bold, 24);
center(header.dateRange || '', 11, font, 18);
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 10, font, margin, 16);
y -= 6;
if (header.studentId) left('Student ID: ' + header.studentId, 10, font, margin, 14);
if (header.fullName) left('Name: ' + header.fullName, 10, font, margin, 14);
if (header.program) left('Program: ' + header.program, 10, font, margin, 14);
if (header.yearLevel) left('Year Level: ' + header.yearLevel, 10, font, margin, 14);
y -= 10;
// Table columns: NOTE, UNITS, TEACHER, COURSE DESCRIPTION, COURSE, GROUP
const cols = [
{ key: 'status', label: 'NOTE', x: margin, w: 50 },
{ key: 'units', label: 'UNITS', x: margin + 50, w: 45 },
{ key: 'teacher', label: 'TEACHER', x: margin + 95, w: 110 },
{ key: 'description', label: 'COURSE DESCRIPTION', x: margin + 205, w: 150 },
{ key: 'courseCode', label: 'COURSE', x: margin + 355, w: 70 },
{ key: 'groupSchedule', label: 'GROUP', x: margin + 425, w: 87 }
];
const cellSize = 9;
const headerSize = 9;
function drawRow(cells, isHeader) {
const f = isHeader ? bold : font;
const size = isHeader ? headerSize : cellSize;
const linesPerCol = cols.map((c) => wrapText(cells[c.key] || '', f, size, c.w - 6));
const rowHeight = Math.max(...linesPerCol.map((l) => l.length)) * 11 + 8;
if (!isHeader && y - rowHeight < 50) {
// Row doesn't fit on the current page: start a new page and re-draw the header.
page = doc.addPage([612, 792]);
y = 792 - 50;
drawHeaderRow();
}
if (isHeader) {
page.drawRectangle({ x: margin, y: y - rowHeight, width: contentWidth, height: rowHeight, color: rgb(0.85, 0.9, 0.95) });
}
page.drawRectangle({ x: margin, y: y - rowHeight, width: contentWidth, height: rowHeight, borderColor: black, borderWidth: 0.5 });
for (const c of cols) {
page.drawRectangle({ x: c.x, y: y - rowHeight, width: c.w, height: rowHeight, borderColor: black, borderWidth: 0.5 });
}
for (let i = 0; i < cols.length; i++) {
const c = cols[i];
const lines = linesPerCol[i];
let ty = y - 10;
for (const line of lines) {
page.drawText(line, { x: c.x + 3, y: ty, size, font: f, color: black });
ty -= 11;
}
}
y -= rowHeight;
return true;
}
function drawHeaderRow() {
const headerCells = {};
cols.forEach((c) => { headerCells[c.key] = c.label; });
drawRow(headerCells, true);
}
drawHeaderRow();
for (const course of courses) {
const cells = {
status: course.status,
units: course.units,
teacher: course.teacher,
description: course.description,
courseCode: course.courseCode,
groupSchedule: (course.group ? course.group + ' ' : '') + (course.schedule || '')
};
if (!drawRow(cells, false)) break;
}
y -= 12;
if (data.totalUnits) {
page.drawText('Total Units: ' + data.totalUnits, { x: margin, y: y, size: 11, font: bold, color: black });
y -= 16;
}
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
page.drawText(printedBy, { x: margin, y: y, size: 9, font, color: gray });
return doc.save(); // returns Promise<Uint8Array>
}
return { buildStudyLoadPdf };
});
-14
View File
@@ -28,14 +28,8 @@ body {
} }
.switch input:checked + .slider { background: #4caf50; } .switch input:checked + .slider { background: #4caf50; }
.switch input:checked + .slider::before { transform: translateX(18px); } .switch input:checked + .slider::before { transform: translateX(18px); }
.tabs { display: flex; border-bottom: 1px solid #ddd; }
.tabs button {
flex: 1; padding: 8px; border: none; background: #f5f5f5; cursor: pointer;
}
.tabs button.active { background: #fff; font-weight: bold; border-bottom: 2px solid #006699; }
.tab { padding: 12px; } .tab { padding: 12px; }
.tab h2 { margin: 0 0 8px; font-size: 14px; } .tab h2 { margin: 0 0 8px; font-size: 14px; }
.tab h3 { margin: 12px 0 6px; font-size: 12px; color: #555; }
.profile-list { list-style: none; margin: 0 0 8px; padding: 0; } .profile-list { list-style: none; margin: 0 0 8px; padding: 0; }
.profile-list li { .profile-list li {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
@@ -54,11 +48,3 @@ body {
border: 1px solid #ccc; border-radius: 4px; border: 1px solid #ccc; border-radius: 4px;
} }
.form .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } .form .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.course-row {
border: 1px solid #ddd; border-radius: 4px; padding: 6px; margin-bottom: 6px;
}
.course-row .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.course-row input { margin-bottom: 4px; }
.status { margin-top: 8px; font-size: 12px; }
.status.error { color: #c00; }
.status.ok { color: #080; }
+1 -27
View File
@@ -16,12 +16,7 @@
</div> </div>
</header> </header>
<nav class="tabs"> <section class="tab">
<button data-tab="login" class="active">Auto-Login</button>
<button data-tab="studyload">Study Load</button>
</nav>
<section id="tab-login" class="tab">
<h2>Profiles</h2> <h2>Profiles</h2>
<ul id="profileList" class="profile-list"></ul> <ul id="profileList" class="profile-list"></ul>
<button id="addProfile" class="btn">+ Add Profile</button> <button id="addProfile" class="btn">+ Add Profile</button>
@@ -37,27 +32,6 @@
</div> </div>
</section> </section>
<section id="tab-studyload" class="tab" hidden>
<h2>Study Load PDF</h2>
<button id="loadFromPortal" class="btn primary">Load from portal</button>
<div id="studyLoadForm" class="form" hidden>
<h3>Header</h3>
<input id="hFullName" placeholder="Full name">
<input id="hStudentId" placeholder="Student ID">
<input id="hProgram" placeholder="Program">
<input id="hYearLevel" placeholder="Year level">
<input id="hSemester" placeholder="Semester">
<input id="hDateEnrolled" placeholder="Date enrolled">
<input id="hDateRange" placeholder="Date range (e.g. AUGUST - JANUARY)">
<h3>Courses</h3>
<div id="coursesEditor"></div>
<button id="addCourse" class="btn">+ Add course</button>
<input id="totalUnits" placeholder="Total units">
<button id="generatePdf" class="btn primary">Generate PDF</button>
</div>
<div id="status" class="status"></div>
</section>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </html>
-109
View File
@@ -101,115 +101,6 @@ $('#saveProfile').addEventListener('click', async () => {
renderProfiles(profiles); renderProfiles(profiles);
}); });
// ---- Tabs ----
document.querySelectorAll('.tabs button').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tabs button').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
$('#tab-login').hidden = btn.dataset.tab !== 'login';
$('#tab-studyload').hidden = btn.dataset.tab !== 'studyload';
});
});
// ---- Study Load ----
function setStatus(msg, cls) {
const el = $('#status');
el.textContent = msg;
el.className = 'status ' + (cls || '');
}
function renderCourses(courses) {
const box = $('#coursesEditor');
box.innerHTML = '';
courses.forEach((c, i) => {
const row = document.createElement('div');
row.className = 'course-row';
row.innerHTML =
'<div class="grid">' +
'<input data-i="' + i + '" data-f="group" placeholder="Group" value="' + escapeHtml(c.group || '') + '">' +
'<input data-i="' + i + '" data-f="courseCode" placeholder="Course code" value="' + escapeHtml(c.courseCode || '') + '">' +
'<input data-i="' + i + '" data-f="description" placeholder="Description" value="' + escapeHtml(c.description || '') + '">' +
'<input data-i="' + i + '" data-f="units" placeholder="Units" value="' + escapeHtml(c.units || '') + '">' +
'<input data-i="' + i + '" data-f="teacher" placeholder="Teacher" value="' + escapeHtml(c.teacher || '') + '">' +
'<input data-i="' + i + '" data-f="status" placeholder="Status" value="' + escapeHtml(c.status || '') + '">' +
'</div>' +
'<input data-i="' + i + '" data-f="schedule" placeholder="Schedule" value="' + escapeHtml(c.schedule || '') + '">' +
'<button class="btn remove-course" data-i="' + i + '">Remove</button>';
box.appendChild(row);
});
box.querySelectorAll('input').forEach((inp) => {
inp.addEventListener('input', () => {
const i = +inp.dataset.i;
const f = inp.dataset.f;
studyLoadCourses[i][f] = inp.value;
});
});
box.querySelectorAll('.remove-course').forEach((btn) => {
btn.addEventListener('click', () => {
studyLoadCourses.splice(+btn.dataset.i, 1);
renderCourses(studyLoadCourses);
});
});
}
let studyLoadCourses = [];
$('#loadFromPortal').addEventListener('click', async () => {
setStatus('Loading from portal...');
try {
const res = await browser.runtime.sendMessage({ type: 'scrape-study-load' });
if (!res || !res.ok) throw new Error((res && res.error) || 'Scrape failed');
const d = res.data;
$('#hFullName').value = d.header.fullName || '';
$('#hStudentId').value = d.header.studentId || '';
$('#hProgram').value = d.header.program || '';
$('#hYearLevel').value = d.header.yearLevel || '';
$('#hSemester').value = d.header.semester || '';
$('#hDateEnrolled').value = d.header.dateEnrolled || '';
$('#hDateRange').value = d.header.dateRange || '';
$('#totalUnits').value = d.totalUnits || '';
studyLoadCourses = d.courses || [];
renderCourses(studyLoadCourses);
$('#studyLoadForm').hidden = false;
setStatus('Loaded. Review and generate.', 'ok');
} catch (err) {
setStatus('Could not load from portal: ' + err.message + ' — enter manually below.', 'error');
studyLoadCourses = [];
renderCourses(studyLoadCourses);
$('#studyLoadForm').hidden = false;
}
});
$('#addCourse').addEventListener('click', () => {
studyLoadCourses.push({ group: '', courseCode: '', description: '', units: '', teacher: '', schedule: '', status: '' });
renderCourses(studyLoadCourses);
});
$('#generatePdf').addEventListener('click', async () => {
const data = {
header: {
fullName: $('#hFullName').value.trim(),
studentId: $('#hStudentId').value.trim(),
program: $('#hProgram').value.trim(),
yearLevel: $('#hYearLevel').value.trim(),
semester: $('#hSemester').value.trim(),
dateEnrolled: $('#hDateEnrolled').value.trim(),
dateRange: $('#hDateRange').value.trim()
},
courses: studyLoadCourses,
totalUnits: $('#totalUnits').value.trim()
};
if (!data.courses.length) { setStatus('Add at least one course.', 'error'); return; }
setStatus('Generating PDF...');
try {
const res = await browser.runtime.sendMessage({ type: 'generate-pdf', data });
if (!res || !res.ok) throw new Error((res && res.error) || 'Generation failed');
setStatus('PDF download started.', 'ok');
} catch (err) {
setStatus('Error: ' + err.message, 'error');
}
});
// ---- Init ---- // ---- Init ----
(async function init() { (async function init() {
await loadMaster(); await loadMaster();
+85
View File
@@ -0,0 +1,85 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// Builds a printable HTML document replicating the official ISMIS Study Load
// layout (Letter size, 8.5in x 11in). Auto-triggers window.print() on load so
// the user can print or "Save as PDF" at the official dimensions.
function buildStudyLoadHtml(data) {
const header = data.header || {};
const courses = data.courses || [];
const rows = courses.map((c) =>
'<tr>' +
'<td class="col-g">' + escapeHtml(c.group) + '</td>' +
'<td class="col-c">' + escapeHtml(c.courseCode) + '</td>' +
'<td class="col-d">' + escapeHtml(c.description) +
(c.schedule ? '<div class="sched">' + escapeHtml(c.schedule) + '</div>' : '') +
'</td>' +
'<td class="col-t">' + escapeHtml(c.teacher) + '</td>' +
'<td class="col-u">' + escapeHtml(c.units) + '</td>' +
'<td class="col-n">' + escapeHtml(c.status) + '</td>' +
'</tr>'
).join('\n');
const studentLine = [
header.studentId ? '<b>' + escapeHtml(header.studentId) + '</b>' : '',
escapeHtml(header.fullName || ''),
escapeHtml(header.program || ''),
header.yearLevel ? 'Year Level: ' + escapeHtml(header.yearLevel) : ''
].filter(Boolean).join('&nbsp;&nbsp;&nbsp;');
const semester = header.semester ? '<div class="centered">' + escapeHtml(header.semester) + '</div>\n' : '';
const dateRange = header.dateRange ? '<div class="centered">' + escapeHtml(header.dateRange) + '</div>\n' : '';
const dateEnrolled = header.dateEnrolled
? '<div class="date-enrolled">Date Enrolled: ' + escapeHtml(header.dateEnrolled) + '</div>\n'
: '';
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
return '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n' +
'<title>Student\'s Study Load</title>\n<style>\n' +
'@page { size: 8.5in 11in; margin: 0.45in 0.5in; }\n' +
'html, body { margin: 0; padding: 0; }\n' +
'body { font-family: "Segoe UI", Arial, sans-serif; font-size: 9pt; color: #000; }\n' +
'.title { text-align: center; font-weight: bold; font-size: 9.5pt; margin: 0 0 2pt 0; }\n' +
'.centered { text-align: center; margin: 2pt 0; }\n' +
'.date-enrolled { font-style: italic; margin: 3pt 0; }\n' +
'.student { margin: 4pt 0 8pt 0; }\n' +
'table { width: 100%; border-collapse: collapse; border-bottom: 0.75pt solid #000; }\n' +
'thead th { font-weight: normal; font-size: 8pt; text-align: left; ' +
'border-top: 0.75pt solid #000; border-bottom: 0.75pt solid #000; padding: 1pt 2pt; }\n' +
'td { font-size: 8pt; padding: 1pt 2pt; vertical-align: top; }\n' +
'.col-g { width: 5%; } .col-c { width: 9%; } .col-d { width: 37%; }\n' +
'.col-t { width: 24%; } .col-u { width: 6%; text-align: right; } ' +
'.col-n { width: 19%; text-align: right; }\n' +
'.sched { margin-top: 1pt; }\n' +
'.total-row td { padding-top: 6pt; }\n' +
'</style>\n</head>\n<body>\n' +
'<div class="title">STUDENT\'S STUDY LOAD</div>\n' +
semester + dateRange + dateEnrolled +
'<div class="student">' + studentLine + '</div>\n' +
'<table>\n<thead><tr>' +
'<th>GROUP</th><th>COURSE</th><th>COURSE DESCRIPTION</th><th>TEACHER</th><th>UNITS</th><th>NOTE</th>' +
'</tr></thead>\n<tbody>\n' + rows + '\n' +
'<tr class="total-row"><td colspan="6" style="text-align:right;">' +
escapeHtml(data.totalUnits || '') +
'</td></tr>\n</tbody>\n</table>\n' +
'<div style="text-align:right; margin-top:3pt; font-size:8pt;">' + escapeHtml(printedBy) + '</div>\n' +
'<script>window.onload = function () { window.focus(); window.print(); };</script>\n' +
'</body>\n</html>';
}
return { buildStudyLoadHtml };
});
-52
View File
@@ -1,52 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
global.PDFLib = require('pdf-lib');
const { buildStudyLoadPdf } = require('../pdf.js');
const sampleData = {
header: {
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
yearLevel: '4',
semester: '1ST Semester 2026',
dateEnrolled: '8/5/2024',
dateRange: 'AUGUST - JANUARY'
},
courses: [
{ group: '7', courseCode: 'CIS 2101', description: 'DATA STRUCTURES AND ALGORITHMS', units: '3.00', teacher: 'CHRISTIAN MADERAZO', schedule: 'M,W 03:00 PM - 05:30 PM LB443TC', status: 'REGULAR' },
{ group: '2', courseCode: 'IT 3103N', description: 'SYSTEMS INTEGRATION AND ARCHITECTURE', units: '3.00', teacher: 'KHENT DELA PAZ', schedule: 'T,Th 01:30 PM - 03:00 PM DCISMVR3TC', status: 'REGULAR' }
],
totalUnits: '6.00'
};
function extractPdfText(bytes) {
const tmp = path.join(os.tmpdir(), 'study-load-test.pdf');
fs.writeFileSync(tmp, Buffer.from(bytes));
const script = [
'import fitz',
`doc = fitz.open('${tmp}')`,
'text = ""',
'for page in doc:',
' text += page.get_text()',
'print(text)'
].join('\n');
return execFileSync('python3', ['-c', script], { encoding: 'utf8' });
}
test('buildStudyLoadPdf returns valid PDF bytes', async () => {
const bytes = await buildStudyLoadPdf(sampleData);
assert.ok(bytes instanceof Uint8Array);
const head = Buffer.from(bytes.slice(0, 5)).toString('latin1');
assert.strictEqual(head, '%PDF-');
const doc = await PDFLib.PDFDocument.load(bytes);
assert.strictEqual(doc.getPageCount(), 1);
const text = extractPdfText(bytes);
assert.ok(text.includes('CIS 2101'), 'PDF text should contain course code CIS 2101');
assert.ok(text.includes("STUDENT'S STUDY LOAD"), 'PDF text should contain the title');
});
+60
View File
@@ -0,0 +1,60 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { buildStudyLoadHtml } = require('../printdoc.js');
const sampleData = {
header: {
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
yearLevel: '4',
semester: '1ST Semester 2026',
dateEnrolled: '8/5/2024',
dateRange: 'AUGUST - JANUARY'
},
courses: [
{ group: '7', courseCode: 'CIS 2101', description: 'DATA STRUCTURES AND ALGORITHMS', units: '3.00', teacher: 'CHRISTIAN MADERAZO', schedule: 'M,W 03:00 PM - 05:30 PM LB443TC', status: 'REGULAR' },
{ group: '2', courseCode: 'IT 3103N', description: 'SYSTEMS INTEGRATION AND ARCHITECTURE', units: '3.00', teacher: 'KHENT DELA PAZ', schedule: 'T,Th 01:30 PM - 03:00 PM DCISMVR3TC', status: 'REGULAR' }
],
totalUnits: '6.00'
};
test('buildStudyLoadHtml uses Letter page size', () => {
const html = buildStudyLoadHtml(sampleData);
assert.match(html, /@page\s*\{[^}]*size:\s*8\.5in\s*11in/i);
});
test('buildStudyLoadHtml includes title, student info, and table headers', () => {
const html = buildStudyLoadHtml(sampleData);
assert.ok(html.includes("STUDENT'S STUDY LOAD"));
assert.ok(html.includes('ADRIAN ALFRED CHIONG BONPIN'));
assert.ok(html.includes('22103625'));
assert.ok(html.includes('Year Level: 4'));
for (const h of ['GROUP', 'COURSE', 'COURSE DESCRIPTION', 'TEACHER', 'UNITS', 'NOTE']) {
assert.ok(html.includes(h), 'missing header: ' + h);
}
});
test('buildStudyLoadHtml includes course data, schedule, and total', () => {
const html = buildStudyLoadHtml(sampleData);
assert.ok(html.includes('CIS 2101'));
assert.ok(html.includes('DATA STRUCTURES AND ALGORITHMS'));
assert.ok(html.includes('M,W 03:00 PM - 05:30 PM LB443TC'));
assert.ok(html.includes('6.00'));
});
test('buildStudyLoadHtml escapes user content', () => {
const html = buildStudyLoadHtml({
header: { fullName: '<script>alert(1)</script>' },
courses: [{ group: '', courseCode: 'A&B', description: 'x"y', units: '1', teacher: '', schedule: '', status: 'R' }],
totalUnits: '1.00'
});
assert.ok(!html.includes('<script>alert(1)</script>'), 'script must be escaped');
assert.ok(html.includes('&lt;script&gt;'), 'script must appear escaped');
assert.ok(html.includes('A&amp;B'), 'ampersand must be escaped');
});
test('buildStudyLoadHtml auto-prints on load', () => {
const html = buildStudyLoadHtml(sampleData);
assert.ok(/window\.print\(\)/.test(html), 'must call window.print()');
});