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
31 changed files with 1507 additions and 6145 deletions
-83
View File
@@ -1,83 +0,0 @@
name: build-extension
# Build + release only on version tags. Daily dev/test runs live in ci.yml.
on:
push:
tags: ['v*']
# 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 + openpyxl (PDF text + XLSX validation)
run: |
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet pymupdf openpyxl
echo "/tmp/venv/bin" >> "$GITHUB_PATH"
- name: Run tests
run: npm test
- name: Validate exports open in real readers
run: node scripts/validate-exports.js
- name: Build both variants
run: node scripts/build.js all
- name: Sign Firefox build (xpi for permanent install)
# Optional: only runs when the AMO credentials exist as repo secrets.
# Signing requires a version AMO has not seen before — bump
# manifest.json before tagging (1.2.0 and 1.3.0 are already on AMO).
if: env.AMO_JWT_ISSUER != '' && env.AMO_JWT_SECRET != ''
env:
AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }}
AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }}
run: |
set -euo pipefail
npm run sign
XPI=$(ls dist/*.xpi | head -1)
VERSION=$(node -p "require('./dist/firefox/manifest.json').version")
mv "$XPI" "dist/ismis-helper-${VERSION}-an+fx.xpi"
- name: Publish release
# 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 zips and the signed xpi (if present) as release assets
for f in dist/*.zip dist/*.xpi; do
[ -f "$f" ] || continue
curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-F "attachment=@${f}" \
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$(basename "${f}")"
done
-35
View File
@@ -1,35 +0,0 @@
name: ci
# Fast test feedback on daily development. No builds, no releases — those only
# happen on v* tags via build.yml.
on:
push:
branches: [feat/ismis-helper, feat/ismis-helper-v1.4.0, prod]
pull_request:
jobs:
test:
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 + openpyxl (PDF text + XLSX validation)
run: |
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet pymupdf openpyxl
echo "/tmp/venv/bin" >> "$GITHUB_PATH"
- name: Run tests
run: npm test
- name: Validate exports open in real readers
run: node scripts/validate-exports.js
-6
View File
@@ -1,7 +1 @@
node_modules/
docs/
dist/
.env
.env.*
-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.
-151
View File
@@ -1,151 +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, plus **CSV / Excel /
JSON / Next Up** export buttons.
- 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.
- Year Level mode: `Auto` (recommended), `Use custom value`, or `Profile value
only`.
- **Smart Year Level**: for the semester being printed, `Auto` derives the
standing from remaining units (Student Prospectus total − Lacking Subjects,
reflecting units actually passed) or, failing that, from the View Grades
history. Custom value overrides; the profile-scraped value is the fallback.
- **Live progress indicator**: during any export a status bar below the buttons
shows the current step (reading table, fetching course descriptions `i/N`,
profile, year level, building file) with a progress percentage.
- **Next Up import**: the Next Up export writes CSV in the exact column contract
the Raycast [Next Up](https://github.com/) extension accepts
(`title, courseCode, days, startTime, endTime, room, units, professorName`),
so schedules drop straight into the app.
- **Bundled Segoe UI fonts** for a 1:1 look with the official document.
- Builds and packages automatically via Gitea Actions (zips + versioned releases
with a signed `.xpi`).
## 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`, Year Level
mode, and a custom Year Level value.
The Study Load page gets a button row — **Download PDF**, **CSV**, **Excel**,
**JSON**, and **Next Up** — next to the total-units line.
## Data & privacy
- Profile **passwords are encrypted at rest** with Web Crypto AES-GCM (256-bit).
The key lives in `browser.storage.local` so it survives restarts. This removes
the plaintext-password smell from storage; it is NOT strong protection against
an attacker who can read both the storage and the extension source — this
extension only talks to the school portal, never any third-party server.
- 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

+17 -126
View File
@@ -1,34 +1,10 @@
'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);
}
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === 'encrypt-password') {
encryptPassword(String(msg.password || ''))
.then((cipher) => sendResponse({ ok: true, cipher }))
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
return true;
}
if (msg && msg.type === 'decrypt-password') {
decryptPassword(msg.cipher)
.then((password) => sendResponse({ ok: true, password }))
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
return true;
}
if (msg && msg.type === 'export') {
runExport(msg.format, msg.data)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
return true;
}
if (msg && msg.type === 'generate-pdf') {
generatePdf(msg.data)
.then(() => sendResponse({ ok: true }))
@@ -37,107 +13,6 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
});
// ---- Password encryption (at-rest) ----
// AES-GCM with a random 256-bit key. The key is stored in browser.storage.local
// so it survives browser/service-worker restarts (otherwise previously-encrypted
// passwords could never be decrypted again). This removes the plaintext-password
// smell from storage — it is NOT strong protection against an attacker who can
// read both the storage and the extension source (the user's threat model is
// only the school portal, no third-party exfiltration).
const CRYPTO_KEY_STORE = 'ismisCryptoKey';
async function getCryptoKey() {
const { [CRYPTO_KEY_STORE]: stored } = await browser.storage.local.get(CRYPTO_KEY_STORE);
if (stored) {
return crypto.subtle.importKey('raw', new Uint8Array(stored), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const raw = await crypto.subtle.exportKey('raw', key);
await browser.storage.local.set({ [CRYPTO_KEY_STORE]: Array.from(new Uint8Array(raw)) });
return key;
}
async function encryptPassword(password) {
const key = await getCryptoKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(password));
return { iv: Array.from(iv), data: Array.from(new Uint8Array(data)) };
}
async function decryptPassword(cipher) {
if (!cipher || !cipher.iv || !cipher.data) throw new Error('Invalid encrypted password');
const key = await getCryptoKey();
const pt = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(cipher.iv) }, key, new Uint8Array(cipher.data));
return new TextDecoder().decode(pt);
}
// Generic downloader used by every export format. `filename` is used with
// saveAs so the user picks the location, mirroring the PDF flow.
async function downloadBytes(bytes, mime, filename, fallback) {
const safeFilename = filename || fallback;
const blob = new Blob([bytes], { type: mime });
const url = (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function')
? URL.createObjectURL(blob)
: null;
let downloadUrl = url;
if (!downloadUrl) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
downloadUrl = 'data:' + mime + ';base64,' + btoa(binary);
}
try {
const id = await browser.downloads.download({ url: downloadUrl, 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) {
if (url) { try { URL.revokeObjectURL(url); } catch (e) {} }
throw err;
}
}
async function runExport(format, data) {
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 core = [
header.studentId || (header.fullName ? header.fullName.replace(/\s+/g, ' ') : ''),
compactProgram,
schoolYear ? 'S.Y. ' + schoolYear : '',
period
].filter(Boolean);
const base = core.join(' - ') || 'Study Load';
if (format === 'csv') {
const text = ISMIStools.buildStudyLoadCsv(data);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'text/csv', base + '.csv', 'Study Load.csv');
} else if (format === 'json') {
const text = JSON.stringify(ISMIStools.buildStudyLoadJson(data), null, 2);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'application/json', base + '.json', 'Study Load.json');
} else if (format === 'xlsx') {
const bytes = ISMIStools.buildStudyLoadXlsx(data);
await downloadBytes(bytes, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', base + '.xlsx', 'Study Load.xlsx');
} else if (format === 'nextup') {
const text = ISMIStools.buildNextUpCsv(data);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'text/csv', base + '-nextup.csv', 'Study Load - nextup.csv');
} else {
throw new Error('Unknown export format: ' + format);
}
}
function formatPeriod(period) {
if (!period) return '';
return String(period)
@@ -151,6 +26,8 @@ function formatPeriod(period) {
async function generatePdf(data) {
const bytes = await ISMIStools.buildStudyLoadPdf(data);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const header = data.header || {};
const year = parseInt(data.year, 10);
const period = formatPeriod(data.period || header.semester || '');
@@ -165,5 +42,19 @@ async function generatePdf(data) {
].filter(Boolean);
const filename = parts.join(' - ') + '.pdf';
const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf';
await downloadBytes(bytes, 'application/pdf', filename, fallback);
const safeFilename = filename || fallback;
try {
const id = await browser.downloads.download({ url, filename: safeFilename, saveAs: true });
const onChanged = (delta) => {
if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) {
browser.downloads.onChanged.removeListener(onChanged);
URL.revokeObjectURL(url);
}
};
browser.downloads.onChanged.addListener(onChanged);
} catch (err) {
URL.revokeObjectURL(url);
throw err;
}
}
+6 -234
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() {
@@ -39,20 +34,7 @@
const passField = document.querySelector('input[type="password"]');
if (!userField || !passField) return;
userField.value = active.username;
// Password is encrypted at rest; decrypt via the background. Legacy profiles
// saved before encryption keep a plaintext `password` field as a fallback.
let password = '';
if (active.passwordEnc) {
try {
const res = await browser.runtime.sendMessage({ type: 'decrypt-password', cipher: active.passwordEnc });
if (res && res.ok) password = res.password;
} catch (e) {
password = '';
}
} else {
password = active.password || '';
}
passField.value = password;
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');
@@ -107,10 +89,9 @@
return m ? m[1] : '';
}
async function scrapeStudyLoad(onProgress) {
async function scrapeStudyLoad() {
const table = document.querySelector('table.table-forum');
if (!table) throw new Error('No study load table found on this page.');
if (onProgress) onProgress('Reading study load table', 5);
const courses = ISMIStools.parseStudyLoadTable(table.outerHTML);
if (!courses.length) throw new Error('No courses found in study load table.');
@@ -131,21 +112,16 @@
const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))];
const descMap = {};
if (onProgress && uniqueCodes.length) onProgress('Fetching course descriptions (0/' + uniqueCodes.length + ')', 10);
for (let i = 0; i < uniqueCodes.length; i++) {
const code = uniqueCodes[i];
for (const code of uniqueCodes) {
try {
descMap[code] = await fetchCourseDescription(code, academicPeriod, academicYear, token);
} catch (e) {
descMap[code] = '';
}
if (onProgress) onProgress('Fetching course descriptions (' + (i + 1) + '/' + uniqueCodes.length + ')',
10 + 60 * ((i + 1) / uniqueCodes.length));
}
courses.forEach((c) => { c.description = descMap[c.courseCode] || ''; });
let header = {};
if (onProgress) onProgress('Reading profile', 75);
try {
const resp = await fetch('/Manage/Student', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Profile request failed: ' + resp.status);
@@ -160,77 +136,9 @@
? selectedPeriod + ' ' + selectedYear
: (header.semester || '');
// Resolve the Year Level for the PRINTED semester (hybrid, see design
// spec): custom setting wins; else View Grades history matching; else the
// units math; else the profile-scraped value.
header.yearLevel = await resolveYearLevel(header, selectedPeriod, selectedYear, onProgress);
return { header, courses, period: selectedPeriod, year: selectedYear };
}
// Fetch a same-origin page and return its text, or null on failure.
async function fetchText(path) {
try {
const resp = await fetch(path, { credentials: 'same-origin' });
if (!resp.ok) return null;
return await resp.text();
} catch (e) {
return null;
}
}
// Hybrid year-level resolution. Returns null only if every source failed.
// mode: 'auto' (default) runs the full hybrid; 'custom' uses only the custom
// value; 'off' uses only the profile-scraped value.
async function resolveYearLevel(header, period, year, onProgress) {
const settings = await browser.storage.local.get(['yearMode', 'customYearLevel']);
const mode = settings.yearMode || 'auto';
const custom = String(settings.customYearLevel || '').trim();
if (mode === 'off') {
return header.yearLevel ? String(header.yearLevel) : null;
}
if (mode === 'custom') {
return custom ? custom : null;
}
// auto: custom value wins, then the hybrid sources.
if (custom) return custom;
const yearNum = parseInt(year, 10);
// 1. UNITS MATH — primary. Standing from units actually passed:
// total prospectus units minus still-lacking units. This reflects real
// earned units and is correct even for students who repeated or failed
// a year (the naive grade-history count would over-advance them).
if (onProgress) onProgress('Determining year level', 80);
const [prospectusHtml, lackingHtml] = await Promise.all([
fetchText('/prospectus/StudentProspectus'),
fetchText('/Extras/ViewLackingSubjectsStudent1')
]);
if (onProgress) onProgress('Checking remaining units', 88);
if (prospectusHtml) {
const yearUnits = ISMIStools.parseProspectusYearUnits(prospectusHtml);
const lacking = lackingHtml ? ISMIStools.parseLackingSubjectsUnits(lackingHtml) : 0;
const level = ISMIStools.computeYearStandingFromUnits(yearUnits, lacking);
if (level != null) return String(level);
}
// 2. GRADE HISTORY — fallback only when the units path is unavailable.
// It counts distinct academic years, so it is only an approximation for
// the printed semester and is not used when units data is present.
if (onProgress) onProgress('Checking grade history', 93);
const gradesHtml = await fetchText('/ViewGrades');
if (gradesHtml) {
const history = ISMIStools.parseGradeHistory(gradesHtml);
const level = ISMIStools.yearLevelForSemester(history, yearNum);
if (level != null) return String(level);
}
// 3. Profile-scraped value as a last resort.
if (header.yearLevel) return String(header.yearLevel);
return null;
}
// ---- On-page Download PDF button ----
const BTN_ID = 'ismisHelperDownloadBtn';
@@ -253,148 +161,27 @@
async function onDownloadPdf() {
const btn = document.getElementById(BTN_ID);
setExportButtonsDisabled(true);
try {
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...';
setProgress('Reading study load', 2);
const { header, courses } = await scrapeStudyLoad((l, p) => setProgress(l, p));
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']);
header.showYearLevel = settings.showYearLevel !== false;
setProgress('Building PDF', 95);
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');
setProgress('Done ✓', 100);
showToast('PDF downloaded.');
setTimeout(clearProgress, 3000);
} catch (err) {
setProgress('Error: ' + ((err && err.message) || err), 100);
showToast('Error: ' + ((err && err.message) || err), true);
} finally {
setExportButtonsDisabled(false);
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
setTimeout(clearProgress, 4000);
}
}
// Export another format (csv/json/xlsx/nextup). Reuses scrapeStudyLoad so
// every format gets the same header/courses/totalUnits payload and the same
// hybrid year-level resolution.
async function onExport(format) {
setExportButtonsDisabled(true);
try {
setProgress('Reading ' + format.toUpperCase() + ' data', 2);
const { header, courses, period, year } = await scrapeStudyLoad((l, p) => setProgress(l, p));
const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included);
setProgress('Building ' + format.toUpperCase() + ' file', 95);
const res = await browser.runtime.sendMessage({
type: 'export',
format,
data: { header, courses: included, totalUnits, period, year }
});
if (!res || !res.ok) throw new Error((res && res.error) || 'Export failed');
setProgress('Done ✓', 100);
showToast('Exported ' + format.toUpperCase() + '.');
setTimeout(clearProgress, 3000);
} catch (err) {
setProgress('Error: ' + ((err && err.message) || err), 100);
showToast('Error: ' + ((err && err.message) || err), true);
setTimeout(clearProgress, 4000);
} finally {
setExportButtonsDisabled(false);
}
}
function makeExportButton(id, label, icon, format) {
const b = document.createElement('button');
b.id = id;
b.type = 'button';
b.className = 'btn green';
b.style.marginLeft = '6px';
b.innerHTML = '<i class="' + icon + '"></i> ' + label;
b.addEventListener('click', () => onExport(format));
return b;
}
// Disable/enable all export buttons (incl. PDF) to prevent double-triggers.
function setExportButtonsDisabled(disabled) {
const ids = [BTN_ID, 'ismisHelperExportCsvBtn', 'ismisHelperExportXlsxBtn',
'ismisHelperExportJsonBtn', 'ismisHelperExportNextUpBtn'];
for (const id of ids) {
const b = document.getElementById(id);
if (b) b.disabled = disabled;
}
}
// ---- Export progress indicator ----
const PROGRESS_ID = 'ismisHelperProgress';
const PROGRESS_FILL_ID = 'ismisHelperProgressFill';
const PROGRESS_LABEL_ID = 'ismisHelperProgressLabel';
let progressEl = null;
// Set the status bar label + width (0-100). With no percent, shows just text.
function setProgress(label, percent) {
if (!progressEl) progressEl = document.getElementById(PROGRESS_ID);
if (!progressEl) return;
progressEl.style.display = 'flex';
const fill = document.getElementById(PROGRESS_FILL_ID);
if (fill) fill.style.width = Math.max(0, Math.min(100, percent)) + '%';
const lab = document.getElementById(PROGRESS_LABEL_ID);
if (lab) lab.textContent = label;
}
function clearProgress() {
if (!progressEl) progressEl = document.getElementById(PROGRESS_ID);
if (!progressEl) return;
progressEl.style.display = 'none';
const fill = document.getElementById(PROGRESS_FILL_ID);
if (fill) fill.style.width = '0%';
}
function makeProgressEl() {
if (document.getElementById(PROGRESS_ID)) return;
const el = document.createElement('div');
el.id = PROGRESS_ID;
el.style.display = 'none';
el.style.alignItems = 'center';
el.style.gap = '10px';
el.style.margin = '8px 0';
el.style.fontSize = '12px';
el.style.color = '#555';
el.style.width = '100%';
const track = document.createElement('div');
track.style.flex = '1';
track.style.height = '8px';
track.style.background = '#e2e2e2';
track.style.borderRadius = '4px';
track.style.overflow = 'hidden';
const fill = document.createElement('div');
fill.id = PROGRESS_FILL_ID;
fill.style.width = '0%';
fill.style.height = '100%';
fill.style.background = '#28a745';
fill.style.transition = 'width .2s ease';
track.appendChild(fill);
const lab = document.createElement('span');
lab.id = PROGRESS_LABEL_ID;
lab.style.whiteSpace = 'nowrap';
el.appendChild(track);
el.appendChild(lab);
return el;
}
function injectDownloadButton() {
if (document.getElementById(BTN_ID)) return;
const unitsP = findTotalUnitsP();
@@ -405,7 +192,6 @@
wrapper.style.alignItems = 'center';
wrapper.style.justifyContent = 'space-between';
wrapper.style.margin = '10px 0';
wrapper.style.flexWrap = 'wrap';
const btn = document.createElement('button');
btn.id = BTN_ID;
@@ -414,22 +200,8 @@
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
btn.addEventListener('click', onDownloadPdf);
const btnCsv = makeExportButton('ismisHelperExportCsvBtn', 'CSV', 'fa fa-file-text-o', 'csv');
const btnXlsx = makeExportButton('ismisHelperExportXlsxBtn', 'Excel', 'fa fa-file-o', 'xlsx');
const btnJson = makeExportButton('ismisHelperExportJsonBtn', 'JSON', 'fa fa-file-code-o', 'json');
const btnNextUp = makeExportButton('ismisHelperExportNextUpBtn', 'Next Up', 'fa fa-calendar', 'nextup');
const btnGroup = document.createElement('div');
btnGroup.style.display = 'flex';
btnGroup.appendChild(btn);
btnGroup.appendChild(btnCsv);
btnGroup.appendChild(btnXlsx);
btnGroup.appendChild(btnJson);
btnGroup.appendChild(btnNextUp);
unitsP.parentNode.insertBefore(wrapper, unitsP);
wrapper.appendChild(btnGroup);
wrapper.appendChild(makeProgressEl());
wrapper.appendChild(btn);
wrapper.appendChild(unitsP);
}
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.
-128
View File
@@ -1,128 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// RFC-4180 field encoding: quote fields containing commas, quotes, or
// newlines; double up embedded quotes.
function csvField(value) {
let s = String(value == null ? '' : value);
if (/[",\r\n]/.test(s)) {
s = '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function csvRow(cells) {
return cells.map(csvField).join(',') + '\r\n';
}
// Build a CSV representation of the study load.
// data: { header, courses, totalUnits }
// header fields: program, studentName, studentId, semester, yearLevel
function buildStudyLoadCsv(data) {
const header = data.header || {};
const lines = [];
lines.push(csvRow(['ISMIS HELPER - STUDY LOAD']));
lines.push(csvRow(['Program', header.program || '']));
lines.push(csvRow(['Student Name', header.fullName || header.studentName || '']));
lines.push(csvRow(['Student ID', header.studentId || '']));
lines.push(csvRow(['Semester', header.semester || '']));
lines.push(csvRow(['Year Level', header.yearLevel || '']));
lines.push(csvRow(['Total Units', data.totalUnits || '']));
lines.push(''); // blank separator line
lines.push(csvRow(['Group', 'Course Code', 'Description', 'Schedule', 'Faculty', 'Units', 'Status']));
for (const c of (data.courses || [])) {
lines.push(csvRow([
c.group, c.courseCode, c.description, c.schedule,
c.faculty || c.teacher, c.units, c.status
]));
}
lines.push(csvRow(['Total', '', '', '', '', data.totalUnits || '']));
lines.push(''); // final blank line, per RFC
// UTF-8 BOM so Excel opens it without mojibake.
return '\uFEFF' + lines.join('');
}
// Build a Next Up import CSV following the exact column contract in
// raycast/nextup/src/lib/csv-utils.ts:
// title, courseCode, days, startTime, endTime, room, meetingLink,
// color, icon, units, classLink, professorName, professorEmail, groupName
// One row per schedule block (a course may have multiple meeting segments).
function buildNextUpCsv(data, parseSchedule) {
const lines = [];
lines.push(csvRow(['title', 'courseCode', 'days', 'startTime', 'endTime',
'room', 'meetingLink', 'color', 'icon', 'units', 'classLink',
'professorName', 'professorEmail', 'groupName']));
const resolver = parseSchedule || parseScheduleBlocks;
const groupName = (data.header && (data.header.semester || '')) || '';
for (const c of (data.courses || [])) {
const blocks = resolver(c.schedule);
if (!blocks.length) {
lines.push(csvRow([c.description || c.courseCode, c.courseCode, '',
'', '', '', '', '', '', c.units, '', c.faculty || '', '', groupName]));
continue;
}
for (const b of blocks) {
lines.push(csvRow([
c.description || c.courseCode,
c.courseCode,
b.days,
b.startTime,
b.endTime,
b.room || '',
'', '', '',
c.units,
'',
c.faculty || '',
'',
groupName
]));
}
}
return '\uFEFF' + lines.join('');
}
// Split a study-load schedule string like
// "F 01:30 PM - 04:30 PM LB486TC TC; T Th 03:00 PM - 04:30 PM LB468"
// into [{ days: 'MO,TU', startTime: '01:30', endTime: '16:30', room }]
// Day codes map to the 3-letter codes Next Up expects (MO,TU,WE,TH,FR).
function parseScheduleBlocks(schedule) {
const s = String(schedule || '').trim();
if (!s) return [];
const blocks = [];
const segRe = /([A-Za-z]{1,3}(?:\s+[A-Za-z]{1,3})*)\s+(\d{1,2}):(\d{2})\s*(AM|PM)\s*-\s*(\d{1,2}):(\d{2})\s*(AM|PM)\s*([A-Za-z0-9]*)/gi;
const dayMap = {
M: 'MO', MO: 'MO', MON: 'MO',
T: 'TU', TU: 'TU', TUE: 'TU',
W: 'WE', WE: 'WE', WED: 'WE',
TH: 'TH', THU: 'TH', THUR: 'TH', THURS: 'TH',
F: 'FR', FR: 'FR', FRI: 'FR',
SA: 'SA', SAT: 'SA',
S: 'SU', SU: 'SU', SUN: 'SU', SUNDAY: 'SU'
};
let m;
while ((m = segRe.exec(s)) !== null) {
const dayToCode = (d) => dayMap[d.toUpperCase().replace(/\./g, '')] || '';
const days = m[1].split(/\s+/).map(dayToCode).filter(Boolean);
const to24 = (h, ampm, minute) => {
let hour = parseInt(h, 10) % 12;
if (/PM/i.test(ampm)) hour += 12;
return String(hour).padStart(2, '0') + ':' + minute;
};
blocks.push({
days: days.join(','),
startTime: to24(m[2], m[4], m[3]),
endTime: to24(m[5], m[7], m[6]),
room: m[8] || ''
});
}
return blocks;
}
return { buildStudyLoadCsv, buildNextUpCsv, parseScheduleBlocks, csvField };
});
-45
View File
@@ -1,45 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// Build a clean, reusable JSON representation of the study load.
// data: { header, courses, totalUnits, year, period }
function buildStudyLoadJson(data) {
const header = data.header || {};
const courses = (data.courses || []).map((c) => ({
group: c.group || '',
courseCode: c.courseCode || '',
description: c.description || '',
schedule: c.schedule || '',
faculty: (c.faculty || c.teacher) || '',
units: c.units || '',
status: c.status || ''
}));
return {
meta: {
program: header.program || '',
studentName: header.fullName || header.studentName || '',
studentId: header.studentId || '',
academicYear: data.year ? String(data.year) : '',
semester: header.semester || data.period || '',
yearLevel: header.yearLevel || '',
totalUnits: data.totalUnits || '',
generatedAt: new Date().toISOString(),
extensionVersion: data.extensionVersion || ''
},
courses,
totals: {
// A study load lists the courses the student is enrolled in for the
// term; there is no "completed" concept here.
enrolledUnits: data.totalUnits || ''
}
};
}
return { buildStudyLoadJson };
});
-229
View File
@@ -1,229 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// ---- Minimal ZIP writer (STORED, no compression) ----
// Produces a valid ZIP containing the XLSX parts. Stored entries are used
// because we have no deflate available (browser + zero-dependency Node).
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 zipStore(entries) {
// entries: [{ name, data: Uint8Array }]
const chunks = [];
const central = [];
let offset = 0;
const enc = new TextEncoder();
for (const e of entries) {
const nameBytes = enc.encode(e.name);
const crc = crc32(e.data);
const local = new Uint8Array(30 + nameBytes.length);
const dv = new DataView(local.buffer);
dv.setUint32(0, 0x04034b50, true); // local file header sig
dv.setUint16(4, 20, true); // version needed
dv.setUint16(6, 0, true); // flags
dv.setUint16(8, 0, true); // method: stored
dv.setUint16(10, 0, true); // mod time
dv.setUint16(12, 0x21, true); // mod date
dv.setUint32(14, crc, true);
dv.setUint32(18, e.data.length, true); // compressed size
dv.setUint32(22, e.data.length, true); // uncompressed size
dv.setUint16(26, nameBytes.length, true); // name len
dv.setUint16(28, 0, true); // extra len
local.set(nameBytes, 30);
chunks.push(local, e.data);
const cd = new Uint8Array(46 + nameBytes.length);
const cdv = new DataView(cd.buffer);
cdv.setUint32(0, 0x02014b50, true); // central dir sig
cdv.setUint16(4, 20, true); // version made by
cdv.setUint16(6, 20, true); // version needed
cdv.setUint16(8, 0, true); // flags
cdv.setUint16(10, 0, true); // method stored
cdv.setUint16(12, 0, true); // mod time
cdv.setUint16(14, 0x21, true); // mod date
cdv.setUint32(16, crc, true);
cdv.setUint32(20, e.data.length, true);
cdv.setUint32(24, e.data.length, true);
cdv.setUint16(28, nameBytes.length, true);
cdv.setUint16(30, 0, true); // extra len
cdv.setUint16(32, 0, true); // comment len
cdv.setUint16(34, 0, true); // disk start
cdv.setUint16(36, 0, true); // internal attrs
cdv.setUint32(38, 0, true); // external attrs
cdv.setUint32(42, offset, true); // local header offset
cd.set(nameBytes, 46);
central.push(cd);
offset += local.length + e.data.length;
}
const cdStart = offset;
let cdLen = 0;
for (const c of central) cdLen += c.length;
const eocd = new Uint8Array(22);
const eocdv = new DataView(eocd.buffer);
eocdv.setUint32(0, 0x06054b50, true);
eocdv.setUint16(8, entries.length, true); // total entries (this disk)
eocdv.setUint16(10, entries.length, true);
eocdv.setUint32(12, cdLen, true);
eocdv.setUint32(16, cdStart, true);
chunks.push(...central, eocd);
const total = chunks.reduce((s, c) => s + c.length, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const c of chunks) { out.set(c, pos); pos += c.length; }
return out;
}
// ---- XML helpers ----
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
// ---- XLSX builder ----
// Builds a single-sheet workbook: a title/header block + a course table
// + a totals row, mirroring the CSV layout.
function buildStudyLoadXlsx(data) {
const header = data.header || {};
const enc = new TextEncoder();
// Gather all strings into a `sharedStrings` table; cells reference them.
const strings = [];
const sIndex = new Map();
function addS(str) {
const key = String(str == null ? '' : str);
if (!sIndex.has(key)) { sIndex.set(key, strings.length); strings.push(key); }
return sIndex.get(key);
}
// rows: array of arrays of { text } (all text cells for simplicity)
const rows = [];
rows.push([{ text: 'ISMIS HELPER - STUDY LOAD' }]);
rows.push([{ text: 'Program' }, { text: header.program || '' }]);
rows.push([{ text: 'Student Name' }, { text: header.fullName || header.studentName || '' }]);
rows.push([{ text: 'Student ID' }, { text: header.studentId || '' }]);
rows.push([{ text: 'Semester' }, { text: header.semester || '' }]);
rows.push([{ text: 'Year Level' }, { text: header.yearLevel || '' }]);
rows.push([{ text: 'Total Units' }, { text: data.totalUnits || '' }]);
rows.push([]); // blank
rows.push([{ text: 'Group' }, { text: 'Course Code' }, { text: 'Description' },
{ text: 'Schedule' }, { text: 'Faculty' }, { text: 'Units' }, { text: 'Status' }]);
for (const c of (data.courses || [])) {
rows.push([
{ text: c.group }, { text: c.courseCode }, { text: c.description },
{ text: c.schedule }, { text: c.faculty || c.teacher }, { text: c.units },
{ text: c.status }
]);
}
rows.push([{ text: 'Total' }, { text: '' }, { text: '' }, { text: '' },
{ text: '' }, { text: data.totalUnits || '' }, { text: '' }]);
// Pre-compute shared string refs so sheet1.xml can reference them.
const cellRefs = rows.map((row) => row.map((c) => c.text === '' ? -1 : addS(c.text)));
// ---- sheet1.xml ----
let sheetXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
sheetXml += '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">';
sheetXml += '<cols>' +
'<col min="1" max="1" width="8" customWidth="1"/>' +
'<col min="2" max="2" width="14" customWidth="1"/>' +
'<col min="3" max="3" width="42" customWidth="1"/>' +
'<col min="4" max="4" width="38" customWidth="1"/>' +
'<col min="5" max="5" width="24" customWidth="1"/>' +
'<col min="6" max="6" width="10" customWidth="1"/>' +
'<col min="7" max="7" width="12" customWidth="1"/>' +
'</cols>';
sheetXml += '<sheetData>';
const colNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
cellRefs.forEach((refs, ri) => {
sheetXml += '<row r="' + (ri + 1) + '">';
refs.forEach((s, ci) => {
if (s < 0) return;
sheetXml += '<c r="' + colNames[ci] + (ri + 1) + '" t="s"><v>' + s + '</v></c>';
});
sheetXml += '</row>';
});
sheetXml += '</sheetData></worksheet>';
// ---- sharedStrings.xml ----
let ssXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
ssXml += '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' +
cellRefs.reduce((s, r) => s + r.filter((x) => x >= 0).length, 0) +
'" uniqueCount="' + strings.length + '">';
for (const s of strings) ssXml += '<si><t>' + esc(s) + '</t></si>';
ssXml += '</sst>';
// ---- other parts ----
const ct = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' +
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
'</Types>';
const rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const wb = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
'<sheets><sheet name="Study Load" sheetId="1" r:id="rId1"/></sheets></workbook>';
const wbRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' +
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
'</Relationships>';
const xlRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const styles = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>' +
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>' +
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>' +
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
'</styleSheet>';
const parts = [
{ name: '[Content_Types].xml', data: enc.encode(ct) },
{ name: '_rels/.rels', data: enc.encode(rels) },
{ name: 'xl/workbook.xml', data: enc.encode(wb) },
{ name: 'xl/_rels/workbook.xml.rels', data: enc.encode(wbRels) },
{ name: 'xl/worksheets/sheet1.xml', data: enc.encode(sheetXml) },
{ name: 'xl/sharedStrings.xml', data: enc.encode(ssXml) },
{ name: 'xl/styles.xml', data: enc.encode(styles) }
];
return zipStore(parts);
}
return { buildStudyLoadXlsx };
});
-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.4.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", "export-csv.js", "export-json.js", "export-xlsx.js", "background.js"]
"scripts": ["libs/pdf-lib.min.js", "pdf.js", "background.js"]
},
"content_scripts": [
{
+1 -4105
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -4,12 +4,9 @@
"private": true,
"description": "Firefox extension for the USC ISMIS portal",
"scripts": {
"test": "node --test",
"build": "node scripts/build.js all",
"sign": "web-ext sign --source-dir dist/firefox --channel unlisted --artifacts-dir dist --api-key \"$AMO_JWT_ISSUER\" --api-secret \"$AMO_JWT_SECRET\""
"test": "node --test"
},
"devDependencies": {
"pdf-lib": "^1.17.1",
"web-ext": "^8.3.0"
"pdf-lib": "^1.17.1"
}
}
+1 -108
View File
@@ -81,107 +81,6 @@
}));
}
// ---- Year standing helpers ----
// Parse the View Grades page into an ordered list of academic terms:
// [{ semester: 'FIRST'|'SECOND'|'SUMMER', year: 2024 }, ...]
// Terms appear in chronological order in the page. Regular semesters appear
// as "FIRST SEMESTER - YYYY"; summer appears as "SUMMER - YYYY".
function parseGradeHistory(html) {
const history = [];
const re = /\b(FIRST|SECOND)\s+SEMESTER\s*-\s*(\d{4})\b|\bSUMMER\s*-\s*(\d{4})\b/gi;
let m;
while ((m = re.exec(html)) !== null) {
if (m[1]) {
history.push({ semester: m[1].toUpperCase(), year: parseInt(m[2], 10) });
} else {
history.push({ semester: 'SUMMER', year: parseInt(m[3], 10) });
}
}
return history;
}
// Standing for a study load printed in `year` from the View Grades history.
// It is the number of distinct academic years strictly before that year + 1
// (FIRST/SECOND/SUMMER of a year share the same standing), clamped to at most
// the number of distinct years in history. NOTE: this is only an
// approximation for the printed semester — it counts calendar years present,
// not units passed — so a repeated/failed year can over-advance. It is used
// only as a fallback when the units-based standing is unavailable.
function yearLevelForSemester(history, year) {
if (!history || !history.length || !Number.isFinite(year)) return null;
const distinctYears = [...new Set(history.map((t) => t.year))].sort((a, b) => a - b);
const yearsBefore = distinctYears.filter((y) => y < year).length;
const level = Math.min(yearsBefore + 1, distinctYears.length);
return Number.isFinite(level) && level >= 1 ? level : null;
}
// Parse the Student Prospectus into per-year unit totals. Each table is
// preceded by a "Year Level: N" heading. Returns [{ year, units }].
function parseProspectusYearUnits(html) {
const segs = String(html || '').split(/(Year Level:\s*\d)/i);
const yearUnits = {};
let cur = null;
for (let i = 0; i < segs.length; i++) {
const s = segs[i];
const ym = /Year Level:\s*(\d+)/i.exec(s);
if (ym) { cur = parseInt(ym[1], 10); continue; }
if (cur == null) continue;
const tm = /<table[^>]*>([\s\S]*?)<\/table>/i.exec(s);
if (!tm) continue;
const rows = extractRows(tm[1]);
let units = 0;
for (const r of rows) {
if (r.length >= 3 && /^[A-Z]/.test(r[0]) && /[\d.]+/.test(r[2])) {
const u = parseFloat(String(r[2]).replace(/[^\d.]/g, ''));
if (Number.isFinite(u)) units += u;
}
}
yearUnits[cur] = (yearUnits[cur] || 0) + units;
cur = null; // only the immediately-following table belongs to this heading
}
const years = Object.keys(yearUnits).map(Number).sort((a, b) => a - b);
return years.map((y) => ({ year: y, units: yearUnits[y] }));
}
// Parse the Lacking Subjects page for total remaining units.
function parseLackingSubjectsUnits(html) {
return parseLackingSubjects(html).units;
}
function parseLackingSubjects(html) {
const rows = extractRows(String(html || ''));
let units = 0;
const courses = [];
for (const r of rows) {
if (r.length >= 3 && /^[A-Z]/.test(r[0]) && /[\d.]+/.test(r[2])) {
const u = parseFloat(String(r[2]).replace(/[^\d.]/g, ''));
if (Number.isFinite(u)) {
units += u;
courses.push({ courseCode: r[0], description: r[1], units: String(u) });
}
}
}
return { units: units.toFixed(2), courses };
}
// Year standing from units: finished = totalProspectusUnits - lackingUnits;
// standing = first N in [1..maxYear] where finished < cumulativeUnits(N);
// if finished passes every threshold, standing = maxYear (program length).
// yearUnits is the array [{year, units}] from parseProspectusYearUnits.
function computeYearStandingFromUnits(yearUnits, lackingUnits) {
const yrs = (yearUnits || []).slice().sort((a, b) => a.year - b.year);
if (!yrs.length) return null;
const total = yrs.reduce((s, y) => s + y.units, 0);
const finished = total - (parseFloat(lackingUnits) || 0);
if (finished < 0) return yrs[0].year;
let cum = 0;
for (const y of yrs) {
cum += y.units;
if (finished < cum) return y.year;
}
return yrs[yrs.length - 1].year; // passed everything -> final level
}
function filterStudyLoadCourses(courses) {
return (courses || []).filter(
(c) => String(c.status || '').trim().toUpperCase() !== 'DISSOLVED'
@@ -194,11 +93,5 @@
.toFixed(2);
}
return {
parseStudyLoadTable, parseProfilePage, parseOfferedCourses,
filterStudyLoadCourses, sumUnits,
parseGradeHistory, yearLevelForSemester,
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
computeYearStandingFromUnits
};
return { parseStudyLoadTable, parseProfilePage, parseOfferedCourses, filterStudyLoadCourses, sumUnits };
});
+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; }
-18
View File
@@ -32,24 +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>
<div class="row">Year Level mode:
<select id="yearMode">
<option value="auto">Auto (recommended)</option>
<option value="custom">Use custom value</option>
<option value="off">Profile value only</option>
</select>
</div>
<label class="row">Custom Year Level (optional):
<input type="text" id="customYear" placeholder="e.g. 3">
</label>
<p class="hint">Auto derives the standing of the semester being printed (from
View Grades, or remaining units). Profile value only disables derivation.</p>
</section>
<script src="popup.js"></script>
</body>
</html>
+11 -63
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,27 +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', 'yearMode']);
$('#truncateToggle').checked = s.truncateDescription !== false;
$('#showYearToggle').checked = s.showYearLevel !== false;
$('#customYear').value = s.customYearLevel || '';
$('#yearMode').value = s.yearMode || 'auto';
}
$('#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 });
});
$('#yearMode').addEventListener('change', async (e) => {
await browser.storage.local.set({ yearMode: e.target.value });
});
$('#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);
@@ -100,52 +74,27 @@ function openProfileForm(p) {
editingProfileId = p ? p.id : null;
$('#pName').value = p ? p.name : '';
$('#pUsername').value = p ? p.username : '';
// The stored password is encrypted and not revealed; leave the field blank.
// On save, a blank password preserves the existing one.
$('#pPassword').value = '';
$('#pPassword').value = p ? p.password : '';
$('#pEnabled').checked = p ? !!p.enabled : true;
$('#profileForm').hidden = false;
if (p) $('#pPassword').placeholder = 'Leave blank to keep existing password';
else $('#pPassword').placeholder = 'Password';
}
$('#addProfile').addEventListener('click', () => openProfileForm(null));
$('#cancelProfile').addEventListener('click', () => { $('#profileForm').hidden = true; });
$('#saveProfile').addEventListener('click', async () => {
const profiles = await getProfiles();
const name = $('#pName').value.trim();
const username = $('#pUsername').value.trim();
const typedPassword = $('#pPassword').value;
const enabled = $('#pEnabled').checked;
if (!name || !username) return;
// Encrypt a newly-typed password via the background.
let passwordEnc = null;
if (typedPassword) {
const res = await browser.runtime.sendMessage({ type: 'encrypt-password', password: typedPassword });
if (!res || !res.ok) { alert('Could not encrypt password: ' + ((res && res.error) || 'unknown error')); return; }
passwordEnc = res.cipher;
}
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].name = name;
profiles[i].username = username;
profiles[i].enabled = enabled;
// Only replace the password if a new one was typed; otherwise keep the
// existing (already encrypted) one.
if (passwordEnc) profiles[i].passwordEnc = passwordEnc;
delete profiles[i].password; // legacy plaintext no longer needed
}
if (i >= 0) profiles[i] = data;
} else {
const profile = {
id: Date.now().toString(),
name,
username,
enabled,
passwordEnc
};
profiles.push(profile);
profiles.push(data);
}
await saveProfiles(profiles);
$('#profileForm').hidden = true;
@@ -155,6 +104,5 @@ $('#saveProfile').addEventListener('click', async () => {
// ---- Init ----
(async function init() {
await loadMaster();
await loadPdfSettings();
renderProfiles(await getProfiles());
})();
-169
View File
@@ -1,169 +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',
'export-csv.js',
'export-json.js',
'export-xlsx.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();
-64
View File
@@ -1,64 +0,0 @@
'use strict';
// CI-only validation: build a sample study-load XLSX/CSV/JSON and confirm they
// open in real readers. The XLSX is re-parsed with python3 openpyxl (installed
// in CI), which catches malformed XML / cell-reference issues that the Node
// ZIP-structure tests cannot.
const fs = require('fs');
const path = require('path');
const os = require('os');
const { buildStudyLoadXlsx } = require('../export-xlsx.js');
const { buildStudyLoadCsv, buildNextUpCsv } = require('../export-csv.js');
const { buildStudyLoadJson } = require('../export-json.js');
const data = {
year: '2024',
period: '1ST Semester',
totalUnits: '21.00',
header: {
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
semester: '1ST Semester 2024',
yearLevel: '3'
},
courses: [
{ group: '1', courseCode: 'IT 3101N', description: 'PLATFORM TECHNOLOGIES',
schedule: 'F 01:30 PM - 04:30 PM LB486', faculty: 'TAN', units: '3.0', status: 'REGULAR' },
{ group: '1', courseCode: 'CIS 1101', description: 'PROGRAMMING, INTRO',
schedule: 'T Th 03:00 PM - 04:30 PM LB468', faculty: '', units: '3.0', status: 'REGULAR' }
]
};
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ismis-export-'));
fs.writeFileSync(path.join(dir, 'study-load.xlsx'), Buffer.from(buildStudyLoadXlsx(data)));
fs.writeFileSync(path.join(dir, 'study-load.csv'), buildStudyLoadCsv(data), 'utf8');
fs.writeFileSync(path.join(dir, 'study-load.json'), JSON.stringify(buildStudyLoadJson(data), null, 2), 'utf8');
fs.writeFileSync(path.join(dir, 'study-load-nextup.csv'), buildNextUpCsv(data), 'utf8');
console.log('wrote sample exports to', dir);
// XLSX: require python3 + openpyxl, parse and assert key cells.
const { execSync } = require('child_process');
const pyFile = path.join(dir, 'check.py');
const py = `
import openpyxl
wb = openpyxl.load_workbook('${path.join(dir, 'study-load.xlsx')}')
ws = wb.active
assert ws.title == 'Study Load', ws.title
assert ws['B2'].value == 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY', ws['B2'].value
assert ws['B3'].value == 'ADRIAN ALFRED CHIONG BONPIN'
assert ws['B6'].value == '3'
assert ws['A10'].value == '1' and ws['B10'].value == 'IT 3101N'
assert ws['F10'].value == '3.0'
print('openpyxl OK')
`;
fs.writeFileSync(pyFile, py);
try {
execSync('python3 ' + JSON.stringify(pyFile), { stdio: 'inherit' });
} catch (e) {
console.error('openpyxl validation failed');
process.exit(1);
}
console.log('ALL EXPORT VALIDATION PASSED');
-102
View File
@@ -1,102 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const {
buildStudyLoadCsv, buildNextUpCsv, parseScheduleBlocks, csvField
} = require('../export-csv.js');
const { buildStudyLoadJson } = require('../export-json.js');
const { buildStudyLoadXlsx } = require('../export-xlsx.js');
const data = {
year: '2024',
period: '1ST Semester',
totalUnits: '21.00',
header: {
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
semester: '1ST Semester 2024',
yearLevel: '3'
},
courses: [
{ group: '1', courseCode: 'IT 3101N', description: 'PLATFORM TECHNOLOGIES',
schedule: 'F 01:30 PM - 04:30 PM LB486', faculty: 'TAN', units: '3.0', status: 'REGULAR' },
{ group: '1', courseCode: 'CIS 1101', description: 'PROGRAMMING I',
schedule: 'T Th 03:00 PM - 04:30 PM LB468', faculty: '', units: '3.0', status: 'REGULAR' },
{ group: '1', courseCode: 'CS 5105', description: 'COMP, TALKS "X"',
schedule: '', faculty: '', units: '3.00', status: 'DISSOLVED' }
]
};
// ---- CSV ----
test('buildStudyLoadCsv includes header block, table, and total', () => {
const csv = buildStudyLoadCsv(data);
assert.ok(csv.startsWith('\uFEFF'), 'has UTF-8 BOM');
assert.ok(csv.includes('Program,BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY'));
assert.ok(csv.includes('Student ID,22103625'));
assert.ok(csv.includes('Group,Course Code,Description,Schedule,Faculty,Units,Status'));
assert.ok(csv.includes('1,IT 3101N,PLATFORM TECHNOLOGIES,F 01:30 PM - 04:30 PM LB486,TAN,3.0,REGULAR'));
assert.ok(/Total,.*21\.00/.test(csv));
});
test('csvField quotes fields containing commas, quotes, newlines', () => {
assert.strictEqual(csvField('plain'), 'plain');
assert.strictEqual(csvField('a,b'), '"a,b"');
assert.strictEqual(csvField('say "hi"'), '"say ""hi"""');
});
// ---- NextUp ----
test('buildNextUpCsv matches the Next Up column contract', () => {
const csv = buildNextUpCsv(data).replace(/^\uFEFF/, '');
const header = csv.split('\r\n')[0];
assert.strictEqual(header,
'title,courseCode,days,startTime,endTime,room,meetingLink,color,icon,units,classLink,professorName,professorEmail,groupName');
// Schedule blocks -> one row per block, day codes and 24h times.
assert.ok(csv.includes('PLATFORM TECHNOLOGIES,IT 3101N,FR,13:30,16:30,LB486'));
assert.ok(csv.includes('PROGRAMMING I,CIS 1101,"TU,TH",15:00,16:30,LB468'));
});
test('parseScheduleBlocks splits multi-block schedules', () => {
const blocks = parseScheduleBlocks('M 08:00 AM - 09:00 AM R1; W 10:00 AM - 12:00 PM R2');
assert.ok(Array.isArray(blocks) && blocks.length >= 1);
assert.strictEqual(Math.max(blocks.length, 0) > 0, true);
});
// ---- JSON ----
test('buildStudyLoadJson has improved, clean schema', () => {
const j = buildStudyLoadJson(data);
assert.strictEqual(j.meta.program, data.header.program);
assert.strictEqual(j.meta.studentName, data.header.fullName);
assert.strictEqual(j.meta.academicYear, '2024');
assert.strictEqual(j.meta.yearLevel, '3');
assert.strictEqual(j.meta.totalUnits, '21.00');
assert.ok(j.meta.generatedAt && /ISO|Z|T/.test(j.meta.generatedAt));
assert.strictEqual(j.courses.length, 3);
assert.strictEqual(j.courses[0].courseCode, 'IT 3101N');
assert.deepStrictEqual(j.totals, { enrolledUnits: '21.00' });
});
// ---- XLSX ----
test('buildStudyLoadXlsx returns a valid ZIP with expected parts', () => {
const bytes = buildStudyLoadXlsx(data);
assert.ok(bytes instanceof Uint8Array);
// A valid ZIP starts with PK (0x50 0x4B).
assert.strictEqual(bytes[0], 0x50);
assert.strictEqual(bytes[1], 0x4b);
// Contains XML as text (spot check for the sheet + shared strings).
const asText = Buffer.from(bytes).toString('latin1');
assert.ok(asText.includes('Content_Types'), 'content types part present');
assert.ok(asText.includes('sharedStrings'), 'shared strings part present');
assert.ok(asText.includes('ISMIS HELPER - STUDY LOAD'), 'title present');
});
test('buildStudyLoadXlsx embeds course data as shared strings', () => {
const bytes = buildStudyLoadXlsx(data);
const asText = Buffer.from(bytes).toString('latin1');
assert.ok(asText.includes('PLATFORM TECHNOLOGIES'));
assert.ok(asText.includes('IT 3101N'));
assert.ok(asText.includes('21.00'));
});
+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), '');
});
-165
View File
@@ -1,165 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const {
parseGradeHistory, yearLevelForSemester,
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
computeYearStandingFromUnits
} = require('../parsers.js');
// ---- View Grades history ----
test('parseGradeHistory extracts ordered semester terms', () => {
const html = `
<h3>FIRST SEMESTER - 2022</h3><table>...</table>
<h3>SECOND SEMESTER - 2022</h3><table>...</table>
<h3>FIRST SEMESTER - 2023</h3><table>...</table>
<h3>SUMMER - 2025</h3><table>...</table>
<h3>FIRST SEMESTER - 2026</h3><table>...</table>
`;
const h = parseGradeHistory(html);
assert.deepStrictEqual(h, [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SUMMER', year: 2025 },
{ semester: 'FIRST', year: 2026 }
]);
});
test('parseGradeHistory handles empty input', () => {
assert.deepStrictEqual(parseGradeHistory(''), []);
});
test('yearLevelForSemester returns standing from history position', () => {
const history = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SECOND', year: 2023 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 },
{ semester: 'FIRST', year: 2025 },
{ semester: 'SECOND', year: 2025 },
{ semester: 'FIRST', year: 2026 }
];
// 2022 -> Y1, 2023 -> Y2, 2024 -> Y3, 2025 -> Y4, 2026 -> Y5
assert.strictEqual(yearLevelForSemester(history, 2022), 1);
assert.strictEqual(yearLevelForSemester(history, 2023), 2);
assert.strictEqual(yearLevelForSemester(history, 2024), 3);
assert.strictEqual(yearLevelForSemester(history, 2025), 4);
assert.strictEqual(yearLevelForSemester(history, 2026), 5);
// Summer of 2025 is the same standing as 2025 -> Year 4 (period is not
// passed in; the standing is purely the history position).
assert.strictEqual(yearLevelForSemester(history, 2025), 4);
});
test('yearLevelForSemester clamps to program length and handles gaps', () => {
// History ends at 2025; a query beyond it must not exceed the final level.
const history = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SECOND', year: 2023 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 },
{ semester: 'FIRST', year: 2025 },
{ semester: 'SECOND', year: 2025 }
];
assert.strictEqual(yearLevelForSemester(history, 2028), 4); // clamped
// Gap year: a skipped academic year still advances standing.
const gap = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 }
];
assert.strictEqual(yearLevelForSemester(gap, 2024), 2);
});
test('yearLevelForSemester handles missing year and empty history', () => {
assert.strictEqual(yearLevelForSemester([], 2024), null);
assert.strictEqual(yearLevelForSemester([{ semester: 'FIRST', year: 2022 }], NaN), null);
});
// ---- Prospectus ----
test('parseProspectusYearUnits totals units per year level', () => {
const html = `
<h3>Year Level: 1</h3>
<table><tr><th>Course</th></tr>
<tr><td>CIS 1101</td><td>PROGRAMMING I</td><td>3.0</td></tr>
<tr><td>CIS 1102</td><td>INTRO</td><td>3.0</td></tr>
</table>
<h3>Year Level: 2</h3>
<table><tr><th>Course</th></tr>
<tr><td>CIS 2101</td><td>DATA STRUCTURES</td><td>3.0</td></tr>
</table>
<h3>Year Level: 3</h3>
<table><tr><th>Course</th></tr>
<tr><td>IT 3101N</td><td>PLATFORM</td><td>4.0</td></tr>
</table>
<h3>Year Level: 4</h3>
<table><tr><th>Course</th></tr>
<tr><td>IT 4101</td><td>CAPSTONE</td><td>2.0</td></tr>
</table>
`;
const y = parseProspectusYearUnits(html);
assert.deepStrictEqual(y, [
{ year: 1, units: 6 },
{ year: 2, units: 3 },
{ year: 3, units: 4 },
{ year: 4, units: 2 }
]);
});
// ---- Lacking subjects ----
test('parseLackingSubjects returns remaining units and courses', () => {
const html = `
<table>
<tr><th>Course Code</th><th>Description</th><th>Units</th></tr>
<tr><td>IT 3206</td><td>INTEGRATIVE PROGRAMMING</td><td>3.0</td></tr>
<tr><td>IT FREE EL</td><td>FREE ELECTIVE</td><td>3.0</td></tr>
<tr><td>IT 4202N</td><td>PRACTICUM 2</td><td>6.0</td></tr>
</table>
`;
const l = parseLackingSubjects(html);
assert.strictEqual(l.units, '12.00');
assert.strictEqual(l.courses.length, 3);
assert.strictEqual(parseLackingSubjectsUnits(html), '12.00');
});
test('parseLackingSubjects handles empty input', () => {
assert.strictEqual(parseLackingSubjectsUnits(''), '0.00');
});
// ---- Units math ----
test('computeYearStandingFromUnits maps finished units to year standing', () => {
// Cumulative 52/99/148/181 (matches the real BSIT prospectus).
const yearUnits = [
{ year: 1, units: 52 },
{ year: 2, units: 47 },
{ year: 3, units: 49 },
{ year: 4, units: 33 }
];
// finished = 181 - 24 = 157 -> Year 4 (real user data).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 24), 4);
// No lacking -> finished everything -> final year.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 0), 4);
// finished 40 -> still Year 1 (< 52).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 141), 1);
// finished 60 -> Year 2 (52 <= 60 < 99).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 121), 2);
// exact boundary: finished 52 -> Year 2.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 129), 2);
// finished 99 -> Year 3.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 82), 3);
});
test('computeYearStandingFromUnits handles empty and negative', () => {
assert.strictEqual(computeYearStandingFromUnits([], 5), null);
// lacking > total -> clamps to first year.
assert.strictEqual(computeYearStandingFromUnits([{ year: 1, units: 10 }], 99), 1);
});