37 Commits
Author SHA1 Message Date
adrianbonpin 2fa4064c7c feat: format download filename as ID - Program - S.Y. YYYY-YYYY - Semester - Study Load 2026-08-19 00:57:27 +08:00
adrianbonpin 4ef392423a fix: raise total underline above the number 2026-08-19 00:54:27 +08:00
adrianbonpin e6249ae330 fix: draw total underline above the total 2026-08-19 00:52:32 +08:00
adrianbonpin 51d467fbe7 fix: center NOTE header, draw total underline below total 2026-08-19 00:48:13 +08:00
adrianbonpin 52360ab559 fix: split multi-segment schedules and expand row height to prevent text overlap 2026-08-19 00:44:17 +08:00
adrianbonpin c75850da2e fix: remove header vertical dividers, fix header label positions, correct total underline 2026-08-19 00:29:51 +08:00
adrianbonpin 17aeab9592 feat: 1:1 reference styling for Study Load PDF (header box, spacing, columns, schedule format, name format) 2026-08-18 23:57:31 +08:00
adrianbonpin 053c6aaa98 feat: pull semester/year from the study load search dropdowns 2026-08-18 22:19:55 +08:00
adrianbonpin 57c4b6f204 fix: match reference layout geometry (margins, student row zones, column spread, spacing) 2026-08-18 19:56:06 +08:00
adrianbonpin 792974bd32 feat: add header logo, fix teacher field, compact program, date range, total under UNITS 2026-08-18 19:47:59 +08:00
adrianbonpin 631ff4e9d2 fix: render new Study Load layout (GROUP-first, student row, stacked schedule) in PDF 2026-08-18 19:41:00 +08:00
adrianbonpin 1537881ebc feat: restore direct PDF download via background; drop printdoc 2026-08-18 19:38:26 +08:00
adrianbonpin 8bcfee341c style: place Download PDF button inline with total units, left-aligned 2026-08-18 19:34:31 +08:00
adrianbonpin b09e0cea76 fix: open printable Study Load via blob URL to avoid cross-origin document error 2026-08-18 19:32:24 +08:00
adrianbonpin bf4e7170a6 style: add top and bottom margin to Download PDF button 2026-08-18 19:31:27 +08:00
adrianbonpin cb2beecc0e feat: on-page Download PDF opens Letter-size printable Study Load; drop pdf-lib/background path 2026-08-18 19:28:44 +08:00
adrianbonpin d6bacef1ec feat: filter DISSOLVED courses and sum units 2026-08-18 19:24:00 +08:00
adrianbonpin 9f02bebdea feat: use USC ISMIS logo on portal blue icon 2026-08-18 19:14:15 +08:00
adrianbonpin 0128476c37 fix: match actual login field name (Username) 2026-08-18 19:11:08 +08:00
adrianbonpin f7e72ddff3 fix: use background.scripts for Firefox MV3 compatibility 2026-08-18 19:09:02 +08:00
adrianbonpin 2cf3b71e27 chore: ignore node_modules 2026-08-18 17:14:59 +08:00
adrianbonpin 8020a24655 fix: single active profile and accurate download message 2026-08-18 17:13:51 +08:00
adrianbonpin 78570c4088 feat: popup UI for profiles and study load 2026-08-18 17:09:54 +08:00
adrianbonpin 4d7ff1a0e7 fix: revoke object URL on download completion 2026-08-18 15:55:48 +08:00
adrianbonpin a072edd6d1 feat: background message routing and PDF download 2026-08-18 15:51:30 +08:00
adrianbonpin d12a7da646 fix: check response status in content script fetches 2026-08-18 15:50:19 +08:00
adrianbonpin c4f379ff1c feat: content script for auto-login, privacy, scraping 2026-08-18 15:47:15 +08:00
adrianbonpin 0d29f0116a fix: widen NOTE column and add pagination to study load PDF 2026-08-18 15:44:12 +08:00
adrianbonpin cefad72416 feat: build study load PDF 2026-08-18 15:38:37 +08:00
adrianbonpin 544d4aab1a fix: robust group-suffix stripping in parseOfferedCourses 2026-08-18 15:35:08 +08:00
adrianbonpin 5a9c185a48 feat: parse offered courses descriptions 2026-08-18 15:33:07 +08:00
adrianbonpin 94d9b7a879 fix: harden parseProfilePage class and program extraction 2026-08-18 15:30:49 +08:00
adrianbonpin 3c409ea048 feat: parse profile page header 2026-08-18 15:28:12 +08:00
adrianbonpin 06d9a4be11 docs: note node --test fix in plan 2026-08-18 15:26:43 +08:00
adrianbonpin b920bf6f61 fix: use node --test for Node 26 compatibility 2026-08-18 15:26:37 +08:00
adrianbonpin a57d7ac44c feat: parse study load table 2026-08-18 15:26:13 +08:00
adrianbonpin a0178aa0ab chore: scaffold ISMIS Helper extension 2026-08-18 15:23:54 +08:00
22 changed files with 1476 additions and 763 deletions
-73
View File
@@ -1,73 +0,0 @@
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
-3
View File
@@ -1,4 +1 @@
node_modules/
docs/
dist/
-86
View File
@@ -1,86 +0,0 @@
# 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
@@ -1,128 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 78 KiB

