27 Commits
Author SHA1 Message Date
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
20 changed files with 1094 additions and 51 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+35
View File
@@ -0,0 +1,35 @@
'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;
}
});
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;
}
}
+217
View File
@@ -0,0 +1,217 @@
(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 : '';
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 = {};
}
return { header, courses };
}
// ---- 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 } = await scrapeStudyLoad();
const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included);
const res = await browser.runtime.sendMessage({
type: 'generate-pdf',
data: { header, courses: included, totalUnits }
});
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,
"description": "Firefox extension for the USC ISMIS portal",
"scripts": {
"test": "node --test test/"
"test": "node --test"
},
"devDependencies": {
"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.
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
official ISMIS document, including course descriptions looked up from the
Offered Courses endpoint.
3. **Study Load PDF** — an on-page "Download PDF" button on the Study Load page that
generates a PDF replicating the official ISMIS Study Load document (Letter size,
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.
## 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/
├── 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, scraping
├── popup.html # popup UI
├── manifest.json # MV3 manifest, permissions, content script, popup, background
├── background.js # message handler: build PDF (pdf-lib) + download via downloads API
├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, button, scrape
├── 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.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)
```
### Permissions (manifest.json)
- `storage` — persist profiles and the master toggle.
- `downloads` — save the generated Study Load PDF.
- `activeTab` — access the current tab for scraping.
- Content script matches `https://ismis.usc.edu.ph/*`.
- `host_permissions` — `https://ismis.usc.edu.ph/*` for the content script.
- 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
- Content script runs on `https://ismis.usc.edu.ph/Account/Login`.
- Reads the active (first enabled) profile from `browser.storage.local`.
- Locates the username and password input fields (by common `name`/`id` values such
as `UserName`, `Password`, `username`, `password`).
- Reads the active (enabled) profile from `browser.storage.local`.
- Locates the username and password input fields (`name`/`id` = `Username` /
`Password`, with fallback variants).
- Fills both fields, then submits the form (auto-submit).
- 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:
`{ id, name, username, password, enabled }`.
- 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).
## 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
- On the Study Load page, parse the course table with columns:
**Group #, Course Code, Schedule, Faculty, Units, Status**.
- Read the total-units footer (e.g. `12.00 Units / Total Units: 18.00`).
- Parse the course table (columns: Group #, Course Code, Schedule, Faculty, Units,
Status) via `parseStudyLoadTable`.
- **Header info** (full name, student ID, program, year level, semester) is scraped
from the **My Profile** page (`/Manage/Student`), which exposes: Full Name,
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
the PDF.
from the **My Profile** page (`/Manage/Student`).
### Filtering
- **DISSOLVED courses are excluded** (`filterStudyLoadCourses`).
- Total units = sum of the included courses' units (`sumUnits`).
### Description lookup
- For each unique course code, POST to
`https://ismis.usc.edu.ph/CourseSchedule/CourseScheduleOffered` with form data:
- `Courses=<course code>`
- `AcademicPeriod=<selected value>`
- `AcademicYear=<selected value>`
- `__RequestVerificationToken=<token from page>`
- 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).
`https://ismis.usc.edu.ph/CourseSchedule/CourseScheduleOffered` with the
`__RequestVerificationToken` and selected `AcademicPeriod`/`AcademicYear`, then
parse the returned table for the **Course Description**.
- If lookup fails for a course, the description is left blank and generation
continues.
### PDF generation
- `background.js` uses **pdf-lib** to build the PDF replicating the official layout:
- Header block: semester, "STUDENT'S STUDY LOAD", date range, date enrolled,
student ID, name, program, year level.
- Course table: Course, Course Description, Units, Teacher, Group, Note
(status: REGULAR/PARALLEL/DISSOLVED), Schedule.
- Footer: total units, printed-by line.
- Downloads the PDF via the `downloads` API.
- `pdf.js` (`buildStudyLoadPdf`, using **pdf-lib**) builds a Letter-size (612×792pt)
PDF replicating the official layout: title block (STUDENT'S STUDY LOAD, semester,
date range, date enrolled), student info row (ID, name, program, year level), the
course table (GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE — NOTE is
the status), and a footer (total units, "Printed by: …"). Multi-page
pagination re-draws the table header. NOTE column is wide enough for `REGULAR`.
- The content script sends the data to the background (`generate-pdf`), which
downloads the PDF via the `downloads` API (`saveAs: true`).
## Data flow
```
popup ──message──▶ background ──message──▶ content (scrape + description lookup)
▲ │
└────────────── data back ◀────────────────────┘
│
▼
pdf-lib → download
Study Load page ──▶ content script (scrape + filter DISSOLVED + description lookup)
│
▼ browser.runtime.sendMessage({ type: 'generate-pdf', data })
background.js (pdf-lib buildStudyLoadPdf)
│
▼
browser.downloads.download → Study Load PDF file
```
## Error handling
@@ -117,13 +130,16 @@ popup ──message──▶ background ──message──▶ content (scrape +
- Master toggle off → no extension behavior on the portal.
- No enabled profile → no auto-login.
- Description lookup fails for a course → blank description, PDF still generated.
- Scrape fails entirely → manual entry form in the popup.
- Login fields not found → log a warning, no crash.
- No enrolled (non-DISSOLVED) courses → error toast, no download.
- Download fails → error toast; object URL is revoked.
## 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".
- Verify auto-login on the login page.
- Verify privacy modal auto-accept.
- 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

+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/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 };
});
+210
View File
@@ -0,0 +1,210 @@
(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 : [''];
}
// Wraps multi-line text (segments split on \n) into wrapped lines.
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 : [''];
}
// Builds a Letter-size PDF replicating the official ISMIS Study Load layout:
// title block (STUDENT'S STUDY LOAD, 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 units, printed-by). NOTE is the status; UNITS/NOTE are right-aligned;
// 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 || [];
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 italic = doc.embedStandardFont(StandardFonts.HelveticaOblique);
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, 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 ----
center("STUDENT'S STUDY LOAD", 9.5, bold, 18);
if (header.semester) center(header.semester, 9, font, 15);
if (header.dateRange) center(header.dateRange, 9, font, 15);
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13);
// Student info row: ID (bold), name, program, "Year Level: N"
const studentParts = [];
if (header.studentId) studentParts.push({ text: header.studentId, f: bold });
if (header.fullName) studentParts.push({ text: header.fullName, f: font });
if (header.program) studentParts.push({ text: header.program, f: font });
if (header.yearLevel) studentParts.push({ text: 'Year Level: ' + header.yearLevel, f: font });
if (studentParts.length) {
let sx = margin;
for (const part of studentParts) {
const w = part.f.widthOfTextAtSize(part.text, 9);
page.drawText(part.text, { x: sx, y, size: 9, font: part.f, color: black });
sx += w + 18;
}
y -= 16;
}
// ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ----
const cols = [
{ key: 'group', label: 'GROUP', x: margin, w: 26, align: 'left' },
{ key: 'courseCode', label: 'COURSE', x: margin + 26, w: 46, align: 'left' },
{ key: 'description', label: 'COURSE DESCRIPTION', x: margin + 72, w: 189, align: 'left' },
{ key: 'teacher', label: 'TEACHER', x: margin + 261, w: 123, align: 'left' },
{ key: 'units', label: 'UNITS', x: margin + 384, w: 31, align: 'right' },
{ key: 'status', label: 'NOTE', x: margin + 415, w: 97, align: 'right' }
];
const cellSize = 8;
const headerSize = 8;
const lineH = 10;
function rowLines(cells) {
return cols.map((c) => {
let lines;
if (c.key === 'description') {
lines = wrapLines(cells.description, font, cellSize, c.w - 6);
const sched = String(cells.schedule || '');
if (sched) lines.push(...wrapText(sched, font, cellSize, c.w - 6));
} else {
lines = wrapLines(cells[c.key], font, cellSize, c.w - 6);
}
return lines;
});
}
function drawRow(cells, isHeader) {
const f = isHeader ? bold : font;
const size = isHeader ? headerSize : cellSize;
const linesPerCol = rowLines(cells);
const rowHeight = Math.max(...linesPerCol.map((l) => l.length)) * lineH + 8;
if (y - rowHeight < 50) {
page = doc.addPage([612, 792]);
y = 792 - 50;
drawHeaderRow();
}
if (isHeader) {
// top line above the header row
hline(margin, margin + contentWidth, y);
}
for (let i = 0; i < cols.length; i++) {
const c = cols[i];
const lines = linesPerCol[i];
let ty = y - 9;
for (const line of lines) {
if (c.align === 'right') {
const w = f.widthOfTextAtSize(line, size);
page.drawText(line, { x: c.x + c.w - 3 - w, y: ty, size, font: f, color: black });
} else {
page.drawText(line, { x: c.x + 3, y: ty, size, font: f, color: black });
}
ty -= lineH;
}
}
y -= rowHeight;
if (isHeader) {
// line below the header row
hline(margin, margin + contentWidth, y);
}
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: course.schedule,
teacher: course.teacher,
units: course.units,
status: course.status
};
drawRow(cells, false);
}
// ---- Footer ----
if (y < 90) {
page = doc.addPage([612, 792]);
y = 792 - 50;
}
y -= 8;
if (data.totalUnits) {
const w = bold.widthOfTextAtSize(data.totalUnits, 9);
page.drawText(data.totalUnits, { x: margin + contentWidth - w, y, size: 9, font: bold, color: black });
y -= 14;
}
hline(margin, margin + contentWidth, y);
y -= 12;
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: gray });
return doc.save(); // returns Promise<Uint8Array>
}
return { buildStudyLoadPdf };
});
+50
View File
@@ -0,0 +1,50 @@
* { 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; }
+37
View File
@@ -0,0 +1,37 @@
<!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>
<script src="popup.js"></script>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
'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';
});
// ---- 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();
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');
});
+52
View File
@@ -0,0 +1,52 @@
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');
});