Compare commits
19
Commits
2fa4064c7c
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa63f5157 | ||
|
|
7c578d2d16 | ||
|
|
41e7a4b81e | ||
|
|
92a7408100 | ||
|
|
c1567aca00 | ||
|
|
40fa9cc9b5 | ||
|
|
cd418f513d | ||
|
|
f68aa305fa | ||
|
|
9edaa687a5 | ||
|
|
b571953f10 | ||
|
|
71ef7fa36f | ||
|
|
73c68cc1e8 | ||
|
|
e11a7aad83 | ||
|
|
cb47d6faac | ||
|
|
106900860a | ||
|
|
b3b4495427 | ||
|
|
79d58f994d | ||
|
|
161f540e40 | ||
|
|
79702cb847 |
@@ -0,0 +1,73 @@
|
||||
name: build-extension
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [feat/ismis-helper, prod]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
|
||||
# Ensure the job token can create releases and upload assets.
|
||||
permissions:
|
||||
contents: write
|
||||
releases: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dev dependencies
|
||||
run: npm ci || npm install
|
||||
|
||||
- name: Install PyMuPDF (PDF text assertions)
|
||||
run: |
|
||||
python3 -m venv /tmp/venv
|
||||
/tmp/venv/bin/pip install --quiet pymupdf
|
||||
echo "/tmp/venv/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build both variants
|
||||
run: node scripts/build.js all
|
||||
|
||||
- name: Upload zip artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ismis-helper-zips
|
||||
path: dist/*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish release on version tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
# Gitea-native release: softprops/action-gh-release targets GitHub's API
|
||||
# and fails on Gitea (405). Instead we call the Gitea API directly with
|
||||
# the runner-provided GITHUB_TOKEN and GITHUB_API_URL.
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO="${{ github.repository }}"
|
||||
TAG="${{ github.ref_name }}"
|
||||
API="${GITHUB_API_URL:-https://git.ranio.xyz/api/v1}"
|
||||
# Create the release for the tag
|
||||
RELEASE_JSON=$(curl -sS -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"ISMIS Helper ${TAG}\"}" \
|
||||
"${API}/repos/${REPO}/releases")
|
||||
RELEASE_ID=$(echo "${RELEASE_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
# Attach both zips as release assets
|
||||
for f in dist/*.zip; do
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-F "attachment=@${f}" \
|
||||
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$(basename "${f}")"
|
||||
done
|
||||
@@ -1 +1,4 @@
|
||||
node_modules/
|
||||
|
||||
docs/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ISMIS Helper — Agent Guide
|
||||
|
||||
Guidance for AI coding agents and contributors working in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
A Firefox/Chrome **MV3** extension that automates the USC ISMIS student portal
|
||||
(`ismis.usc.edu.ph`): auto-login, privacy-policy auto-accept, and a **Study Load
|
||||
PDF** export that visually matches the official document (bundled **Segoe UI**
|
||||
fonts, USC logo, course table, units total).
|
||||
|
||||
No build step is required for the source itself; `scripts/build.js` only packages
|
||||
the two browser variants into `dist/`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install # dev deps (only needed for tests)
|
||||
npm test # run the test suite (node --test)
|
||||
node scripts/build.js all # build both browser zips -> dist/
|
||||
node scripts/build.js firefox # build just firefox
|
||||
node scripts/build.js chrome # build just chrome
|
||||
node scripts/build.js all --version 1.3.0 # versioned zips
|
||||
npm run build # shorthand for `node scripts/build.js all`
|
||||
```
|
||||
|
||||
Never edit anything under `dist/` — it is generated and gitignored.
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `manifest.json` — **Firefox** manifest (the single source of truth). The Chrome
|
||||
manifest is derived at build time.
|
||||
- `background.js` — message handler; builds the PDF via `pdf.js` and downloads it.
|
||||
Also has the cross-browser `browser.*`/`chrome.*` alias shim at the top.
|
||||
- `content.js` — runs on ismis.usc.edu.ph: auto-login, privacy modal auto-accept,
|
||||
scrapes the study load, injects the on-page **Download PDF** button.
|
||||
- `parsers.js` — pure HTML-scraping helpers (study load table, profile page,
|
||||
offered courses, filtering/summing).
|
||||
- `pdf.js` — builds the PDF; exports `buildStudyLoadPdf` plus formatting helpers
|
||||
(`compactProgram`, `formatSchedule`, `formatFullName`, `formatTeacher`,
|
||||
`deriveDateRange`, `truncateToWidth`).
|
||||
- `popup.html` / `popup.js` / `popup.css` — settings UI (profiles, master toggle,
|
||||
PDF settings like truncation and Year Level).
|
||||
- `libs/` — vendored `pdf-lib.min.js` and `fontkit.min.js` (needed to embed the
|
||||
custom Segoe UI fonts).
|
||||
- `assets/` — the transparent PNG logo and `fonts/Segoe-UI*.ttf`.
|
||||
- `icons/` — 16/32/48/128 extension icons.
|
||||
- `scripts/build.js` — zero-dependency packaging script for both browsers.
|
||||
- `.gitea/workflows/build.yml` — Gitea Actions: builds + tests on push, creates a
|
||||
Gitea Release with both zips when a `v*` tag is pushed.
|
||||
- `test/` — `node --test` suite (`pdf.test.js`, `parsers.test.js`).
|
||||
|
||||
## Build model (Firefox vs Chrome)
|
||||
|
||||
- The **root is the Firefox source**: `manifest.json` uses `background.scripts`
|
||||
(multiple files) and `browser.*` APIs.
|
||||
- The **Chrome build** derives a manifest with
|
||||
`"background": { "service_worker": "background.chrome.js" }` and assembles that
|
||||
single worker by concatenating, in order:
|
||||
`libs/fontkit.min.js` → `libs/pdf-lib.min.js` → `pdf.js` → `background.js`.
|
||||
- The cross-browser shim (top of `background.js`, `content.js`, `popup.js`) aliases
|
||||
`browser` to `chrome` when the former is absent. It is a no-op in Firefox.
|
||||
- `scripts/build.js` **excludes** `test/`, `docs/`, `scripts/`, `dist/`, and
|
||||
`.git` from the packaged output.
|
||||
|
||||
## Conventions & constraints
|
||||
|
||||
- Plain ES5+ JavaScript. No framework, no bundler, no build step for the source.
|
||||
- Use Promise-based `browser.*` APIs (Chrome MV3 supports `browser.*`; the shim
|
||||
covers builds that only expose `chrome.*`).
|
||||
- Login profiles are stored **plaintext** in `browser.storage.local`. At most one
|
||||
profile is enabled at a time; a master `enabled` toggle disables all behavior.
|
||||
- The on-page **Download PDF** button must never touch another window's DOM; it
|
||||
sends a message to the background which downloads via
|
||||
`browser.downloads.download({ saveAs: true })` (blob → object URL).
|
||||
- If you change `pdf.js` layout constants, verify against the reference PDF
|
||||
(`/Users/adrianbonpin/Downloads/BSIT - Y3S1 - Study Load.pdf`) and re-check the
|
||||
position measurements, since offsets can be font-metric dependent.
|
||||
- Keep `docs/` and `dist/` out of git (they are ignored).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Run `npm test` before and after changes.
|
||||
2. Build (`npm run build`) to make sure both variants assemble.
|
||||
3. Manually load `dist/firefox` in Firefox (`about:debugging`) and `dist/chrome`
|
||||
in Chrome (`chrome://extensions`) to verify PDF generation.
|
||||
@@ -0,0 +1,128 @@
|
||||
# ISMIS Helper
|
||||
|
||||
A lightweight **[Firefox](https://addons.mozilla.org/)** / **[Chrome](https://chromewebstore.google.com/)**
|
||||
extension for the USC ISMIS student portal that:
|
||||
|
||||
- Auto-fills and auto-submits your login.
|
||||
- Auto-accepts the privacy-policy modal.
|
||||
- Exports a **Study Load PDF** that matches the official layout — bundled **Segoe UI**
|
||||
fonts, the USC logo, the course table, and the units total.
|
||||
|
||||
One codebase builds for **both browsers** (see [Build](#build)).
|
||||
|
||||
## Install
|
||||
|
||||
> Temporary/development install. For distribution, publish the built zips to
|
||||
> Firefox AMO and the Chrome Web Store — they are store-ready.
|
||||
|
||||
### Firefox (load temporary add-on)
|
||||
|
||||
1. Build the Firefox variant (or download `ismis-helper-firefox.zip` from CI):
|
||||
```bash
|
||||
node scripts/build.js firefox
|
||||
```
|
||||
The output is at `dist/ismis-helper-firefox.zip` (unzip it, or use `dist/firefox/`
|
||||
directly).
|
||||
2. Open `about:debugging` in Firefox.
|
||||
3. Click **This Firefox** → **Load Temporary Add-on…**.
|
||||
4. Select `dist/firefox/manifest.json`.
|
||||
5. Open `https://ismis.usc.edu.ph` and use the extension.
|
||||
|
||||
### Chrome (load unpacked)
|
||||
|
||||
1. Build the Chrome variant (or download `ismis-helper-chrome.zip` from CI):
|
||||
```bash
|
||||
node scripts/build.js chrome
|
||||
```
|
||||
2. Unzip `dist/ismis-helper-chrome.zip` (or use `dist/chrome/` directly).
|
||||
3. Open `chrome://extensions` in Chrome.
|
||||
4. Enable **Developer mode** (toggle, top-right).
|
||||
5. Click **Load unpacked** and select the unzipped `dist/chrome` folder.
|
||||
6. Open `https://ismis.usc.edu.ph` and use the extension.
|
||||
|
||||
> For a persistent install, package and publish via Firefox AMO / Chrome Web Store
|
||||
> with the same built zips.
|
||||
|
||||
### Distribute to real users
|
||||
|
||||
#### Firefox (self-hosted, temporary)
|
||||
|
||||
> **Note:** Firefox blocks unsigned extensions for normal installs. The proper
|
||||
> fix is to sign the add-on via **addons.mozilla.org (AMO)** and publish it there
|
||||
> (or self-host a signed `.xpi`). AMO is currently **under maintenance**, so until
|
||||
> it returns, the only way to install the unsigned build is to lower Firefox's
|
||||
> signing check on each user's machine. This is a per-user browser setting and is
|
||||
> **not** suitable for a broad audience — use it for personal / small-group use.
|
||||
|
||||
1. Build the Firefox variant (or download `ismis-helper-firefox.zip` from a release):
|
||||
```bash
|
||||
node scripts/build.js firefox
|
||||
```
|
||||
2. In Firefox, open `about:config` and accept the warning.
|
||||
3. Search for `xpinstall.signatures.required` and set it to **`false`**.
|
||||
4. **Restart Firefox** for the change to take effect.
|
||||
5. Install the add-on from the release zip: drag `ismis-helper-firefox.zip` into
|
||||
Firefox (or File → Open File… and select it).
|
||||
|
||||
> When AMO is back online, prefer the signed route: publish the same zip to AMO
|
||||
> (or sign it with `web-ext sign`) so users install it normally without touching
|
||||
> `about:config`.
|
||||
|
||||
#### Chrome (self-hosted)
|
||||
|
||||
Chrome does **not** require signing for normal installs. Users can install the
|
||||
built `ismis-helper-chrome.zip` directly:
|
||||
|
||||
1. Download `ismis-helper-chrome.zip` from a release and unzip it.
|
||||
2. Open `chrome://extensions`, enable **Developer mode**, and click **Load unpacked**.
|
||||
3. Select the unzipped folder.
|
||||
|
||||
> For a store experience, publish the same zip to the Chrome Web Store (one-time
|
||||
> $5 developer registration).
|
||||
|
||||
## Features
|
||||
|
||||
- **Saved login profiles** (stored in `browser.storage.local`) — only one enabled at
|
||||
a time, with a master on/off toggle.
|
||||
- **One-click Download PDF** button on the Study Load page.
|
||||
- PDF settings in the popup:
|
||||
- Truncate long course descriptions to match the official PDF (default on).
|
||||
- Show/hide `Year Level`, and set a custom Year Level value.
|
||||
- **Bundled Segoe UI fonts** for a 1:1 look with the official document.
|
||||
- Builds and packages automatically via Gitea Actions (zips + versioned releases).
|
||||
|
||||
## Build
|
||||
|
||||
Requires **Node.js** (the `build.js` script itself is dependency-free; `npm install`
|
||||
is only needed to run the tests).
|
||||
|
||||
```bash
|
||||
npm install # dev deps for tests
|
||||
npm test # run the test suite
|
||||
npm run build # build both -> dist/*.zip
|
||||
node scripts/build.js all # build both explicitly
|
||||
node scripts/build.js chrome # just chrome
|
||||
node scripts/build.js all --version 1.3.0 # versioned zips
|
||||
```
|
||||
|
||||
**CI (Gitea Actions):** on every push, both zips are built, tests run, and the
|
||||
zips are uploaded as artifacts. When you push a `v*` tag (e.g. `v1.3.0`), it
|
||||
additionally creates a **Gitea Release** with both versioned zips attached.
|
||||
|
||||
## Settings
|
||||
|
||||
Open the extension popup to:
|
||||
- Manage login profiles (add / edit / enable).
|
||||
- Toggle the master on/off switch.
|
||||
- Set PDF options: truncate descriptions, show/hide `Year Level`, custom Year Level.
|
||||
|
||||
## Data & privacy
|
||||
|
||||
- Auto-login stores your portal credentials **in plaintext** on your own device.
|
||||
- This is a personal-use helper for the university portal; use it only with your
|
||||
own account.
|
||||
|
||||
## Development
|
||||
|
||||
See [`AGENTS.md`](AGENTS.md) for the repository layout, build model, and coding
|
||||
conventions for this project.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
+22
-4
@@ -1,9 +1,28 @@
|
||||
'use strict';
|
||||
// Cross-browser API namespace. Firefox provides Promise-based `browser.*`;
|
||||
// Chrome MV3 exposes `browser.*` too, but some builds only populate the
|
||||
// `chrome.*` global. Alias to whichever is present.
|
||||
if (typeof browser === 'undefined' || browser == null) {
|
||||
var browser = (typeof chrome !== 'undefined' ? chrome : {});
|
||||
}
|
||||
|
||||
function errMsg(err) {
|
||||
return String((err && err.message) || err);
|
||||
}
|
||||
|
||||
// Build a download URL from PDF bytes. Chrome MV3 runs the background as a
|
||||
// service worker, where URL.createObjectURL is NOT available, so we use a
|
||||
// base64 data URL (works in both Firefox and Chrome). PDFs here are small
|
||||
// (~150KB), well within data-URL length limits.
|
||||
function pdfDataUrl(bytes) {
|
||||
let binary = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return 'data:application/pdf;base64,' + btoa(binary);
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg && msg.type === 'generate-pdf') {
|
||||
generatePdf(msg.data)
|
||||
@@ -26,8 +45,9 @@ 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);
|
||||
// Chrome MV3 background is a service worker (no URL.createObjectURL), so we
|
||||
// use a base64 data URL that works in both browsers.
|
||||
const url = pdfDataUrl(bytes);
|
||||
const header = data.header || {};
|
||||
const year = parseInt(data.year, 10);
|
||||
const period = formatPeriod(data.period || header.semester || '');
|
||||
@@ -49,12 +69,10 @@ async function generatePdf(data) {
|
||||
const onChanged = (delta) => {
|
||||
if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) {
|
||||
browser.downloads.onChanged.removeListener(onChanged);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
browser.downloads.onChanged.addListener(onChanged);
|
||||
} catch (err) {
|
||||
URL.revokeObjectURL(url);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -1,6 +1,11 @@
|
||||
(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() {
|
||||
@@ -164,13 +169,25 @@
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...';
|
||||
const { header, courses } = await scrapeStudyLoad();
|
||||
const { header, courses, period: selectedPeriod } = await scrapeStudyLoad();
|
||||
const included = ISMIStools.filterStudyLoadCourses(courses);
|
||||
if (!included.length) throw new Error('No enrolled courses found.');
|
||||
const totalUnits = ISMIStools.sumUnits(included);
|
||||
const settings = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
|
||||
// Resolve the Year Level: custom value wins; otherwise prefer the scraped
|
||||
// value, smart-adjusted (minus one) during SUMMER when the year level has
|
||||
// not yet advanced.
|
||||
header.showYearLevel = settings.showYearLevel !== false;
|
||||
const custom = String(settings.customYearLevel || '').trim();
|
||||
if (custom) {
|
||||
header.yearLevel = custom;
|
||||
} else if (/summer/i.test(selectedPeriod || '')) {
|
||||
const n = parseInt(header.yearLevel, 10);
|
||||
if (Number.isFinite(n) && n > 1) header.yearLevel = String(n - 1);
|
||||
}
|
||||
const res = await browser.runtime.sendMessage({
|
||||
type: 'generate-pdf',
|
||||
data: { header, courses: included, totalUnits }
|
||||
data: { header, courses: included, totalUnits, truncateDescription: settings.truncateDescription !== false }
|
||||
});
|
||||
if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed');
|
||||
showToast('PDF downloaded.');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,145 +0,0 @@
|
||||
# ISMIS Helper — Firefox Extension Design
|
||||
|
||||
**Date:** 2026-08-18
|
||||
**Status:** Approved
|
||||
**Target site:** `https://ismis.usc.edu.ph` (USC ISMIS student portal)
|
||||
|
||||
## Overview
|
||||
|
||||
A personal Firefox extension (Manifest V3, no build step) that improves the USC ISMIS
|
||||
student portal experience. It provides three features:
|
||||
|
||||
1. **Auto-login** — auto-fill and auto-submit the login form from a saved profile.
|
||||
2. **Auto-accept Privacy Policy** — dismiss the recurring privacy-policy modal.
|
||||
3. **Study Load PDF** — 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.
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+1
-1
@@ -6,7 +6,7 @@
|
||||
"permissions": ["storage", "downloads"],
|
||||
"host_permissions": ["https://ismis.usc.edu.ph/*"],
|
||||
"background": {
|
||||
"scripts": ["libs/pdf-lib.min.js", "pdf.js", "background.js"]
|
||||
"scripts": ["libs/fontkit.min.js", "libs/pdf-lib.min.js", "pdf.js", "background.js"]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"description": "Firefox extension for the USC ISMIS portal",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
"test": "node --test",
|
||||
"build": "node scripts/build.js all"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pdf-lib": "^1.17.1"
|
||||
|
||||
@@ -56,6 +56,44 @@
|
||||
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) => {
|
||||
@@ -64,6 +102,22 @@
|
||||
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.
|
||||
@@ -94,13 +148,12 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// Fetch a bundled asset in the extension background page. Returns null in
|
||||
// Node (tests) or if the asset is unavailable.
|
||||
async function loadAssetBytes(asset) {
|
||||
const ext = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL);
|
||||
try {
|
||||
const url = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL)
|
||||
? browser.runtime.getURL('assets/logo.jpeg')
|
||||
: null;
|
||||
const url = ext ? browser.runtime.getURL(asset) : null;
|
||||
if (!url) return null;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
@@ -110,6 +163,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -121,12 +231,14 @@
|
||||
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 font = doc.embedStandardFont(StandardFonts.Helvetica);
|
||||
const bold = doc.embedStandardFont(StandardFonts.HelveticaBold);
|
||||
const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique);
|
||||
const fonts = await loadFonts(doc, PDFLib);
|
||||
const { font, bold, italic } = fonts;
|
||||
const margin = 36;
|
||||
const contentWidth = 539.3; // matches reference right edge 575.3
|
||||
let y = 792 - 10; // near the very top, matching the reference logo position
|
||||
@@ -134,10 +246,16 @@
|
||||
const black = rgb(0, 0, 0);
|
||||
const gray = rgb(0.3, 0.3, 0.3);
|
||||
|
||||
const logoBytes = await loadLogo();
|
||||
const logo = await loadLogo();
|
||||
let logoImg = null;
|
||||
if (logoBytes) {
|
||||
try { logoImg = await doc.embedJpg(logoBytes); } catch (e) { logoImg = null; }
|
||||
if (logo) {
|
||||
try {
|
||||
// Transparent PNG embeds with an alpha mask (matches official PDF);
|
||||
// the JPEG is embedded as-is (no mask).
|
||||
logoImg = logo.transparent
|
||||
? await doc.embedPng(logo.bytes)
|
||||
: await doc.embedJpg(logo.bytes);
|
||||
} catch (e) { logoImg = null; }
|
||||
}
|
||||
|
||||
function center(text, size, f, dy) {
|
||||
@@ -158,26 +276,38 @@
|
||||
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;
|
||||
y -= logoH + 8.79;
|
||||
}
|
||||
center("STUDENT'S STUDY LOAD", 9.5, bold, 14);
|
||||
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.5);
|
||||
// Baseline advances match the reference (Segoe UI) header layout.
|
||||
center("STUDENT'S STUDY LOAD", 9.5, bold, 13.84);
|
||||
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.76);
|
||||
const dateRange = header.dateRange || deriveDateRange(header.semester);
|
||||
if (dateRange) center(dateRange, 9, font, 13.5);
|
||||
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13.5);
|
||||
if (dateRange) center(dateRange, 9, font, 11.75);
|
||||
// Always reserve the Date Enrolled line's vertical space (so the layout
|
||||
// stays aligned with the reference) even though we never have the value.
|
||||
if (header.dateEnrolled) {
|
||||
left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 14.53);
|
||||
} else {
|
||||
y -= 14.53; // keep the same gap, drawing nothing
|
||||
}
|
||||
|
||||
// Student info row, laid out in fixed zones across the page (matching the
|
||||
// reference): ID (bold) left, name at ~100, program at ~338, and
|
||||
// "Year Level: N" right-aligned to the right content edge (575.3).
|
||||
const yearLevelText = header.yearLevel ? 'Year Level: ' + header.yearLevel : '';
|
||||
// Year Level is shown by default; the extension can hide it or override the
|
||||
// value via settings (header.showYearLevel / header.yearLevel).
|
||||
const yearLevelText = (header.showYearLevel !== false && header.yearLevel)
|
||||
? 'Year Level: ' + header.yearLevel
|
||||
: '';
|
||||
if (header.studentId) page.drawText(header.studentId, { x: margin, y, size: 9, font: bold, color: black });
|
||||
if (header.fullName) page.drawText(formatFullName(header.fullName), { x: 100, y, size: 9, font, color: black });
|
||||
if (header.program) page.drawText(compactProgram(header.program), { x: 338, y, size: 9, font, color: black });
|
||||
if (yearLevelText) {
|
||||
const w = font.widthOfTextAtSize(yearLevelText, 9);
|
||||
page.drawText(yearLevelText, { x: margin + contentWidth - w, y, size: 9, font, color: black });
|
||||
// Right-aligned to the content edge, nudged left ~6pt (half of 1rem).
|
||||
page.drawText(yearLevelText, { x: margin + contentWidth - w - 6, y, size: 9, font, color: black });
|
||||
}
|
||||
if (header.studentId || header.fullName || header.program || yearLevelText) y -= 14.5;
|
||||
if (header.studentId || header.fullName || header.program || yearLevelText) y -= 13.59;
|
||||
|
||||
// ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ----
|
||||
// Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3).
|
||||
@@ -193,17 +323,17 @@
|
||||
const headerSize = 8;
|
||||
const lineH = 12;
|
||||
|
||||
// Header label positions tuned for Helvetica: left/center aligned within
|
||||
// each column so labels never overlap.
|
||||
// Header label positions tuned for Segoe UI (matches the reference):
|
||||
// left/center aligned within each column so labels never overlap.
|
||||
function headerLabelXFor() {
|
||||
const widths = {};
|
||||
for (const c of cols) {
|
||||
const key = c.label.toUpperCase().replace(/\s+/g, '_');
|
||||
widths[key] = bold.widthOfTextAtSize(c.label, headerSize);
|
||||
widths[key] = font.widthOfTextAtSize(c.label, headerSize);
|
||||
}
|
||||
return {
|
||||
GROUP: cols[0].x,
|
||||
COURSE: cols[1].x + 5,
|
||||
GROUP: cols[0].x + 1.3,
|
||||
COURSE: cols[1].x + 9,
|
||||
COURSE_DESCRIPTION: cols[2].x + (cols[2].w - widths.COURSE_DESCRIPTION) / 2,
|
||||
TEACHER: cols[3].x + (cols[3].w - widths.TEACHER) / 2,
|
||||
UNITS: cols[4].x + (cols[4].w - widths.UNITS) / 2,
|
||||
@@ -215,7 +345,12 @@
|
||||
return cols.map((c) => {
|
||||
let lines;
|
||||
if (c.key === 'description') {
|
||||
lines = wrapLines(cells.description, font, cellSize, c.w - 6);
|
||||
if (truncateDescription) {
|
||||
// Single line, hard-truncated to fit the column (official PDF style).
|
||||
lines = [truncateToWidth(cells.description, font, cellSize, c.w - 3)];
|
||||
} else {
|
||||
lines = wrapLines(cells.description, font, cellSize, c.w - 6);
|
||||
}
|
||||
const sched = String(cells.schedule || '');
|
||||
if (sched) lines.push(...wrapLines(sched, font, cellSize, c.w - 6));
|
||||
} else {
|
||||
@@ -226,7 +361,8 @@
|
||||
}
|
||||
|
||||
function drawRow(cells, isHeader) {
|
||||
const f = isHeader ? bold : font;
|
||||
// Header labels and data all use the body font (header is not bold).
|
||||
const f = font;
|
||||
const size = isHeader ? headerSize : cellSize;
|
||||
const linesPerCol = rowLines(cells);
|
||||
|
||||
@@ -278,8 +414,9 @@
|
||||
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, y: ty, size, font: f, color: black });
|
||||
page.drawText(line, { x: c.x + (c.w - w) / 2, y: ty, size, font: f, color: black });
|
||||
} else if (c.key === 'status') {
|
||||
page.drawText(line, { x: c.x + 35, y: ty, size, font: f, color: black });
|
||||
} else {
|
||||
@@ -312,7 +449,7 @@
|
||||
courseCode: course.courseCode,
|
||||
description: course.description,
|
||||
schedule: formatSchedule(course.schedule),
|
||||
teacher: course.teacher || course.faculty,
|
||||
teacher: formatTeacher(course.teacher || course.faculty),
|
||||
units: course.units,
|
||||
status: course.status
|
||||
};
|
||||
@@ -324,18 +461,18 @@
|
||||
page = doc.addPage([612, 792]);
|
||||
y = 792 - 10;
|
||||
}
|
||||
y -= 12;
|
||||
if (data.totalUnits) {
|
||||
// Total sits under the UNITS column, right-aligned within it.
|
||||
// Total sits one standard row height below the last course row (matching
|
||||
// the reference, which has a ~24.5pt gap), centered within the UNITS col.
|
||||
const unitsCol = cols[4];
|
||||
const w = bold.widthOfTextAtSize(data.totalUnits, 8);
|
||||
const w = font.widthOfTextAtSize(data.totalUnits, 8);
|
||||
// Short underline above the total, spanning the UNITS column.
|
||||
hline(unitsCol.x, unitsCol.x + unitsCol.w, y + 9);
|
||||
page.drawText(data.totalUnits, { x: unitsCol.x + unitsCol.w - w, y, size: 8, font: bold, color: black });
|
||||
page.drawText(data.totalUnits, { x: unitsCol.x + (unitsCol.w - w) / 2, y, size: 8, font, color: black });
|
||||
y -= 12;
|
||||
}
|
||||
hline(margin, margin + contentWidth, y);
|
||||
y -= 12;
|
||||
y -= 10.1;
|
||||
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
|
||||
const pw = font.widthOfTextAtSize(printedBy, 8);
|
||||
page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black });
|
||||
@@ -343,5 +480,5 @@
|
||||
return doc.save(); // returns Promise<Uint8Array>
|
||||
}
|
||||
|
||||
return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName };
|
||||
return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth };
|
||||
});
|
||||
|
||||
@@ -48,3 +48,9 @@ body {
|
||||
border: 1px solid #ccc; border-radius: 4px;
|
||||
}
|
||||
.form .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||
.tab .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||
.tab .row input[type="text"] {
|
||||
flex: 1; min-width: 0; padding: 4px 6px;
|
||||
border: 1px solid #ccc; border-radius: 4px;
|
||||
}
|
||||
.hint { font-size: 11px; color: #777; margin-top: 4px; }
|
||||
|
||||
+10
@@ -32,6 +32,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab">
|
||||
<h2>PDF Settings</h2>
|
||||
<label class="row"><input type="checkbox" id="truncateToggle"> Truncate long course descriptions (official PDF style)</label>
|
||||
<label class="row"><input type="checkbox" id="showYearToggle"> Show Year Level on the PDF</label>
|
||||
<label class="row">Custom Year Level (optional):
|
||||
<input type="text" id="customYear" placeholder="e.g. 3">
|
||||
</label>
|
||||
<p class="hint">Leave blank to use the scraped value (auto-adjusted in Summer).</p>
|
||||
</section>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
'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';
|
||||
@@ -18,6 +23,23 @@ $('#masterToggle').addEventListener('change', async (e) => {
|
||||
$('#masterLabel').textContent = e.target.checked ? 'Enabled' : 'Disabled';
|
||||
});
|
||||
|
||||
// ---- PDF settings: truncate long course descriptions (default ON) ----
|
||||
async function loadPdfSettings() {
|
||||
const s = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
|
||||
$('#truncateToggle').checked = s.truncateDescription !== false;
|
||||
$('#showYearToggle').checked = s.showYearLevel !== false;
|
||||
$('#customYear').value = s.customYearLevel || '';
|
||||
}
|
||||
$('#truncateToggle').addEventListener('change', async (e) => {
|
||||
await browser.storage.local.set({ truncateDescription: e.target.checked });
|
||||
});
|
||||
$('#showYearToggle').addEventListener('change', async (e) => {
|
||||
await browser.storage.local.set({ showYearLevel: e.target.checked });
|
||||
});
|
||||
$('#customYear').addEventListener('change', async (e) => {
|
||||
await browser.storage.local.set({ customYearLevel: e.target.value.trim() });
|
||||
});
|
||||
|
||||
// ---- Profiles ----
|
||||
async function getProfiles() {
|
||||
const { profiles } = await browser.storage.local.get(PROFILES_KEY);
|
||||
@@ -104,5 +126,6 @@ $('#saveProfile').addEventListener('click', async () => {
|
||||
// ---- Init ----
|
||||
(async function init() {
|
||||
await loadMaster();
|
||||
await loadPdfSettings();
|
||||
renderProfiles(await getProfiles());
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
'use strict';
|
||||
// Zero-dependency build script: packages the extension for Firefox and Chrome.
|
||||
// Usage: node scripts/build.js [firefox|chrome|all] [--version X.Y.Z]
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
const FF_GECKO_ID = process.env.FF_GECKO_ID || 'ismis-helper@adrianbonpin';
|
||||
|
||||
// Order matters for the single-file Chrome service worker:
|
||||
// fontkit -> pdf-lib -> pdf.js -> background.js
|
||||
const CHROME_BG_PARTS = [
|
||||
'libs/fontkit.min.js',
|
||||
'libs/pdf-lib.min.js',
|
||||
'pdf.js',
|
||||
'background.js',
|
||||
];
|
||||
|
||||
// Everything to copy from ROOT into a target, relative paths.
|
||||
function collectFiles(srcDir, out = [], prefix = '') {
|
||||
for (const entry of fs.readdirSync(srcDir)) {
|
||||
const abs = path.join(srcDir, entry);
|
||||
const rel = prefix ? prefix + '/' + entry : entry;
|
||||
if (entry === '.git' || entry === 'node_modules' || entry === 'dist' ||
|
||||
entry === 'docs' || entry === 'scripts' || entry === 'test' ||
|
||||
entry === 'manifest.json' || entry.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
if (fs.statSync(abs).isDirectory()) collectFiles(abs, out, rel);
|
||||
else out.push(rel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function copyDir(relList, destDir) {
|
||||
for (const rel of relList) {
|
||||
const src = path.join(ROOT, rel);
|
||||
const dst = path.join(destDir, rel);
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest() {
|
||||
return JSON.parse(fs.readFileSync(path.join(ROOT, 'manifest.json'), 'utf8'));
|
||||
}
|
||||
|
||||
// ---- STORED zip writer (no compression, valid for both browsers) ----
|
||||
function crc32(buf) {
|
||||
let table = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function writeStoredZip(files, baseDir, zipPath) {
|
||||
const chunks = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
for (const rel of files) {
|
||||
const data = fs.readFileSync(path.join(baseDir, rel));
|
||||
const name = Buffer.from(rel, 'utf8');
|
||||
const crc = crc32(data);
|
||||
const lh = Buffer.alloc(30);
|
||||
lh.writeUInt32LE(0x04034b50, 0);
|
||||
lh.writeUInt16LE(20, 4);
|
||||
lh.writeUInt16LE(0, 6);
|
||||
lh.writeUInt16LE(0, 8); // method: stored
|
||||
lh.writeUInt16LE(0, 10);
|
||||
lh.writeUInt16LE(0, 12);
|
||||
lh.writeUInt32LE(crc, 14);
|
||||
lh.writeUInt32LE(data.length, 18);
|
||||
lh.writeUInt32LE(data.length, 22);
|
||||
lh.writeUInt16LE(name.length, 26);
|
||||
lh.writeUInt16LE(0, 28);
|
||||
chunks.push(lh, name, data);
|
||||
const ch = Buffer.alloc(46);
|
||||
ch.writeUInt32LE(0x02014b50, 0);
|
||||
ch.writeUInt16LE(20, 4);
|
||||
ch.writeUInt16LE(20, 6);
|
||||
ch.writeUInt16LE(0, 8);
|
||||
ch.writeUInt16LE(0, 10);
|
||||
ch.writeUInt16LE(0, 12);
|
||||
ch.writeUInt32LE(crc, 16);
|
||||
ch.writeUInt32LE(data.length, 20);
|
||||
ch.writeUInt32LE(data.length, 24);
|
||||
ch.writeUInt16LE(name.length, 28);
|
||||
ch.writeUInt16LE(0, 30);
|
||||
ch.writeUInt16LE(0, 32);
|
||||
ch.writeUInt16LE(0, 34);
|
||||
ch.writeUInt16LE(0, 36);
|
||||
ch.writeUInt32LE(0, 38);
|
||||
ch.writeUInt32LE(offset, 42);
|
||||
central.push(ch, name);
|
||||
offset += lh.length + name.length + data.length;
|
||||
}
|
||||
const cdStart = offset;
|
||||
let cdSize = 0;
|
||||
central.forEach((b) => { cdSize += b.length; });
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(cdSize, 12);
|
||||
eocd.writeUInt32LE(cdStart, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
const all = Buffer.concat([...chunks, ...central, eocd]);
|
||||
fs.writeFileSync(zipPath, all);
|
||||
}
|
||||
|
||||
function buildTarget(target, version) {
|
||||
const dir = path.join(DIST, target);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const relList = collectFiles(ROOT);
|
||||
copyDir(relList, dir);
|
||||
|
||||
if (target === 'firefox') {
|
||||
const m = readManifest();
|
||||
if (version) m.version = version;
|
||||
m.browser_specific_settings = { gecko: { id: FF_GECKO_ID } };
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2) + '\n');
|
||||
} else { // chrome
|
||||
const m = readManifest();
|
||||
if (version) m.version = version;
|
||||
delete m.background;
|
||||
m.background = { service_worker: 'background.chrome.js' };
|
||||
delete m.browser_specific_settings;
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2) + '\n');
|
||||
const worker = CHROME_BG_PARTS
|
||||
.map((p) => fs.readFileSync(path.join(ROOT, p), 'utf8'))
|
||||
.join('\n;\n');
|
||||
fs.writeFileSync(path.join(dir, 'background.chrome.js'), worker);
|
||||
}
|
||||
|
||||
const suffix = version ? '-' + version : '';
|
||||
const zipName = path.join(DIST, `ismis-helper-${target}${suffix}.zip`);
|
||||
writeStoredZip(collectFiles(dir), dir, zipName);
|
||||
console.log(`built ${target}: ${zipName}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const rawTargets = args.filter((a) => !a.startsWith('--'));
|
||||
const vi = args.indexOf('--version');
|
||||
let version = null;
|
||||
if (vi !== -1 && args[vi + 1]) version = String(args[vi + 1]).replace(/^v/, '');
|
||||
const requested = rawTargets.length ? rawTargets : ['all'];
|
||||
const targets = requested.includes('all')
|
||||
? ['firefox', 'chrome']
|
||||
: requested.filter((t) => t === 'firefox' || t === 'chrome');
|
||||
if (!targets.length) {
|
||||
console.error('Usage: node scripts/build.js [firefox|chrome|all] [--version X.Y.Z]');
|
||||
process.exit(1);
|
||||
}
|
||||
for (const t of targets) buildTarget(t, version);
|
||||
}
|
||||
|
||||
main();
|
||||
+42
-2
@@ -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 } = require('../pdf.js');
|
||||
const { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth } = require('../pdf.js');
|
||||
|
||||
const sampleData = {
|
||||
header: {
|
||||
@@ -38,7 +38,19 @@ function extractPdfText(bytes) {
|
||||
return execFileSync('python3', ['-c', script], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
test('buildStudyLoadPdf returns valid PDF bytes', async () => {
|
||||
// PyMuPDF (fitz) is only needed for the text-extraction assertions below. It is
|
||||
// installed in CI, but if it is missing locally we skip those assertions rather
|
||||
// than hard-failing.
|
||||
function fitzAvailable() {
|
||||
try {
|
||||
execFileSync('python3', ['-c', 'import fitz'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('buildStudyLoadPdf returns valid PDF bytes', async (t) => {
|
||||
const bytes = await buildStudyLoadPdf(sampleData);
|
||||
assert.ok(bytes instanceof Uint8Array);
|
||||
const head = Buffer.from(bytes.slice(0, 5)).toString('latin1');
|
||||
@@ -46,6 +58,11 @@ test('buildStudyLoadPdf returns valid PDF bytes', async () => {
|
||||
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');
|
||||
@@ -82,9 +99,32 @@ 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), '');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user