+4 -30
View File
@@ -1,36 +1,9 @@
'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. Firefox's background is a real page
// (URL.createObjectURL available) and supports blob: URLs in downloads.
// Chrome MV3 runs the background as a service worker where URL.createObjectURL
// is NOT available, but its downloads API accepts base64 data: URLs.
// Firefox rejects data: URLs in browser.downloads.download with
// "Access denied for URL data:...", so pick per-browser.
function buildDownloadUrl(bytes) {
if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function') {
const blob = new Blob([bytes], { type: 'application/pdf' });
return URL.createObjectURL(blob);
}
// Fallback: Chrome MV3 service worker. PDFs here are small (~150KB), well
// within data-URL length limits.
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)
@@ -53,9 +26,8 @@ function formatPeriod(period) {
async function generatePdf(data) {
const bytes = await ISMIStools.buildStudyLoadPdf(data);
// Firefox accepts blob: object URLs; Chrome MV3 service worker has no
// URL.createObjectURL, so it falls back to a base64 data: URL there.
const url = buildDownloadUrl(bytes);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const header = data.header || {};
const year = parseInt(data.year, 10);
const period = formatPeriod(data.period || header.semester || '');
@@ -77,10 +49,12 @@ async function generatePdf(data) {
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;
}
}
+2 -19
View File
@@ -1,11 +1,6 @@
(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() {
@@ -169,25 +164,13 @@
try {
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...';
const { header, courses, period: selectedPeriod } = await scrapeStudyLoad();
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 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 }
data: { header, courses: included, totalUnits }
});
if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed');
showToast('PDF downloaded.');
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
# 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** — 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. 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
├── 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 (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.
- `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 (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.
## 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.
- 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
- 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`).
### 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 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
- `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
```
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
- 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.
- 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 the on-page Download PDF button: appears inline with the total-units
footer, excludes DISSOLVED courses, and downloads a Letter-size PDF.
-1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{
"manifest_version": 3,
"name": "ISMIS Helper",
"version": "1.3.0",
"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"]
"scripts": ["libs/pdf-lib.min.js", "pdf.js", "background.js"]
},
"content_scripts": [
{
+1 -2
View File
@@ -4,8 +4,7 @@
"private": true,
"description": "Firefox extension for the USC ISMIS portal",
"scripts": {
"test": "node --test",
"build": "node scripts/build.js all"
"test": "node --test"
},
"devDependencies": {
"pdf-lib": "^1.17.1"
+35 -172
View File
@@ -56,44 +56,6 @@
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) => {
@@ -102,22 +64,6 @@
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.
@@ -148,12 +94,13 @@
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);
// Load the bundled header logo (JPEG) in the extension background page.
// Returns null in Node (tests) or if the asset is unavailable.
async function loadLogo() {
try {
const url = ext ? browser.runtime.getURL(asset) : null;
const url = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL)
? browser.runtime.getURL('assets/logo.jpeg')
: null;
if (!url) return null;
const resp = await fetch(url);
if (!resp.ok) return null;
@@ -163,63 +110,6 @@
}
}
// 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,
@@ -231,14 +121,12 @@
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 font = doc.embedStandardFont(StandardFonts.Helvetica);
const bold = doc.embedStandardFont(StandardFonts.HelveticaBold);
const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique);
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
@@ -246,16 +134,10 @@
const black = rgb(0, 0, 0);
const gray = rgb(0.3, 0.3, 0.3);
const logo = await loadLogo();
const logoBytes = 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; }
if (logoBytes) {
try { logoImg = await doc.embedJpg(logoBytes); } catch (e) { logoImg = null; }
}
function center(text, size, f, dy) {
@@ -276,38 +158,26 @@
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;
y -= logoH + 8;
}
// 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);
center("STUDENT'S STUDY LOAD", 9.5, bold, 14);
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.5);
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
}
if (dateRange) center(dateRange, 9, font, 13.5);
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13.5);
// 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
: '';
const yearLevelText = 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 });
page.drawText(yearLevelText, { x: margin + contentWidth - w, y, size: 9, font, color: black });
}
if (header.studentId || header.fullName || header.program || yearLevelText) y -= 13.59;
if (header.studentId || header.fullName || header.program || yearLevelText) y -= 14.5;
// ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ----
// Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3).
@@ -323,17 +193,17 @@
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.
// Header label positions tuned for Helvetica: 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);
widths[key] = bold.widthOfTextAtSize(c.label, headerSize);
}
return {
GROUP: cols[0].x + 1.3,
COURSE: cols[1].x + 9,
GROUP: cols[0].x,
COURSE: cols[1].x + 5,
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,
@@ -345,12 +215,7 @@
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);
}
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 {
@@ -361,8 +226,7 @@
}
function drawRow(cells, isHeader) {
// Header labels and data all use the body font (header is not bold).
const f = font;
const f = isHeader ? bold : font;
const size = isHeader ? headerSize : cellSize;
const linesPerCol = rowLines(cells);
@@ -414,9 +278,8 @@
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 });
page.drawText(line, { x: c.x + c.w - w, 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 {
@@ -449,7 +312,7 @@
courseCode: course.courseCode,
description: course.description,
schedule: formatSchedule(course.schedule),
teacher: formatTeacher(course.teacher || course.faculty),
teacher: course.teacher || course.faculty,
units: course.units,
status: course.status
};
@@ -461,18 +324,18 @@
page = doc.addPage([612, 792]);
y = 792 - 10;
}
y -= 12;
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.
// Total sits under the UNITS column, right-aligned within it.
const unitsCol = cols[4];
const w = font.widthOfTextAtSize(data.totalUnits, 8);
const w = bold.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 });
page.drawText(data.totalUnits, { x: unitsCol.x + unitsCol.w - w, y, size: 8, font: bold, color: black });
y -= 12;
}
hline(margin, margin + contentWidth, y);
y -= 10.1;
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: black });
@@ -480,5 +343,5 @@
return doc.save(); // returns Promise<Uint8Array>
}
return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth };
return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName };
});
-6
View File
@@ -48,9 +48,3 @@ body {
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; }
-10
View File
@@ -32,16 +32,6 @@
</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>
-23
View File
@@ -1,10 +1,5 @@
'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';
@@ -23,23 +18,6 @@ $('#masterToggle').addEventListener('change', async (e) => {
$('#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);
@@ -126,6 +104,5 @@ $('#saveProfile').addEventListener('click', async () => {
// ---- Init ----
(async function init() {
await loadMaster();
await loadPdfSettings();
renderProfiles(await getProfiles());
})();
-166
View File
@@ -1,166 +0,0 @@
'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();
+2 -42
View File
@@ -5,7 +5,7 @@ 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 { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName } = require('../pdf.js');
const sampleData = {
header: {
@@ -38,19 +38,7 @@ function extractPdfText(bytes) {
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) => {
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');
@@ -58,11 +46,6 @@ test('buildStudyLoadPdf returns valid PDF bytes', async (t) => {
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');
@@ -99,32 +82,9 @@ test('formatFullName reorders first-last names to last-first', () => {
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), '');
});