Author SHA1 Message Date
adrianbonpin e11a7aad83 fix: leave Date Enrolled line empty (reserve space), nudge Printed by closer to bottom border 2026-08-19 02:15:16 +08:00
adrianbonpin cb47d6faac fix: teacher name format, non-bold total, total row spacing, year-level show/custom/smart settings 2026-08-19 02:12:06 +08:00
adrianbonpin 106900860a feat: add option to truncate long course descriptions to single line (default on, matches official PDF) 2026-08-19 02:02:50 +08:00
adrianbonpin b3b4495427 feat: embed bundled Segoe UI fonts (with fontkit) for 1:1 typography match with reference PDF 2026-08-19 01:49:14 +08:00
adrianbonpin 79d58f994d fix: Year Level nudge reduced to 6pt (half) 2026-08-19 01:28:48 +08:00
adrianbonpin 161f540e40 fix: non-bold table headers, nudge Year Level left, center units + total to match reference 2026-08-19 01:25:50 +08:00
adrianbonpin 79702cb847 fix: embed logo as transparent PNG (SMask) to remove green badge background, matching official PDF 2026-08-19 01:16:30 +08:00
adrianbonpin 2fa4064c7c feat: format download filename as ID - Program - S.Y. YYYY-YYYY - Semester - Study Load 2026-08-19 00:57:27 +08:00
adrianbonpin 4ef392423a fix: raise total underline above the number 2026-08-19 00:54:27 +08:00
adrianbonpin e6249ae330 fix: draw total underline above the total 2026-08-19 00:52:32 +08:00
adrianbonpin 51d467fbe7 fix: center NOTE header, draw total underline below total 2026-08-19 00:48:13 +08:00
adrianbonpin 52360ab559 fix: split multi-segment schedules and expand row height to prevent text overlap 2026-08-19 00:44:17 +08:00
adrianbonpin c75850da2e fix: remove header vertical dividers, fix header label positions, correct total underline 2026-08-19 00:29:51 +08:00
adrianbonpin 17aeab9592 feat: 1:1 reference styling for Study Load PDF (header box, spacing, columns, schedule format, name format) 2026-08-18 23:57:31 +08:00
adrianbonpin 053c6aaa98 feat: pull semester/year from the study load search dropdowns 2026-08-18 22:19:55 +08:00
adrianbonpin 57c4b6f204 fix: match reference layout geometry (margins, student row zones, column spread, spacing) 2026-08-18 19:56:06 +08:00
adrianbonpin 792974bd32 feat: add header logo, fix teacher field, compact program, date range, total under UNITS 2026-08-18 19:47:59 +08:00
adrianbonpin 631ff4e9d2 fix: render new Study Load layout (GROUP-first, student row, stacked schedule) in PDF 2026-08-18 19:41:00 +08:00
adrianbonpin 1537881ebc feat: restore direct PDF download via background; drop printdoc 2026-08-18 19:38:26 +08:00
adrianbonpin 8bcfee341c style: place Download PDF button inline with total units, left-aligned 2026-08-18 19:34:31 +08:00
adrianbonpin b09e0cea76 fix: open printable Study Load via blob URL to avoid cross-origin document error 2026-08-18 19:32:24 +08:00
adrianbonpin bf4e7170a6 style: add top and bottom margin to Download PDF button 2026-08-18 19:31:27 +08:00
adrianbonpin cb2beecc0e feat: on-page Download PDF opens Letter-size printable Study Load; drop pdf-lib/background path 2026-08-18 19:28:44 +08:00
adrianbonpin d6bacef1ec feat: filter DISSOLVED courses and sum units 2026-08-18 19:24:00 +08:00
adrianbonpin 9f02bebdea feat: use USC ISMIS logo on portal blue icon 2026-08-18 19:14:15 +08:00
adrianbonpin 0128476c37 fix: match actual login field name (Username) 2026-08-18 19:11:08 +08:00
adrianbonpin f7e72ddff3 fix: use background.scripts for Firefox MV3 compatibility 2026-08-18 19:09:02 +08:00
adrianbonpin 2cf3b71e27 chore: ignore node_modules 2026-08-18 17:14:59 +08:00
adrianbonpin 8020a24655 fix: single active profile and accurate download message 2026-08-18 17:13:51 +08:00
adrianbonpin 78570c4088 feat: popup UI for profiles and study load 2026-08-18 17:09:54 +08:00
adrianbonpin 4d7ff1a0e7 fix: revoke object URL on download completion 2026-08-18 15:55:48 +08:00
adrianbonpin a072edd6d1 feat: background message routing and PDF download 2026-08-18 15:51:30 +08:00
adrianbonpin d12a7da646 fix: check response status in content script fetches 2026-08-18 15:50:19 +08:00
adrianbonpin c4f379ff1c feat: content script for auto-login, privacy, scraping 2026-08-18 15:47:15 +08:00
adrianbonpin 0d29f0116a fix: widen NOTE column and add pagination to study load PDF 2026-08-18 15:44:12 +08:00
adrianbonpin cefad72416 feat: build study load PDF 2026-08-18 15:38:37 +08:00
adrianbonpin 544d4aab1a fix: robust group-suffix stripping in parseOfferedCourses 2026-08-18 15:35:08 +08:00
adrianbonpin 5a9c185a48 feat: parse offered courses descriptions 2026-08-18 15:33:07 +08:00
adrianbonpin 94d9b7a879 fix: harden parseProfilePage class and program extraction 2026-08-18 15:30:49 +08:00
adrianbonpin 3c409ea048 feat: parse profile page header 2026-08-18 15:28:12 +08:00
adrianbonpin 06d9a4be11 docs: note node --test fix in plan 2026-08-18 15:26:43 +08:00
adrianbonpin b920bf6f61 fix: use node --test for Node 26 compatibility 2026-08-18 15:26:37 +08:00
adrianbonpin a57d7ac44c feat: parse study load table 2026-08-18 15:26:13 +08:00
adrianbonpin a0178aa0ab chore: scaffold ISMIS Helper extension 2026-08-18 15:23:54 +08:00
27 changed files with 1514 additions and 51 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+60
View File
@@ -0,0 +1,60 @@
'use strict';
function errMsg(err) {
return String((err && err.message) || err);
}
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === 'generate-pdf') {
generatePdf(msg.data)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
return true;
}
});
function formatPeriod(period) {
if (!period) return '';
return String(period)
.replace(/\b1ST\b/g, '1st')
.replace(/\b2ND\b/g, '2nd')
.replace(/\b3RD\b/g, '3rd')
.replace(/\b(\d+)TH\b/g, '$1th')
.toLowerCase()
.replace(/^./, (c) => c.toUpperCase());
}
async function generatePdf(data) {
const bytes = await ISMIStools.buildStudyLoadPdf(data);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const header = data.header || {};
const year = parseInt(data.year, 10);
const period = formatPeriod(data.period || header.semester || '');
const compactProgram = ISMIStools.compactProgram(header.program || '');
const schoolYear = Number.isFinite(year) ? `${year}-${year + 1}` : '';
const parts = [
header.studentId || (header.fullName ? header.fullName.replace(/\s+/g, ' ') : ''),
compactProgram,
schoolYear ? 'S.Y. ' + schoolYear : '',
period,
'Study Load'
].filter(Boolean);
const filename = parts.join(' - ') + '.pdf';
const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf';
const safeFilename = filename || fallback;
try {
const id = await browser.downloads.download({ url, filename: safeFilename, 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;
}
}
+242
View File
@@ -0,0 +1,242 @@
(function () {
'use strict';
const MASTER_KEY = 'enabled';
async function isEnabled() {
const { enabled } = await browser.storage.local.get(MASTER_KEY);
return enabled !== false;
}
function autoAcceptPrivacy() {
const modal = document.getElementById('myModal');
if (!modal) return;
const checkbox = document.getElementById('Confirmation');
const submit = document.getElementById('Submit');
if (checkbox && !checkbox.checked) checkbox.click();
if (submit) submit.click();
}
function watchPrivacyModal() {
autoAcceptPrivacy();
const observer = new MutationObserver(() => autoAcceptPrivacy());
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 30000);
}
async function autoLogin() {
const { profiles } = await browser.storage.local.get('profiles');
const active = (profiles || []).find((p) => p.enabled);
if (!active) return;
const userField = document.querySelector(
'input[name="Username"], input[id="Username"], input[name="UserName"], input[name="username"], input[id="UserName"], input[id="username"]'
);
const passField = document.querySelector('input[type="password"]');
if (!userField || !passField) return;
userField.value = active.username;
passField.value = active.password;
userField.dispatchEvent(new Event('input', { bubbles: true }));
passField.dispatchEvent(new Event('input', { bubbles: true }));
const form = userField.closest('form') || passField.closest('form');
if (!form) return;
setTimeout(() => {
const submitBtn = form.querySelector('input[type="submit"], button[type="submit"]');
if (submitBtn) submitBtn.click();
else form.submit();
}, 300);
}
async function fetchCourseDescription(courseCode, academicPeriod, academicYear, token) {
const body = new URLSearchParams();
body.set('Courses', courseCode);
body.set('AcademicPeriod', academicPeriod);
body.set('AcademicYear', academicYear);
body.set('AjaxFormVM.Controller', 'CourseSchedule');
body.set('AjaxFormVM.Action', 'CourseScheduleOffered');
body.set('AjaxFormVM.ResultTargetId', 'CourseScheduleOfferedList');
body.set('AjaxFormVM.LinkToReturn', '');
body.set('AjaxFormVM.LinkToReturnTargetId', '');
body.set('AjaxFormVM.FormHeader', 'Choose term to view courses offered');
body.set('AjaxFormVM.FormHeaderIcon', '');
body.set('AjaxFormVM.FormId', 'CourseScheduleCourseScheduleOffered');
body.set('AjaxFormVM.ShowCloseButton', 'True');
body.set('AjaxFormVM.PreventAutoUpdateTarget', 'True');
body.set('AjaxFormVM.CloseModalAfterSubmit', 'False');
body.set('AjaxFormVM.AjaxFormType', 'Search');
body.set('AjaxFormVM.FormNoteLocation', '');
body.set('AjaxFormVM.SubmitButton.FaIcon', '');
body.set('AjaxFormVM.SubmitButton.ButtonText', '');
body.set('AjaxFormVM.SubmitButton.ButtonSize', '');
body.set('__RequestVerificationToken', token);
const resp = await fetch('/CourseSchedule/CourseScheduleOffered?Length=14', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
body: body.toString(),
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('Offered courses request failed: ' + resp.status);
const html = await resp.text();
const courses = ISMIStools.parseOfferedCourses(html);
const match = courses.find((c) => c.courseCode === courseCode);
return match ? match.description : '';
}
function extractTotalUnits(text) {
const m = /Total Units:\s*([\d.]+)/i.exec(text);
return m ? m[1] : '';
}
async function scrapeStudyLoad() {
const table = document.querySelector('table.table-forum');
if (!table) throw new Error('No study load table found on this page.');
const courses = ISMIStools.parseStudyLoadTable(table.outerHTML);
if (!courses.length) throw new Error('No courses found in study load table.');
const periodEl = document.getElementById('AcademicPeriod');
const yearEl = document.getElementById('AcademicYear');
const tokenEl = document.querySelector('input[name="__RequestVerificationToken"]');
const academicPeriod = periodEl ? periodEl.value : '';
const academicYear = yearEl ? yearEl.value : '';
const token = tokenEl ? tokenEl.value : '';
// Semester/year come from the search dropdowns the user picked.
// AcademicPeriod's selected option label (e.g. "1ST Semester") + year.
const periodLabel = (periodEl && periodEl.selectedOptions && periodEl.selectedOptions[0])
? periodEl.selectedOptions[0].textContent.trim()
: '';
const selectedPeriod = periodLabel || academicPeriod;
const selectedYear = academicYear;
const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))];
const descMap = {};
for (const code of uniqueCodes) {
try {
descMap[code] = await fetchCourseDescription(code, academicPeriod, academicYear, token);
} catch (e) {
descMap[code] = '';
}
}
courses.forEach((c) => { c.description = descMap[c.courseCode] || ''; });
let header = {};
try {
const resp = await fetch('/Manage/Student', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Profile request failed: ' + resp.status);
const html = await resp.text();
header = ISMIStools.parseProfilePage(html);
} catch (e) {
header = {};
}
// Semester goes into the header so the PDF shows the dropdown's term/year.
header.semester = (selectedPeriod && selectedYear)
? selectedPeriod + ' ' + selectedYear
: (header.semester || '');
return { header, courses, period: selectedPeriod, year: selectedYear };
}
// ---- On-page Download PDF button ----
const BTN_ID = 'ismisHelperDownloadBtn';
function showToast(msg, isError) {
if (window.toastr) {
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> Downloading...';
const { header, courses, period: selectedPeriod } = await scrapeStudyLoad();
const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included);
const settings = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
// Resolve the Year Level: custom value wins; otherwise prefer the scraped
// value, smart-adjusted (minus one) during SUMMER when the year level has
// not yet advanced.
header.showYearLevel = settings.showYearLevel !== false;
const custom = String(settings.customYearLevel || '').trim();
if (custom) {
header.yearLevel = custom;
} else if (/summer/i.test(selectedPeriod || '')) {
const n = parseInt(header.yearLevel, 10);
if (Number.isFinite(n) && n > 1) header.yearLevel = String(n - 1);
}
const res = await browser.runtime.sendMessage({
type: 'generate-pdf',
data: { header, courses: included, totalUnits, truncateDescription: settings.truncateDescription !== false }
});
if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed');
showToast('PDF downloaded.');
} 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 wrapper = document.createElement('div');
wrapper.style.display = 'flex';
wrapper.style.alignItems = 'center';
wrapper.style.justifyContent = 'space-between';
wrapper.style.margin = '10px 0';
const btn = document.createElement('button');
btn.id = BTN_ID;
btn.type = 'button';
btn.className = 'btn green';
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
btn.addEventListener('click', onDownloadPdf);
unitsP.parentNode.insertBefore(wrapper, unitsP);
wrapper.appendChild(btn);
wrapper.appendChild(unitsP);
}
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() {
if (!(await isEnabled())) return;
watchPrivacyModal();
if (/\/Account\/Login/i.test(location.href)) autoLogin();
if (document.getElementById('StudyLoad')) watchStudyLoad();
})();
})();
@@ -92,7 +92,7 @@ ismis-helper/
"private": true, "private": true,
"description": "Firefox extension for the USC ISMIS portal", "description": "Firefox extension for the USC ISMIS portal",
"scripts": { "scripts": {
"test": "node --test test/" "test": "node --test"
}, },
"devDependencies": { "devDependencies": {
"pdf-lib": "^1.17.1" "pdf-lib": "^1.17.1"
@@ -11,40 +11,50 @@ 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. **Study Load PDF** — an on-page "Download PDF" button on the Study Load page that
official ISMIS document, including course descriptions looked up from the generates a PDF replicating the official ISMIS Study Load document (Letter size,
Offered Courses endpoint. with course descriptions looked up from the Offered Courses endpoint) and
downloads it directly.
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
├── background.js # service worker: message routing + PDF generation ├── background.js # message handler: build PDF (pdf-lib) + download via downloads API
├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, scraping ├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, button, scrape
├── popup.html # popup UI ├── parsers.js # UMD: table/profile/offered-courses parsing + filtering
├── pdf.js # UMD: buildStudyLoadPdf (Letter PDF, official layout, pagination)
├── 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 ├── libs/pdf-lib.min.js # vendored pdf-lib library (used by background)
└── 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. - `downloads` — save the generated Study Load PDF.
- `activeTab` — access the current tab for scraping. - `host_permissions` — `https://ismis.usc.edu.ph/*` for the content script.
- Content script matches `https://ismis.usc.edu.ph/*`. - Background loads `libs/pdf-lib.min.js` + `pdf.js` via `background.scripts`.
## Background script
- `background.js` handles the `generate-pdf` message from the content script:
builds the PDF via `ISMIStools.buildStudyLoadPdf`, wraps the bytes in a Blob,
downloads via `browser.downloads.download({ saveAs: true })`, and revokes the
object URL when the download reaches `complete`/`interrupted` (and on failure).
## 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,54 +72,57 @@ 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 — Study Load PDF
### 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 inline with the
`12.00 Units / Total Units: 18.00` footer (button on the left, units text on the
right, via a flex row), styled like the page's buttons (`btn green`).
### 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
- If scraping finds no table, the popup shows a manual entry form for the same fields
(including optional course descriptions).
### PDF generation ### PDF generation
- `background.js` uses **pdf-lib** to build the PDF replicating the official layout: - `pdf.js` (`buildStudyLoadPdf`, using **pdf-lib**) builds a Letter-size (612×792pt)
- Header block: semester, "STUDENT'S STUDY LOAD", date range, date enrolled, PDF replicating the official layout: title block (STUDENT'S STUDY LOAD, semester,
student ID, name, program, year level. date range, date enrolled), student info row (ID, name, program, year level), the
- Course table: Course, Course Description, Units, Teacher, Group, Note course table (GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE — NOTE is
(status: REGULAR/PARALLEL/DISSOLVED), Schedule. the status), and a footer (total units, "Printed by: …"). Multi-page
- Footer: total units, printed-by line. pagination re-draws the table header. NOTE column is wide enough for `REGULAR`.
- Downloads the PDF via the `downloads` API. - The content script sends the data to the background (`generate-pdf`), which
downloads the PDF via the `downloads` API (`saveAs: true`).
## Data flow ## Data flow
``` ```
popup ──message──▶ background ──message──▶ content (scrape + description lookup) Study Load page ──▶ content script (scrape + filter DISSOLVED + description lookup)
▲ │ │
└────────────── data back ◀────────────────────┘ ▼ browser.runtime.sendMessage({ type: 'generate-pdf', data })
│ background.js (pdf-lib buildStudyLoadPdf)
▼ │
pdf-lib → download ▼
browser.downloads.download → Study Load PDF file
``` ```
## Error handling ## Error handling
@@ -117,13 +130,16 @@ popup ──message──▶ background ──message──▶ content (scrape +
- 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, PDF still generated.
- Scrape fails entirely → manual entry form in the popup. - No enrolled (non-DISSOLVED) courses → error toast, no download.
- Login fields not found → log a warning, no crash. - Download fails → error toast; object URL is revoked.
## Testing ## Testing
- Node `node:test` suites: parsers (table/profile/offered-courses/filter/sum) and
pdf (valid Letter PDF output).
- 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 inline with the total-units
footer, excludes DISSOLVED courses, and downloads a Letter-size PDF.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+1
View File
File diff suppressed because one or more lines are too long
+16
View File
File diff suppressed because one or more lines are too long
+33
View File
@@ -0,0 +1,33 @@
{
"manifest_version": 3,
"name": "ISMIS Helper",
"version": "1.2.0",
"description": "Auto-login, auto-accept privacy policy, and Study Load PDF for the USC ISMIS portal.",
"permissions": ["storage", "downloads"],
"host_permissions": ["https://ismis.usc.edu.ph/*"],
"background": {
"scripts": ["libs/fontkit.min.js", "libs/pdf-lib.min.js", "pdf.js", "background.js"]
},
"content_scripts": [
{
"matches": ["https://ismis.usc.edu.ph/*"],
"js": ["parsers.js", "content.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html",
"default_title": "ISMIS Helper",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png"
}
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"name": "ismis-helper",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ismis-helper",
"version": "1.0.0",
"devDependencies": {
"pdf-lib": "^1.17.1"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true,
"license": "(MIT AND Zlib)"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "ismis-helper",
"version": "1.0.0",
"private": true,
"description": "Firefox extension for the USC ISMIS portal",
"scripts": {
"test": "node --test"
},
"devDependencies": {
"pdf-lib": "^1.17.1"
}
}
+97
View File
@@ -0,0 +1,97 @@
(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 cleanText(s) {
return String(s == null ? '' : s)
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function extractRows(html) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let trMatch;
while ((trMatch = trRe.exec(html)) !== null) {
const rowHtml = trMatch[1];
if (/<th/i.test(rowHtml)) continue;
const tds = [];
const tdRe = /<td[^>]*>([\s\S]*?)<\/td>/gi;
let tdMatch;
while ((tdMatch = tdRe.exec(rowHtml)) !== null) {
tds.push(cleanText(tdMatch[1]));
}
rows.push(tds);
}
return rows;
}
function parseStudyLoadTable(html) {
return extractRows(html)
.filter((tds) => tds.length >= 6)
.map((tds) => ({
group: tds[0],
courseCode: tds[1],
schedule: tds[2],
faculty: tds[3],
units: tds[4],
status: tds[5]
}));
}
function parseProfilePage(html) {
const result = {};
const h3Match = /<h3[^>]*>([\s\S]*?)<\/h3>/i.exec(html);
const bMatch = (h3Match && /<b>([\s\S]*?)<\/b>/i.exec(h3Match[1])) ||
/<b>([\s\S]*?)<\/b>/i.exec(html);
if (bMatch) result.program = cleanText(bMatch[1]);
const smallMatch = /<small>([\s\S]*?)<\/small>/i.exec(html);
if (smallMatch) {
const lines = smallMatch[1].split(/<br\s*\/?>/i).map(cleanText).filter(Boolean);
result.semester = lines[lines.length - 1] || '';
}
const kvRe = /<td[^>]*>([\s\S]*?)<\/td>\s*<td[^>]*class=["'][^"']*\bbold\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/gi;
let m;
while ((m = kvRe.exec(html)) !== null) {
const key = cleanText(m[1]);
const val = cleanText(m[2]);
if (/full name/i.test(key)) result.fullName = val;
else if (/student id/i.test(key)) result.studentId = val;
else if (/year level/i.test(key)) result.yearLevel = val;
else if (/student type/i.test(key)) result.studentType = val;
}
return result;
}
function parseOfferedCourses(html) {
return extractRows(html)
.filter((tds) => tds.length >= 2)
.map((tds) => ({
courseCode: tds[0].replace(/\s*-\s*Group\s*\d+$/i, '').trim(),
description: tds[1]
}));
}
function filterStudyLoadCourses(courses) {
return (courses || []).filter(
(c) => String(c.status || '').trim().toUpperCase() !== 'DISSOLVED'
);
}
function sumUnits(courses) {
return (courses || [])
.reduce((sum, c) => sum + (parseFloat(c.units) || 0), 0)
.toFixed(2);
}
return { parseStudyLoadTable, parseProfilePage, parseOfferedCourses, filterStudyLoadCourses, sumUnits };
});
+484
View File
@@ -0,0 +1,484 @@
(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 : [''];
}
// Format the raw schedule string to match the official PDF style:
// comma-separate day codes, trim duplicated room suffixes, and split
// multiple meeting segments onto separate lines.
function formatSchedule(schedule) {
let s = String(schedule || '').replace(/\s+/g, ' ').trim();
if (!s) return s;
// Collapse duplicated TC anywhere in the string.
while (/TC\s+TC/.test(s)) s = s.replace(/TC\s+TC/g, 'TC');
// Join adjacent day codes with commas.
s = s.replace(/\b(M|Tu|T|W|Th|F|Sa|Su)\s+(M|Tu|T|W|Th|F|Sa|Su)\b/g, '$1,$2');
// Split concatenated meeting segments (room ending in TC followed by a
// day code) onto separate lines.
s = s.replace(/(TC)\s+(?=(?:Su|Sa|Th|Tu|M|W|F|T)\b)/g, '$1\n');
return s;
}
// Reformat a full name to "Lastname, Firstname Middle" when the source gives
// it as "Firstname Middle Lastname" (no comma). If a comma is already
// present the name is returned unchanged.
function formatFullName(name) {
const s = String(name || '').trim();
if (!s || /,/.test(s)) return s;
const parts = s.split(/\s+/);
if (parts.length < 2) return s;
const last = parts.pop();
return last + ', ' + parts.join(' ');
}
// Format a faculty/teacher name to match the official PDF's style:
// "First [MiddleInitial] Last" (no comma). Handles a source like
// "Surname, First Middle" by dropping full middle names while keeping
// honorifics/titles (e.g. FR.) and single-letter middle initials.
const HONORIFICS = new Set(['FR', 'REV', 'DR', 'SR', 'BR', 'PROF', 'ENGR', 'ATTY', 'HON', 'MR', 'MS', 'MRS', 'BRO', 'SIS']);
function formatTeacher(name) {
let v = String(name || '').trim();
if (!v) return v;
let surname = '', given = [];
if (/,/.test(v)) {
const idx = v.lastIndexOf(',');
surname = v.slice(0, idx).trim();
given = v.slice(idx + 1).trim().split(/\s+/).filter(Boolean);
} else {
const tok = v.split(/\s+/).filter(Boolean);
if (tok.length === 0) return v;
if (tok.length === 1) return v;
given = tok.slice(0, -1);
surname = tok[tok.length - 1];
}
const out = [];
let seenFirstName = false;
for (const tok of given) {
const bare = tok.replace(/\.$/, '').toUpperCase();
const isTitle = HONORIFICS.has(bare);
const isInitial = tok.length === 1 || (bare.length === 1 && /\.$/.test(tok));
if (isTitle || isInitial) {
out.push(tok);
} else if (!seenFirstName) {
out.push(tok);
seenFirstName = true;
}
// full middle names (after the first name) are dropped
}
if (surname) out.push(surname);
return out.join(' ');
}
function wrapLines(text, font, size, maxWidth) {
const out = [];
String(text == null ? '' : text).split(/\n/).forEach((seg) => {
out.push(...wrapText(seg, font, size, maxWidth));
});
return out.length ? out : [''];
}
// Truncate a string to the longest prefix that fits within maxWidth at the
// given size, hard-cutting mid-word with no ellipsis. Mirrors the official
// PDF which cuts off course descriptions that exceed the column width.
function truncateToWidth(text, font, size, maxWidth) {
const s = String(text == null ? '' : text).trim();
if (!s) return '';
if (font.widthOfTextAtSize(s, size) <= maxWidth) return s;
let lo = 0, hi = s.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (font.widthOfTextAtSize(s.slice(0, mid), size) <= maxWidth) lo = mid;
else hi = mid - 1;
}
return s.slice(0, lo);
}
// Compact a degree name for display:
// "BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY" -> "BS IT".
// Multi-word fields become initials; single-word fields stay title-cased.
function compactProgram(program) {
const s = String(program || '').trim().toUpperCase();
let m = /^BACHELOR OF SCIENCE IN (.+)$/.exec(s);
let prefix = 'BS';
if (!m) {
m = /^BACHELOR OF ARTS IN (.+)$/.exec(s);
if (m) prefix = 'AB';
}
if (!m) return String(program || '').trim();
const field = m[1];
const words = field.split(/\s+/).filter((w) => !/^(AND|OF|THE|IN)$/.test(w));
if (words.length <= 1) {
const single = words[0] || field;
return prefix + ' ' + single.charAt(0).toUpperCase() + single.slice(1).toLowerCase();
}
return prefix + ' ' + words.map((w) => w[0]).join('');
}
// Map a semester to the official date range label.
function deriveDateRange(semester) {
const s = String(semester || '').toUpperCase();
if (/1ST|FIRST/.test(s)) return 'AUGUST - JANUARY';
if (/2ND|SECOND/.test(s)) return 'JANUARY - MAY';
if (/SUMMER/.test(s)) return 'JUNE - JULY';
return '';
}
// Fetch a bundled asset in the extension background page. Returns null in
// Node (tests) or if the asset is unavailable.
async function loadAssetBytes(asset) {
const ext = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL);
try {
const url = ext ? browser.runtime.getURL(asset) : null;
if (!url) return null;
const resp = await fetch(url);
if (!resp.ok) return null;
return new Uint8Array(await resp.arrayBuffer());
} catch (e) {
return null;
}
}
// Embed the bundled Segoe UI fonts (regular/bold/italic). Fontkit is
// required by pdf-lib to embed custom TTF fonts. Falls back to Helvetica
// (standard fonts) when the assets are unavailable (e.g. Node tests).
async function loadFonts(doc, PDFLib) {
const { StandardFonts } = PDFLib;
const fontkit = (typeof self !== 'undefined' && self.fontkit) ||
(typeof globalThis !== 'undefined' && globalThis.fontkit) ||
null;
const segoe = {
regular: 'assets/fonts/Segoe-UI.ttf',
bold: 'assets/fonts/Segoe-UI-Bold.ttf',
italic: 'assets/fonts/Segoe-UI-Italic.ttf'
};
try {
if (fontkit) {
doc.registerFontkit(fontkit);
const regular = await loadAssetBytes(segoe.regular);
const bold = await loadAssetBytes(segoe.bold);
const italic = await loadAssetBytes(segoe.italic);
if (regular && bold && italic) {
return {
font: await doc.embedFont(regular),
bold: await doc.embedFont(bold),
italic: await doc.embedFont(italic),
isCustom: true
};
}
}
} catch (e) { /* fall through to Helvetica */ }
return {
font: doc.embedStandardFont(StandardFonts.Helvetica),
bold: doc.embedStandardFont(StandardFonts.HelveticaBold),
italic: doc.embedStandardFont(StandardFonts.HelveticaOblique),
isCustom: false
};
}
// Load the bundled header logo. Prefer the transparent PNG (matches the
// official PDF, which masks the JPEG's baked-in background) for a clean
// look on white; fall back to the JPEG in Node (tests) or if unavailable.
async function loadLogo() {
const ext = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL);
const candidates = ['assets/logo.png', 'assets/logo.jpeg'];
for (const asset of candidates) {
try {
const url = ext ? browser.runtime.getURL(asset) : null;
if (!url) return null;
const resp = await fetch(url);
if (!resp.ok) continue;
return { bytes: new Uint8Array(await resp.arrayBuffer()), transparent: /logo\.png$/.test(asset) };
} catch (e) {
continue;
}
}
return null;
}
// Builds a Letter-size PDF replicating the official ISMIS Study Load layout:
// header logo, title, semester + date range, date enrolled, a student info
// row (ID, name, program, Year Level), the course table (GROUP, COURSE,
// COURSE DESCRIPTION, TEACHER, UNITS, NOTE), and a footer (total under the
// UNITS column, printed-by). NOTE is the status; schedule is stacked under
// the course description.
async function buildStudyLoadPdf(data) {
const PDFLib = getPDFLib();
const { PDFDocument, StandardFonts, rgb } = PDFLib;
const header = data.header || {};
const courses = data.courses || [];
// Truncate long course descriptions to a single line matching the official
// PDF (default ON). Disabling allows descriptions to wrap.
const truncateDescription = data.truncateDescription !== false;
const doc = await PDFDocument.create();
let page = doc.addPage([612, 792]); // Letter
const fonts = await loadFonts(doc, PDFLib);
const { font, bold, italic } = fonts;
const margin = 36;
const contentWidth = 539.3; // matches reference right edge 575.3
let y = 792 - 10; // near the very top, matching the reference logo position
const black = rgb(0, 0, 0);
const gray = rgb(0.3, 0.3, 0.3);
const logo = await loadLogo();
let logoImg = null;
if (logo) {
try {
// Transparent PNG embeds with an alpha mask (matches official PDF);
// the JPEG is embedded as-is (no mask).
logoImg = logo.transparent
? await doc.embedPng(logo.bytes)
: await doc.embedJpg(logo.bytes);
} catch (e) { logoImg = null; }
}
function center(text, size, f, dy) {
const w = f.widthOfTextAtSize(text, size);
page.drawText(text, { x: (612 - w) / 2, y, size, font: f, color: black });
y -= dy;
}
function left(text, size, f, x, dy) {
page.drawText(text, { x, y, size, font: f, color: black });
y -= dy;
}
function hline(x1, x2, atY) {
page.drawRectangle({ x: x1, y: atY, width: x2 - x1, height: 0.75, color: black });
}
// ---- Header ----
if (logoImg) {
const logoW = 292.5;
const logoH = logoW * (logoImg.height / logoImg.width);
page.drawImage(logoImg, { x: (612 - logoW) / 2, y: y - logoH, width: logoW, height: logoH });
y -= logoH + 8.79;
}
// Baseline advances match the reference (Segoe UI) header layout.
center("STUDENT'S STUDY LOAD", 9.5, bold, 13.84);
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.76);
const dateRange = header.dateRange || deriveDateRange(header.semester);
if (dateRange) center(dateRange, 9, font, 11.75);
// Always reserve the Date Enrolled line's vertical space (so the layout
// stays aligned with the reference) even though we never have the value.
if (header.dateEnrolled) {
left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 14.53);
} else {
y -= 14.53; // keep the same gap, drawing nothing
}
// Student info row, laid out in fixed zones across the page (matching the
// reference): ID (bold) left, name at ~100, program at ~338, and
// "Year Level: N" right-aligned to the right content edge (575.3).
// Year Level is shown by default; the extension can hide it or override the
// value via settings (header.showYearLevel / header.yearLevel).
const yearLevelText = (header.showYearLevel !== false && header.yearLevel)
? 'Year Level: ' + header.yearLevel
: '';
if (header.studentId) page.drawText(header.studentId, { x: margin, y, size: 9, font: bold, color: black });
if (header.fullName) page.drawText(formatFullName(header.fullName), { x: 100, y, size: 9, font, color: black });
if (header.program) page.drawText(compactProgram(header.program), { x: 338, y, size: 9, font, color: black });
if (yearLevelText) {
const w = font.widthOfTextAtSize(yearLevelText, 9);
// Right-aligned to the content edge, nudged left ~6pt (half of 1rem).
page.drawText(yearLevelText, { x: margin + contentWidth - w - 6, y, size: 9, font, color: black });
}
if (header.studentId || header.fullName || header.program || yearLevelText) y -= 13.59;
// ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ----
// Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3).
const cols = [
{ key: 'group', label: 'GROUP', x: 36.0, w: 28.8, align: 'left' },
{ key: 'courseCode', label: 'COURSE', x: 64.8, w: 47.6, align: 'left' },
{ key: 'description', label: 'COURSE DESCRIPTION', x: 112.4, w: 201.4, align: 'left' },
{ key: 'teacher', label: 'TEACHER', x: 313.8, w: 127.6, align: 'left' },
{ key: 'units', label: 'UNITS', x: 441.4, w: 29.5, align: 'right' },
{ key: 'status', label: 'NOTE', x: 470.9, w: 104.4, align: 'right' }
];
const cellSize = 8;
const headerSize = 8;
const lineH = 12;
// Header label positions tuned for Segoe UI (matches the reference):
// left/center aligned within each column so labels never overlap.
function headerLabelXFor() {
const widths = {};
for (const c of cols) {
const key = c.label.toUpperCase().replace(/\s+/g, '_');
widths[key] = font.widthOfTextAtSize(c.label, headerSize);
}
return {
GROUP: cols[0].x + 1.3,
COURSE: cols[1].x + 9,
COURSE_DESCRIPTION: cols[2].x + (cols[2].w - widths.COURSE_DESCRIPTION) / 2,
TEACHER: cols[3].x + (cols[3].w - widths.TEACHER) / 2,
UNITS: cols[4].x + (cols[4].w - widths.UNITS) / 2,
NOTE: cols[5].x + (cols[5].w - widths.NOTE) / 2
};
}
function rowLines(cells) {
return cols.map((c) => {
let lines;
if (c.key === 'description') {
if (truncateDescription) {
// Single line, hard-truncated to fit the column (official PDF style).
lines = [truncateToWidth(cells.description, font, cellSize, c.w - 3)];
} else {
lines = wrapLines(cells.description, font, cellSize, c.w - 6);
}
const sched = String(cells.schedule || '');
if (sched) lines.push(...wrapLines(sched, font, cellSize, c.w - 6));
} else {
lines = wrapLines(cells[c.key], font, cellSize, c.w - 6);
}
return lines;
});
}
function drawRow(cells, isHeader) {
// Header labels and data all use the body font (header is not bold).
const f = font;
const size = isHeader ? headerSize : cellSize;
const linesPerCol = rowLines(cells);
if (isHeader) {
// Reference header row: only a top and bottom horizontal rule; no
// vertical dividers. Text baseline at y; top rule 1.8pt above the
// text bbox top; bottom rule 11.5pt below the text bbox top. For
// 8pt Helvetica the text bbox top sits ~8.6pt above the baseline,
// so the lines are offset by +9.6 (top) and -3.7 (bottom) relative
// to y. Data rows start 13pt below the header text bbox top.
const topY = y + 9.6;
const botY = y - 3.7;
if (botY < 50) {
page = doc.addPage([612, 792]);
y = 792 - 10;
return drawHeaderRow();
}
hline(margin, margin + contentWidth, topY);
const labelXs = headerLabelXFor();
for (let i = 0; i < cols.length; i++) {
const c = cols[i];
const labelKey = c.label.toUpperCase().replace(/\s+/g, '_');
const lx = labelXs[labelKey];
const lines = linesPerCol[i];
page.drawText(lines[0], { x: lx, y, size, font: f, color: black });
}
hline(margin, margin + contentWidth, botY);
y -= 13.0;
return true;
}
// Data rows: fixed 24.5pt baseline-to-baseline spacing. Description +
// schedule stack with 12pt line spacing. Expand the row height when
// the schedule or wrapped description needs more lines.
const maxLines = Math.max(...linesPerCol.map((l) => l.length));
const rowHeight = Math.max(24.5, maxLines * lineH + 0.5);
if (y - rowHeight < 50) {
page = doc.addPage([612, 792]);
y = 792 - 10;
drawHeaderRow();
}
for (let i = 0; i < cols.length; i++) {
const c = cols[i];
const lines = linesPerCol[i];
let ty = y;
for (const line of lines) {
if (c.key === 'units') {
// Center unit values under the UNITS header, matching the reference.
const w = f.widthOfTextAtSize(line, size);
page.drawText(line, { x: c.x + (c.w - w) / 2, y: ty, size, font: f, color: black });
} else if (c.key === 'status') {
page.drawText(line, { x: c.x + 35, y: ty, size, font: f, color: black });
} else {
let dx = 3;
if (c.key === 'group') dx = 12;
else if (c.key === 'courseCode') dx = 8;
else if (c.key === 'teacher') dx = 3.5;
else if (c.key === 'description') dx = 3.5;
page.drawText(line, { x: c.x + dx, y: ty, size, font: f, color: black });
}
ty -= lineH;
}
}
y -= rowHeight;
return true;
}
function drawHeaderRow() {
const cells = {};
cols.forEach((c) => { cells[c.key] = c.label; });
drawRow(cells, true);
}
drawHeaderRow();
for (const course of courses) {
const cells = {
group: course.group,
courseCode: course.courseCode,
description: course.description,
schedule: formatSchedule(course.schedule),
teacher: formatTeacher(course.teacher || course.faculty),
units: course.units,
status: course.status
};
drawRow(cells, false);
}
// ---- Footer ----
if (y < 90) {
page = doc.addPage([612, 792]);
y = 792 - 10;
}
if (data.totalUnits) {
// Total sits one standard row height below the last course row (matching
// the reference, which has a ~24.5pt gap), centered within the UNITS col.
const unitsCol = cols[4];
const w = font.widthOfTextAtSize(data.totalUnits, 8);
// Short underline above the total, spanning the UNITS column.
hline(unitsCol.x, unitsCol.x + unitsCol.w, y + 9);
page.drawText(data.totalUnits, { x: unitsCol.x + (unitsCol.w - w) / 2, y, size: 8, font, color: black });
y -= 12;
}
hline(margin, margin + contentWidth, y);
y -= 10.1;
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
const pw = font.widthOfTextAtSize(printedBy, 8);
page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black });
return doc.save(); // returns Promise<Uint8Array>
}
return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth };
});
+56
View File
@@ -0,0 +1,56 @@
* { box-sizing: border-box; }
body {
width: 360px;
margin: 0;
font-family: system-ui, sans-serif;
font-size: 13px;
color: #222;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #006699;
color: #fff;
}
.top h1 { margin: 0; font-size: 16px; }
.master { display: flex; align-items: center; gap: 6px; }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
.switch input { display: none; }
.slider {
position: absolute; cursor: pointer; inset: 0;
background: #ccc; border-radius: 22px; transition: 0.2s;
}
.slider::before {
content: ""; position: absolute; height: 18px; width: 18px;
left: 2px; top: 2px; background: #fff; border-radius: 50%; transition: 0.2s;
}
.switch input:checked + .slider { background: #4caf50; }
.switch input:checked + .slider::before { transform: translateX(18px); }
.tab { padding: 12px; }
.tab h2 { margin: 0 0 8px; font-size: 14px; }
.profile-list { list-style: none; margin: 0 0 8px; padding: 0; }
.profile-list li {
display: flex; align-items: center; gap: 6px;
padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 6px;
}
.profile-list .name { flex: 1; }
.profile-list .user { color: #777; font-size: 11px; }
.btn {
padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px;
background: #fff; cursor: pointer;
}
.btn.primary { background: #006699; color: #fff; border-color: #006699; }
.btn-row { display: flex; gap: 6px; margin-top: 6px; }
.form input {
width: 100%; padding: 6px; margin-bottom: 6px;
border: 1px solid #ccc; border-radius: 4px;
}
.form .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.tab .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.tab .row input[type="text"] {
flex: 1; min-width: 0; padding: 4px 6px;
border: 1px solid #ccc; border-radius: 4px;
}
.hint { font-size: 11px; color: #777; margin-top: 4px; }
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<header class="top">
<h1>ISMIS Helper</h1>
<div class="master">
<label class="switch">
<input type="checkbox" id="masterToggle">
<span class="slider"></span>
</label>
<span id="masterLabel">Enabled</span>
</div>
</header>
<section class="tab">
<h2>Profiles</h2>
<ul id="profileList" class="profile-list"></ul>
<button id="addProfile" class="btn">+ Add Profile</button>
<div id="profileForm" class="form" hidden>
<input id="pName" placeholder="Profile name">
<input id="pUsername" placeholder="Username">
<input id="pPassword" type="password" placeholder="Password">
<label class="row"><input type="checkbox" id="pEnabled"> Enabled</label>
<div class="btn-row">
<button id="saveProfile" class="btn primary">Save</button>
<button id="cancelProfile" class="btn">Cancel</button>
</div>
</div>
</section>
<section class="tab">
<h2>PDF Settings</h2>
<label class="row"><input type="checkbox" id="truncateToggle"> Truncate long course descriptions (official PDF style)</label>
<label class="row"><input type="checkbox" id="showYearToggle"> Show Year Level on the PDF</label>
<label class="row">Custom Year Level (optional):
<input type="text" id="customYear" placeholder="e.g. 3">
</label>
<p class="hint">Leave blank to use the scraped value (auto-adjusted in Summer).</p>
</section>
<script src="popup.js"></script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
'use strict';
const $ = (sel) => document.querySelector(sel);
const MASTER_KEY = 'enabled';
const PROFILES_KEY = 'profiles';
let editingProfileId = null;
// ---- Master toggle ----
async function loadMaster() {
const { enabled } = await browser.storage.local.get(MASTER_KEY);
$('#masterToggle').checked = enabled !== false;
$('#masterLabel').textContent = enabled === false ? 'Disabled' : 'Enabled';
}
$('#masterToggle').addEventListener('change', async (e) => {
await browser.storage.local.set({ [MASTER_KEY]: e.target.checked });
$('#masterLabel').textContent = e.target.checked ? 'Enabled' : 'Disabled';
});
// ---- PDF settings: truncate long course descriptions (default ON) ----
async function loadPdfSettings() {
const s = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
$('#truncateToggle').checked = s.truncateDescription !== false;
$('#showYearToggle').checked = s.showYearLevel !== false;
$('#customYear').value = s.customYearLevel || '';
}
$('#truncateToggle').addEventListener('change', async (e) => {
await browser.storage.local.set({ truncateDescription: e.target.checked });
});
$('#showYearToggle').addEventListener('change', async (e) => {
await browser.storage.local.set({ showYearLevel: e.target.checked });
});
$('#customYear').addEventListener('change', async (e) => {
await browser.storage.local.set({ customYearLevel: e.target.value.trim() });
});
// ---- Profiles ----
async function getProfiles() {
const { profiles } = await browser.storage.local.get(PROFILES_KEY);
return profiles || [];
}
async function saveProfiles(profiles) {
await browser.storage.local.set({ [PROFILES_KEY]: profiles });
}
function renderProfiles(profiles) {
const list = $('#profileList');
list.innerHTML = '';
for (const p of profiles) {
const li = document.createElement('li');
const name = document.createElement('div');
name.className = 'name';
name.innerHTML = '<strong>' + escapeHtml(p.name) + '</strong><br><span class="user">' + escapeHtml(p.username) + '</span>';
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = !!p.enabled;
toggle.title = 'Enable/disable';
toggle.addEventListener('change', async () => {
if (toggle.checked) {
profiles.forEach((x) => { x.enabled = x.id === p.id; });
} else {
p.enabled = false;
}
await saveProfiles(profiles);
renderProfiles(await getProfiles());
});
const edit = document.createElement('button');
edit.className = 'btn';
edit.textContent = 'Edit';
edit.addEventListener('click', () => openProfileForm(p));
const del = document.createElement('button');
del.className = 'btn';
del.textContent = 'Delete';
del.addEventListener('click', async () => {
await saveProfiles(profiles.filter((x) => x.id !== p.id));
renderProfiles(await getProfiles());
});
li.append(toggle, name, edit, del);
list.appendChild(li);
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function openProfileForm(p) {
editingProfileId = p ? p.id : null;
$('#pName').value = p ? p.name : '';
$('#pUsername').value = p ? p.username : '';
$('#pPassword').value = p ? p.password : '';
$('#pEnabled').checked = p ? !!p.enabled : true;
$('#profileForm').hidden = false;
}
$('#addProfile').addEventListener('click', () => openProfileForm(null));
$('#cancelProfile').addEventListener('click', () => { $('#profileForm').hidden = true; });
$('#saveProfile').addEventListener('click', async () => {
const profiles = await getProfiles();
const data = {
id: editingProfileId || Date.now().toString(),
name: $('#pName').value.trim(),
username: $('#pUsername').value.trim(),
password: $('#pPassword').value,
enabled: $('#pEnabled').checked
};
if (!data.name || !data.username) return;
if (editingProfileId) {
const i = profiles.findIndex((x) => x.id === editingProfileId);
if (i >= 0) profiles[i] = data;
} else {
profiles.push(data);
}
await saveProfiles(profiles);
$('#profileForm').hidden = true;
renderProfiles(profiles);
});
// ---- Init ----
(async function init() {
await loadMaster();
await loadPdfSettings();
renderProfiles(await getProfiles());
})();
+97
View File
@@ -0,0 +1,97 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { parseStudyLoadTable } = require('../parsers.js');
const { parseProfilePage } = require('../parsers.js');
const { parseOfferedCourses } = require('../parsers.js');
const fixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'study-load-table.html'), 'utf8');
const profileFixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'profile.html'), 'utf8');
const offeredFixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'offered-courses.html'), 'utf8');
test('parseStudyLoadTable extracts all course rows', () => {
const rows = parseStudyLoadTable(fixture);
assert.strictEqual(rows.length, 5);
});
test('parseStudyLoadTable maps columns correctly', () => {
const rows = parseStudyLoadTable(fixture);
const first = rows[0];
assert.strictEqual(first.group, '1');
assert.strictEqual(first.courseCode, 'CS 5105');
assert.strictEqual(first.units, '3.00');
assert.strictEqual(first.status, 'DISSOLVED');
assert.ok(first.schedule.includes('F 01:30 PM'));
});
test('parseStudyLoadTable handles empty faculty', () => {
const rows = parseStudyLoadTable(fixture);
const it4102 = rows.find(r => r.courseCode === 'IT 4102N');
assert.strictEqual(it4102.faculty, '');
});
test('parseProfilePage extracts header fields', () => {
const p = parseProfilePage(profileFixture);
assert.strictEqual(p.fullName, 'ADRIAN ALFRED CHIONG BONPIN');
assert.strictEqual(p.studentId, '22103625');
assert.strictEqual(p.yearLevel, '4');
assert.strictEqual(p.studentType, 'CONTINUING');
assert.strictEqual(p.program, 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY');
assert.strictEqual(p.semester, '1ST Semester 2026');
});
test('parseProfilePage matches bold class token with single quotes and extra classes', () => {
const html = `
<h3><b>BACHELOR OF SCIENCE</b></h3>
<table>
<tr><td>Full Name</td><td class='bold text-right'>JOHN DOE</td></tr>
<tr><td>Student Type</td><td class="bold">CONTINUING</td></tr>
</table>
`;
const p = parseProfilePage(html);
assert.strictEqual(p.fullName, 'JOHN DOE');
assert.strictEqual(p.studentType, 'CONTINUING');
assert.strictEqual(p.program, 'BACHELOR OF SCIENCE');
});
test('parseProfilePage degrades gracefully on empty input', () => {
const p = parseProfilePage('');
assert.deepStrictEqual(p, {});
});
test('parseOfferedCourses extracts course code and description', () => {
const courses = parseOfferedCourses(offeredFixture);
const it3206 = courses.find(c => c.courseCode === 'IT 3206');
assert.ok(it3206, 'should find IT 3206');
assert.strictEqual(it3206.description, 'INTEGRATIVE PROGRAMMING AND TECHNOLOGY');
});
test('parseOfferedCourses strips group suffix from code', () => {
const courses = parseOfferedCourses(offeredFixture);
assert.ok(courses.every(c => !/Group/.test(c.courseCode)));
const it3206n = courses.find(c => c.courseCode === 'IT 3206N');
assert.ok(it3206n, 'should find IT 3206N');
});
const { filterStudyLoadCourses, sumUnits } = require('../parsers.js');
test('filterStudyLoadCourses excludes DISSOLVED courses', () => {
const courses = [
{ courseCode: 'CS 5105', status: 'DISSOLVED', units: '3.00' },
{ courseCode: 'IT 3206', status: 'PARALLEL', units: '3.00' },
{ courseCode: 'IT 4102N', status: 'REGULAR', units: '3.00' }
];
const filtered = filterStudyLoadCourses(courses);
assert.deepStrictEqual(filtered.map((c) => c.courseCode), ['IT 3206', 'IT 4102N']);
});
test('filterStudyLoadCourses handles missing status', () => {
const filtered = filterStudyLoadCourses([{ courseCode: 'X', status: '', units: '1' }]);
assert.strictEqual(filtered.length, 1);
});
test('sumUnits computes total from course units', () => {
const courses = [{ units: '3.00' }, { units: '3.00' }, { units: '2.00' }];
assert.strictEqual(sumUnits(courses), '8.00');
});
+113
View File
@@ -0,0 +1,113 @@
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, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth } = 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');
assert.ok(text.includes('CHRISTIAN MADERAZO'), 'PDF text should contain the teacher');
assert.ok(text.includes('BS IT'), 'PDF text should contain the compacted program');
});
test('compactProgram shortens degree names', () => {
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY'), 'BS IT');
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN COMPUTER SCIENCE'), 'BS CS');
assert.strictEqual(compactProgram('BACHELOR OF ARTS IN POLITICAL SCIENCE'), 'AB PS');
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN ACCOUNTANCY'), 'BS Accountancy');
assert.strictEqual(compactProgram('UNKNOWN PROGRAM'), 'UNKNOWN PROGRAM');
});
test('formatSchedule normalizes schedule text', () => {
assert.strictEqual(formatSchedule('M W 07:30 AM - 10:00 AM LB447TC TC'), 'M,W 07:30 AM - 10:00 AM LB447TC');
assert.strictEqual(formatSchedule('T Th 09:00 AM - 10:30 AM LB443TC TC'), 'T,Th 09:00 AM - 10:30 AM LB443TC');
assert.strictEqual(formatSchedule('M,W 03:00 PM - 05:30 PM LB443TC'), 'M,W 03:00 PM - 05:30 PM LB443TC');
assert.strictEqual(
formatSchedule('T 07:30 AM - 10:30 AM LB448TC TC Th 07:30 AM - 10:30 AM LB448TC'),
'T 07:30 AM - 10:30 AM LB448TC\nTh 07:30 AM - 10:30 AM LB448TC'
);
assert.strictEqual(
formatSchedule('W 01:30 PM - 04:30 PM LB448TC TC M 01:30 PM - 04:30 PM LB448TC'),
'W 01:30 PM - 04:30 PM LB448TC\nM 01:30 PM - 04:30 PM LB448TC'
);
});
test('formatFullName reorders first-last names to last-first', () => {
assert.strictEqual(formatFullName('ADRIAN ALFRED CHIONG BONPIN'), 'BONPIN, ADRIAN ALFRED CHIONG');
assert.strictEqual(formatFullName('BONPIN, ADRIAN ALFRED CHIONG'), 'BONPIN, ADRIAN ALFRED CHIONG');
assert.strictEqual(formatFullName('JOHN DOE'), 'DOE, JOHN');
assert.strictEqual(formatFullName(''), '');
});
test('formatTeacher renders names First [MiddleInitial] Last like the reference', () => {
assert.strictEqual(formatTeacher('FILLO, FR. PETER'), 'FR. PETER FILLO');
assert.strictEqual(formatTeacher('BACALSO, REUELLA J. AWAYAN'), 'REUELLA J. BACALSO');
assert.strictEqual(formatTeacher('PEPITO, GLENN BULLECER'), 'GLENN PEPITO');
assert.strictEqual(formatTeacher('MADERAZO, CHRISTIAN'), 'CHRISTIAN MADERAZO');
assert.strictEqual(formatTeacher('DELA PAZ, KHENT'), 'KHENT DELA PAZ');
assert.strictEqual(formatTeacher('CHRISTIAN MADERAZO'), 'CHRISTIAN MADERAZO');
assert.strictEqual(formatTeacher(''), '');
});
test('deriveDateRange maps semesters', () => {
assert.strictEqual(deriveDateRange('1ST Semester 2026'), 'AUGUST - JANUARY');
assert.strictEqual(deriveDateRange('2ND Semester 2026'), 'JANUARY - MAY');
assert.strictEqual(deriveDateRange('Summer 2026'), 'JUNE - JULY');
assert.strictEqual(deriveDateRange(''), '');
});
test('truncateToWidth hard-cuts text to fit within maxWidth', async () => {
const doc = await global.PDFLib.PDFDocument.create();
const font = doc.embedStandardFont(global.PDFLib.StandardFonts.Helvetica);
const longText = 'APPLICATION DEVELOPMENT AND EMERGING TECHNOLOGIES';
const tr = truncateToWidth(longText, font, 8, 100);
assert.ok(tr.length < longText.length, 'long text should be cut down');
assert.ok(longText.startsWith(tr), 'result must be a prefix of the input');
assert.ok(font.widthOfTextAtSize(tr, 8) <= 100, 'truncated line must fit maxWidth');
const short = 'DATA STRUCTURES';
assert.strictEqual(truncateToWidth(short, font, 8, 200), short);
assert.strictEqual(truncateToWidth('', font, 8, 200), '');
});