ci: use Gitea API for releases #2

Closed
adrianbonpin wants to merge 56 commits from feat/ismis-helper into prod
31 changed files with 1949 additions and 1414 deletions
+73
View File
@@ -0,0 +1,73 @@
name: build-extension
on:
push:
branches: [feat/ismis-helper, prod]
tags: ['v*']
pull_request:
# Ensure the job token can create releases and upload assets.
permissions:
contents: write
releases: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dev dependencies
run: npm ci || npm install
- name: Install PyMuPDF (PDF text assertions)
run: |
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet pymupdf
echo "/tmp/venv/bin" >> "$GITHUB_PATH"
- name: Run tests
run: npm test
- name: Build both variants
run: node scripts/build.js all
- name: Upload zip artifacts
uses: actions/upload-artifact@v4
with:
name: ismis-helper-zips
path: dist/*.zip
if-no-files-found: error
- name: Publish release on version tag
if: startsWith(github.ref, 'refs/tags/v')
# Gitea-native release: softprops/action-gh-release targets GitHub's API
# and fails on Gitea (405). Instead we call the Gitea API directly with
# the runner-provided GITHUB_TOKEN and GITHUB_API_URL.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
REPO="${{ github.repository }}"
TAG="${{ github.ref_name }}"
API="${GITHUB_API_URL:-https://git.ranio.xyz/api/v1}"
# Create the release for the tag
RELEASE_JSON=$(curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"ISMIS Helper ${TAG}\"}" \
"${API}/repos/${REPO}/releases")
RELEASE_ID=$(echo "${RELEASE_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Attach both zips as release assets
for f in dist/*.zip; do
curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-F "attachment=@${f}" \
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$(basename "${f}")"
done
+4
View File
@@ -0,0 +1,4 @@
node_modules/
docs/
dist/
+86
View File
@@ -0,0 +1,86 @@
# ISMIS Helper — Agent Guide
Guidance for AI coding agents and contributors working in this repository.
## What this is
A Firefox/Chrome **MV3** extension that automates the USC ISMIS student portal
(`ismis.usc.edu.ph`): auto-login, privacy-policy auto-accept, and a **Study Load
PDF** export that visually matches the official document (bundled **Segoe UI**
fonts, USC logo, course table, units total).
No build step is required for the source itself; `scripts/build.js` only packages
the two browser variants into `dist/`.
## Commands
```bash
npm install # dev deps (only needed for tests)
npm test # run the test suite (node --test)
node scripts/build.js all # build both browser zips -> dist/
node scripts/build.js firefox # build just firefox
node scripts/build.js chrome # build just chrome
node scripts/build.js all --version 1.3.0 # versioned zips
npm run build # shorthand for `node scripts/build.js all`
```
Never edit anything under `dist/` — it is generated and gitignored.
## Repo layout
- `manifest.json` — **Firefox** manifest (the single source of truth). The Chrome
manifest is derived at build time.
- `background.js` — message handler; builds the PDF via `pdf.js` and downloads it.
Also has the cross-browser `browser.*`/`chrome.*` alias shim at the top.
- `content.js` — runs on ismis.usc.edu.ph: auto-login, privacy modal auto-accept,
scrapes the study load, injects the on-page **Download PDF** button.
- `parsers.js` — pure HTML-scraping helpers (study load table, profile page,
offered courses, filtering/summing).
- `pdf.js` — builds the PDF; exports `buildStudyLoadPdf` plus formatting helpers
(`compactProgram`, `formatSchedule`, `formatFullName`, `formatTeacher`,
`deriveDateRange`, `truncateToWidth`).
- `popup.html` / `popup.js` / `popup.css` — settings UI (profiles, master toggle,
PDF settings like truncation and Year Level).
- `libs/` — vendored `pdf-lib.min.js` and `fontkit.min.js` (needed to embed the
custom Segoe UI fonts).
- `assets/` — the transparent PNG logo and `fonts/Segoe-UI*.ttf`.
- `icons/` — 16/32/48/128 extension icons.
- `scripts/build.js` — zero-dependency packaging script for both browsers.
- `.gitea/workflows/build.yml` — Gitea Actions: builds + tests on push, creates a
Gitea Release with both zips when a `v*` tag is pushed.
- `test/` — `node --test` suite (`pdf.test.js`, `parsers.test.js`).
## Build model (Firefox vs Chrome)
- The **root is the Firefox source**: `manifest.json` uses `background.scripts`
(multiple files) and `browser.*` APIs.
- The **Chrome build** derives a manifest with
`"background": { "service_worker": "background.chrome.js" }` and assembles that
single worker by concatenating, in order:
`libs/fontkit.min.js` → `libs/pdf-lib.min.js` → `pdf.js` → `background.js`.
- The cross-browser shim (top of `background.js`, `content.js`, `popup.js`) aliases
`browser` to `chrome` when the former is absent. It is a no-op in Firefox.
- `scripts/build.js` **excludes** `test/`, `docs/`, `scripts/`, `dist/`, and
`.git` from the packaged output.
## Conventions & constraints
- Plain ES5+ JavaScript. No framework, no bundler, no build step for the source.
- Use Promise-based `browser.*` APIs (Chrome MV3 supports `browser.*`; the shim
covers builds that only expose `chrome.*`).
- Login profiles are stored **plaintext** in `browser.storage.local`. At most one
profile is enabled at a time; a master `enabled` toggle disables all behavior.
- The on-page **Download PDF** button must never touch another window's DOM; it
sends a message to the background which downloads via
`browser.downloads.download({ saveAs: true })` (blob → object URL).
- If you change `pdf.js` layout constants, verify against the reference PDF
(`/Users/adrianbonpin/Downloads/BSIT - Y3S1 - Study Load.pdf`) and re-check the
position measurements, since offsets can be font-metric dependent.
- Keep `docs/` and `dist/` out of git (they are ignored).
## Workflow
1. Run `npm test` before and after changes.
2. Build (`npm run build`) to make sure both variants assemble.
3. Manually load `dist/firefox` in Firefox (`about:debugging`) and `dist/chrome`
in Chrome (`chrome://extensions`) to verify PDF generation.
+128
View File
@@ -0,0 +1,128 @@
# ISMIS Helper
A lightweight **[Firefox](https://addons.mozilla.org/)** / **[Chrome](https://chromewebstore.google.com/)**
extension for the USC ISMIS student portal that:
- Auto-fills and auto-submits your login.
- Auto-accepts the privacy-policy modal.
- Exports a **Study Load PDF** that matches the official layout — bundled **Segoe UI**
fonts, the USC logo, the course table, and the units total.
One codebase builds for **both browsers** (see [Build](#build)).
## Install
> Temporary/development install. For distribution, publish the built zips to
> Firefox AMO and the Chrome Web Store — they are store-ready.
### Firefox (load temporary add-on)
1. Build the Firefox variant (or download `ismis-helper-firefox.zip` from CI):
```bash
node scripts/build.js firefox
```
The output is at `dist/ismis-helper-firefox.zip` (unzip it, or use `dist/firefox/`
directly).
2. Open `about:debugging` in Firefox.
3. Click **This Firefox** → **Load Temporary Add-on…**.
4. Select `dist/firefox/manifest.json`.
5. Open `https://ismis.usc.edu.ph` and use the extension.
### Chrome (load unpacked)
1. Build the Chrome variant (or download `ismis-helper-chrome.zip` from CI):
```bash
node scripts/build.js chrome
```
2. Unzip `dist/ismis-helper-chrome.zip` (or use `dist/chrome/` directly).
3. Open `chrome://extensions` in Chrome.
4. Enable **Developer mode** (toggle, top-right).
5. Click **Load unpacked** and select the unzipped `dist/chrome` folder.
6. Open `https://ismis.usc.edu.ph` and use the extension.
> For a persistent install, package and publish via Firefox AMO / Chrome Web Store
> with the same built zips.
### Distribute to real users
#### Firefox (self-hosted, temporary)
> **Note:** Firefox blocks unsigned extensions for normal installs. The proper
> fix is to sign the add-on via **addons.mozilla.org (AMO)** and publish it there
> (or self-host a signed `.xpi`). AMO is currently **under maintenance**, so until
> it returns, the only way to install the unsigned build is to lower Firefox's
> signing check on each user's machine. This is a per-user browser setting and is
> **not** suitable for a broad audience — use it for personal / small-group use.
1. Build the Firefox variant (or download `ismis-helper-firefox.zip` from a release):
```bash
node scripts/build.js firefox
```
2. In Firefox, open `about:config` and accept the warning.
3. Search for `xpinstall.signatures.required` and set it to **`false`**.
4. **Restart Firefox** for the change to take effect.
5. Install the add-on from the release zip: drag `ismis-helper-firefox.zip` into
Firefox (or File → Open File… and select it).
> When AMO is back online, prefer the signed route: publish the same zip to AMO
> (or sign it with `web-ext sign`) so users install it normally without touching
> `about:config`.
#### Chrome (self-hosted)
Chrome does **not** require signing for normal installs. Users can install the
built `ismis-helper-chrome.zip` directly:
1. Download `ismis-helper-chrome.zip` from a release and unzip it.
2. Open `chrome://extensions`, enable **Developer mode**, and click **Load unpacked**.
3. Select the unzipped folder.
> For a store experience, publish the same zip to the Chrome Web Store (one-time
> $5 developer registration).
## Features
- **Saved login profiles** (stored in `browser.storage.local`) — only one enabled at
a time, with a master on/off toggle.
- **One-click Download PDF** button on the Study Load page.
- PDF settings in the popup:
- Truncate long course descriptions to match the official PDF (default on).
- Show/hide `Year Level`, and set a custom Year Level value.
- **Bundled Segoe UI fonts** for a 1:1 look with the official document.
- Builds and packages automatically via Gitea Actions (zips + versioned releases).
## Build
Requires **Node.js** (the `build.js` script itself is dependency-free; `npm install`
is only needed to run the tests).
```bash
npm install # dev deps for tests
npm test # run the test suite
npm run build # build both -> dist/*.zip
node scripts/build.js all # build both explicitly
node scripts/build.js chrome # just chrome
node scripts/build.js all --version 1.3.0 # versioned zips
```
**CI (Gitea Actions):** on every push, both zips are built, tests run, and the
zips are uploaded as artifacts. When you push a `v*` tag (e.g. `v1.3.0`), it
additionally creates a **Gitea Release** with both versioned zips attached.
## Settings
Open the extension popup to:
- Manage login profiles (add / edit / enable).
- Toggle the master on/off switch.
- Set PDF options: truncate descriptions, show/hide `Year Level`, custom Year Level.
## Data & privacy
- Auto-login stores your portal credentials **in plaintext** on your own device.
- This is a personal-use helper for the university portal; use it only with your
own account.
## Development
See [`AGENTS.md`](AGENTS.md) for the repository layout, build model, and coding
conventions for this project.
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

+78
View File
@@ -0,0 +1,78 @@
'use strict';
// Cross-browser API namespace. Firefox provides Promise-based `browser.*`;
// Chrome MV3 exposes `browser.*` too, but some builds only populate the
// `chrome.*` global. Alias to whichever is present.
if (typeof browser === 'undefined' || browser == null) {
var browser = (typeof chrome !== 'undefined' ? chrome : {});
}
function errMsg(err) {
return String((err && err.message) || err);
}
// Build a download URL from PDF bytes. Chrome MV3 runs the background as a
// service worker, where URL.createObjectURL is NOT available, so we use a
// base64 data URL (works in both Firefox and Chrome). PDFs here are small
// (~150KB), well within data-URL length limits.
function pdfDataUrl(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return 'data:application/pdf;base64,' + btoa(binary);
}
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);
// Chrome MV3 background is a service worker (no URL.createObjectURL), so we
// use a base64 data URL that works in both browsers.
const url = pdfDataUrl(bytes);
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);
}
};
browser.downloads.onChanged.addListener(onChanged);
} catch (err) {
throw err;
}
}
+247
View File
@@ -0,0 +1,247 @@
(function () {
'use strict';
// Cross-browser API namespace (see background.js). No-op in Firefox.
if (typeof browser === 'undefined' || browser == null) {
var browser = (typeof chrome !== 'undefined' ? chrome : {});
}
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();
})();
})();
File diff suppressed because it is too large Load Diff
@@ -1,129 +0,0 @@
# ISMIS Helper — Firefox Extension Design
**Date:** 2026-08-18
**Status:** Approved
**Target site:** `https://ismis.usc.edu.ph` (USC ISMIS student portal)
## Overview
A personal Firefox extension (Manifest V3, no build step) that improves the USC ISMIS
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.
A single **master toggle** disables all extension behavior on the portal.
## Architecture
Single MV3 extension, plain JavaScript, no bundler.
```
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
├── popup.js # popup logic
├── popup.css # popup styles
├── libs/pdf-lib.min.js # vendored pdf-lib library
└── 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/*`.
## 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`).
- Fills both fields, then submits the form (auto-submit).
- If no profile is enabled, or the master toggle is off, does nothing.
## Component 2 — Auto-accept Privacy Policy
- On any portal page, if the privacy modal `#myModal` is present and visible:
- Check the `#Confirmation` checkbox ("I have read and understood the Privacy
Policy Statement").
- Click the `#Submit` button (Continue), which is disabled until the checkbox is
checked.
- Gated by the master toggle.
## Component 3 — Profiles
- 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.
- Master toggle stored under key `enabled` (boolean).
## Component 4 — Study Load PDF
### 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`).
- **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.
### 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).
### 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.
## Data flow
```
popup ──message──▶ background ──message──▶ content (scrape + description lookup)
▲ │
└────────────── data back ◀────────────────────┘
│
▼
pdf-lib → download
```
## Error handling
- 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.
## Testing
- 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.
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"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "ismis-helper",
"version": "1.0.0",
"private": true,
"description": "Firefox extension for the USC ISMIS portal",
"scripts": {
"test": "node --test",
"build": "node scripts/build.js all"
},
"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>
+131
View File
@@ -0,0 +1,131 @@
'use strict';
// Cross-browser API namespace (see background.js). No-op in Firefox.
if (typeof browser === 'undefined' || browser == null) {
var browser = (typeof chrome !== 'undefined' ? chrome : {});
}
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());
})();
+166
View File
@@ -0,0 +1,166 @@
'use strict';
// Zero-dependency build script: packages the extension for Firefox and Chrome.
// Usage: node scripts/build.js [firefox|chrome|all] [--version X.Y.Z]
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
const FF_GECKO_ID = process.env.FF_GECKO_ID || 'ismis-helper@adrianbonpin';
// Order matters for the single-file Chrome service worker:
// fontkit -> pdf-lib -> pdf.js -> background.js
const CHROME_BG_PARTS = [
'libs/fontkit.min.js',
'libs/pdf-lib.min.js',
'pdf.js',
'background.js',
];
// Everything to copy from ROOT into a target, relative paths.
function collectFiles(srcDir, out = [], prefix = '') {
for (const entry of fs.readdirSync(srcDir)) {
const abs = path.join(srcDir, entry);
const rel = prefix ? prefix + '/' + entry : entry;
if (entry === '.git' || entry === 'node_modules' || entry === 'dist' ||
entry === 'docs' || entry === 'scripts' || entry === 'test' ||
entry === 'manifest.json' || entry.startsWith('.')) {
continue;
}
if (fs.statSync(abs).isDirectory()) collectFiles(abs, out, rel);
else out.push(rel);
}
return out;
}
function copyDir(relList, destDir) {
for (const rel of relList) {
const src = path.join(ROOT, rel);
const dst = path.join(destDir, rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
}
}
function readManifest() {
return JSON.parse(fs.readFileSync(path.join(ROOT, 'manifest.json'), 'utf8'));
}
// ---- STORED zip writer (no compression, valid for both browsers) ----
function crc32(buf) {
let table = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function writeStoredZip(files, baseDir, zipPath) {
const chunks = [];
const central = [];
let offset = 0;
for (const rel of files) {
const data = fs.readFileSync(path.join(baseDir, rel));
const name = Buffer.from(rel, 'utf8');
const crc = crc32(data);
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4);
lh.writeUInt16LE(0, 6);
lh.writeUInt16LE(0, 8); // method: stored
lh.writeUInt16LE(0, 10);
lh.writeUInt16LE(0, 12);
lh.writeUInt32LE(crc, 14);
lh.writeUInt32LE(data.length, 18);
lh.writeUInt32LE(data.length, 22);
lh.writeUInt16LE(name.length, 26);
lh.writeUInt16LE(0, 28);
chunks.push(lh, name, data);
const ch = Buffer.alloc(46);
ch.writeUInt32LE(0x02014b50, 0);
ch.writeUInt16LE(20, 4);
ch.writeUInt16LE(20, 6);
ch.writeUInt16LE(0, 8);
ch.writeUInt16LE(0, 10);
ch.writeUInt16LE(0, 12);
ch.writeUInt32LE(crc, 16);
ch.writeUInt32LE(data.length, 20);
ch.writeUInt32LE(data.length, 24);
ch.writeUInt16LE(name.length, 28);
ch.writeUInt16LE(0, 30);
ch.writeUInt16LE(0, 32);
ch.writeUInt16LE(0, 34);
ch.writeUInt16LE(0, 36);
ch.writeUInt32LE(0, 38);
ch.writeUInt32LE(offset, 42);
central.push(ch, name);
offset += lh.length + name.length + data.length;
}
const cdStart = offset;
let cdSize = 0;
central.forEach((b) => { cdSize += b.length; });
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(files.length, 8);
eocd.writeUInt16LE(files.length, 10);
eocd.writeUInt32LE(cdSize, 12);
eocd.writeUInt32LE(cdStart, 16);
eocd.writeUInt16LE(0, 20);
const all = Buffer.concat([...chunks, ...central, eocd]);
fs.writeFileSync(zipPath, all);
}
function buildTarget(target, version) {
const dir = path.join(DIST, target);
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
const relList = collectFiles(ROOT);
copyDir(relList, dir);
if (target === 'firefox') {
const m = readManifest();
if (version) m.version = version;
m.browser_specific_settings = { gecko: { id: FF_GECKO_ID } };
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2) + '\n');
} else { // chrome
const m = readManifest();
if (version) m.version = version;
delete m.background;
m.background = { service_worker: 'background.chrome.js' };
delete m.browser_specific_settings;
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2) + '\n');
const worker = CHROME_BG_PARTS
.map((p) => fs.readFileSync(path.join(ROOT, p), 'utf8'))
.join('\n;\n');
fs.writeFileSync(path.join(dir, 'background.chrome.js'), worker);
}
const suffix = version ? '-' + version : '';
const zipName = path.join(DIST, `ismis-helper-${target}${suffix}.zip`);
writeStoredZip(collectFiles(dir), dir, zipName);
console.log(`built ${target}: ${zipName}`);
}
function main() {
const args = process.argv.slice(2);
const rawTargets = args.filter((a) => !a.startsWith('--'));
const vi = args.indexOf('--version');
let version = null;
if (vi !== -1 && args[vi + 1]) version = String(args[vi + 1]).replace(/^v/, '');
const requested = rawTargets.length ? rawTargets : ['all'];
const targets = requested.includes('all')
? ['firefox', 'chrome']
: requested.filter((t) => t === 'firefox' || t === 'chrome');
if (!targets.length) {
console.error('Usage: node scripts/build.js [firefox|chrome|all] [--version X.Y.Z]');
process.exit(1);
}
for (const t of targets) buildTarget(t, version);
}
main();
+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');
});
+130
View File
@@ -0,0 +1,130 @@
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' });
}
// PyMuPDF (fitz) is only needed for the text-extraction assertions below. It is
// installed in CI, but if it is missing locally we skip those assertions rather
// than hard-failing.
function fitzAvailable() {
try {
execFileSync('python3', ['-c', 'import fitz'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
test('buildStudyLoadPdf returns valid PDF bytes', async (t) => {
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);
if (!fitzAvailable()) {
t.skip('PyMuPDF (fitz) not available; skipping PDF text assertions');
return;
}
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), '');
});