Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a94d7072f | ||
|
|
989643cead | ||
|
|
0cb70ecee0 | ||
|
|
89fd587c21 | ||
|
|
d08b1d75aa | ||
|
|
9cc4fa5c97 |
@@ -0,0 +1,87 @@
|
|||||||
|
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
|
||||||
|
NAME="$(basename "$f")"
|
||||||
|
# URL-encode the asset name so '+' (in 'ismis-helper-1.4.0-an+fx.xpi')
|
||||||
|
# survives the query string instead of being decoded to a space.
|
||||||
|
ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$NAME")
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-F "attachment=@${f}" \
|
||||||
|
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ENC}"
|
||||||
|
done
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
|
docs/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -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,151 @@
|
|||||||
|
# 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.
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
+169
@@ -0,0 +1,169 @@
|
|||||||
|
'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 }))
|
||||||
|
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 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)
|
||||||
|
.replace(/\b1ST\b/g, '1st')
|
||||||
|
.replace(/\b2ND\b/g, '2nd')
|
||||||
|
.replace(/\b3RD\b/g, '3rd')
|
||||||
|
.replace(/\b(\d+)TH\b/g, '$1th')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^./, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePdf(data) {
|
||||||
|
const bytes = await ISMIStools.buildStudyLoadPdf(data);
|
||||||
|
const header = data.header || {};
|
||||||
|
const year = parseInt(data.year, 10);
|
||||||
|
const period = formatPeriod(data.period || header.semester || '');
|
||||||
|
const compactProgram = ISMIStools.compactProgram(header.program || '');
|
||||||
|
const schoolYear = Number.isFinite(year) ? `${year}-${year + 1}` : '';
|
||||||
|
const parts = [
|
||||||
|
header.studentId || (header.fullName ? header.fullName.replace(/\s+/g, ' ') : ''),
|
||||||
|
compactProgram,
|
||||||
|
schoolYear ? 'S.Y. ' + schoolYear : '',
|
||||||
|
period,
|
||||||
|
'Study Load'
|
||||||
|
].filter(Boolean);
|
||||||
|
const filename = parts.join(' - ') + '.pdf';
|
||||||
|
const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf';
|
||||||
|
await downloadBytes(bytes, 'application/pdf', filename, fallback);
|
||||||
|
}
|
||||||
+458
@@ -0,0 +1,458 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Cross-browser API namespace (see background.js). No-op in Firefox.
|
||||||
|
if (typeof browser === 'undefined' || browser == null) {
|
||||||
|
var browser = (typeof chrome !== 'undefined' ? chrome : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASTER_KEY = 'enabled';
|
||||||
|
|
||||||
|
async function isEnabled() {
|
||||||
|
const { enabled } = await browser.storage.local.get(MASTER_KEY);
|
||||||
|
return enabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoAcceptPrivacy() {
|
||||||
|
const modal = document.getElementById('myModal');
|
||||||
|
if (!modal) return;
|
||||||
|
const checkbox = document.getElementById('Confirmation');
|
||||||
|
const submit = document.getElementById('Submit');
|
||||||
|
if (checkbox && !checkbox.checked) checkbox.click();
|
||||||
|
if (submit) submit.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchPrivacyModal() {
|
||||||
|
autoAcceptPrivacy();
|
||||||
|
const observer = new MutationObserver(() => autoAcceptPrivacy());
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
setTimeout(() => observer.disconnect(), 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoLogin() {
|
||||||
|
const { profiles } = await browser.storage.local.get('profiles');
|
||||||
|
const active = (profiles || []).find((p) => p.enabled);
|
||||||
|
if (!active) return;
|
||||||
|
const userField = document.querySelector(
|
||||||
|
'input[name="Username"], input[id="Username"], input[name="UserName"], input[name="username"], input[id="UserName"], input[id="username"]'
|
||||||
|
);
|
||||||
|
const passField = document.querySelector('input[type="password"]');
|
||||||
|
if (!userField || !passField) return;
|
||||||
|
userField.value = active.username;
|
||||||
|
// 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;
|
||||||
|
userField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
passField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
const form = userField.closest('form') || passField.closest('form');
|
||||||
|
if (!form) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
const submitBtn = form.querySelector('input[type="submit"], button[type="submit"]');
|
||||||
|
if (submitBtn) submitBtn.click();
|
||||||
|
else form.submit();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCourseDescription(courseCode, academicPeriod, academicYear, token) {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set('Courses', courseCode);
|
||||||
|
body.set('AcademicPeriod', academicPeriod);
|
||||||
|
body.set('AcademicYear', academicYear);
|
||||||
|
body.set('AjaxFormVM.Controller', 'CourseSchedule');
|
||||||
|
body.set('AjaxFormVM.Action', 'CourseScheduleOffered');
|
||||||
|
body.set('AjaxFormVM.ResultTargetId', 'CourseScheduleOfferedList');
|
||||||
|
body.set('AjaxFormVM.LinkToReturn', '');
|
||||||
|
body.set('AjaxFormVM.LinkToReturnTargetId', '');
|
||||||
|
body.set('AjaxFormVM.FormHeader', 'Choose term to view courses offered');
|
||||||
|
body.set('AjaxFormVM.FormHeaderIcon', '');
|
||||||
|
body.set('AjaxFormVM.FormId', 'CourseScheduleCourseScheduleOffered');
|
||||||
|
body.set('AjaxFormVM.ShowCloseButton', 'True');
|
||||||
|
body.set('AjaxFormVM.PreventAutoUpdateTarget', 'True');
|
||||||
|
body.set('AjaxFormVM.CloseModalAfterSubmit', 'False');
|
||||||
|
body.set('AjaxFormVM.AjaxFormType', 'Search');
|
||||||
|
body.set('AjaxFormVM.FormNoteLocation', '');
|
||||||
|
body.set('AjaxFormVM.SubmitButton.FaIcon', '');
|
||||||
|
body.set('AjaxFormVM.SubmitButton.ButtonText', '');
|
||||||
|
body.set('AjaxFormVM.SubmitButton.ButtonSize', '');
|
||||||
|
body.set('__RequestVerificationToken', token);
|
||||||
|
const resp = await fetch('/CourseSchedule/CourseScheduleOffered?Length=14', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Offered courses request failed: ' + resp.status);
|
||||||
|
const html = await resp.text();
|
||||||
|
const courses = ISMIStools.parseOfferedCourses(html);
|
||||||
|
const match = courses.find((c) => c.courseCode === courseCode);
|
||||||
|
return match ? match.description : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTotalUnits(text) {
|
||||||
|
const m = /Total Units:\s*([\d.]+)/i.exec(text);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeStudyLoad(onProgress) {
|
||||||
|
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.');
|
||||||
|
|
||||||
|
const periodEl = document.getElementById('AcademicPeriod');
|
||||||
|
const yearEl = document.getElementById('AcademicYear');
|
||||||
|
const tokenEl = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||||
|
const academicPeriod = periodEl ? periodEl.value : '';
|
||||||
|
const academicYear = yearEl ? yearEl.value : '';
|
||||||
|
const token = tokenEl ? tokenEl.value : '';
|
||||||
|
|
||||||
|
// Semester/year come from the search dropdowns the user picked.
|
||||||
|
// AcademicPeriod's selected option label (e.g. "1ST Semester") + year.
|
||||||
|
const periodLabel = (periodEl && periodEl.selectedOptions && periodEl.selectedOptions[0])
|
||||||
|
? periodEl.selectedOptions[0].textContent.trim()
|
||||||
|
: '';
|
||||||
|
const selectedPeriod = periodLabel || academicPeriod;
|
||||||
|
const selectedYear = academicYear;
|
||||||
|
|
||||||
|
const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))];
|
||||||
|
const descMap = {};
|
||||||
|
if (onProgress && uniqueCodes.length) onProgress('Fetching course descriptions (0/' + uniqueCodes.length + ')', 10);
|
||||||
|
for (let i = 0; i < uniqueCodes.length; i++) {
|
||||||
|
const code = uniqueCodes[i];
|
||||||
|
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);
|
||||||
|
const html = await resp.text();
|
||||||
|
header = ISMIStools.parseProfilePage(html);
|
||||||
|
} catch (e) {
|
||||||
|
header = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semester goes into the header so the PDF shows the dropdown's term/year.
|
||||||
|
header.semester = (selectedPeriod && selectedYear)
|
||||||
|
? selectedPeriod + ' ' + selectedYear
|
||||||
|
: (header.semester || '');
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
function showToast(msg, isError) {
|
||||||
|
if (window.toastr) {
|
||||||
|
if (isError) window.toastr.error(msg);
|
||||||
|
else window.toastr.success(msg);
|
||||||
|
} else if (isError) {
|
||||||
|
alert(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTotalUnitsP() {
|
||||||
|
const ps = document.querySelectorAll('#StudyLoad p');
|
||||||
|
for (const p of ps) {
|
||||||
|
if (/Total Units/i.test(p.textContent || '')) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDownloadPdf() {
|
||||||
|
const btn = document.getElementById(BTN_ID);
|
||||||
|
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 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 }
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
if (!unitsP) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.style.display = 'flex';
|
||||||
|
wrapper.style.alignItems = 'center';
|
||||||
|
wrapper.style.justifyContent = 'space-between';
|
||||||
|
wrapper.style.margin = '10px 0';
|
||||||
|
wrapper.style.flexWrap = 'wrap';
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = BTN_ID;
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn green';
|
||||||
|
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
|
||||||
|
btn.addEventListener('click', onDownloadPdf);
|
||||||
|
|
||||||
|
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(unitsP);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryInjectButton() {
|
||||||
|
if (document.getElementById(BTN_ID)) return;
|
||||||
|
if (document.querySelector('#StudyLoad table.table-forum')) injectDownloadButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchStudyLoad() {
|
||||||
|
tryInjectButton();
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
const inStudyLoad = mutations.some(
|
||||||
|
(m) => m.target && m.target.closest && m.target.closest('#StudyLoad')
|
||||||
|
);
|
||||||
|
if (inStudyLoad) tryInjectButton();
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
if (!(await isEnabled())) return;
|
||||||
|
watchPrivacyModal();
|
||||||
|
if (/\/Account\/Login/i.test(location.href)) autoLogin();
|
||||||
|
if (document.getElementById('StudyLoad')) watchStudyLoad();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,129 +0,0 @@
|
|||||||
# ISMIS Helper — Firefox Extension Design
|
|
||||||
|
|
||||||
**Date:** 2026-08-18
|
|
||||||
**Status:** Approved
|
|
||||||
**Target site:** `https://ismis.usc.edu.ph` (USC ISMIS student portal)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A personal Firefox extension (Manifest V3, no build step) that improves the USC ISMIS
|
|
||||||
student portal experience. It provides three features:
|
|
||||||
|
|
||||||
1. **Auto-login** — auto-fill and auto-submit the login form from a saved profile.
|
|
||||||
2. **Auto-accept Privacy Policy** — dismiss the recurring privacy-policy modal.
|
|
||||||
3. **Study Load PDF** — generate a PDF of the student's study load, replicating the
|
|
||||||
official ISMIS document, including course descriptions looked up from the
|
|
||||||
Offered Courses endpoint.
|
|
||||||
|
|
||||||
A single **master toggle** disables all extension behavior on the portal.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Single MV3 extension, plain JavaScript, no bundler.
|
|
||||||
|
|
||||||
```
|
|
||||||
ismis-helper/
|
|
||||||
├── manifest.json # MV3 manifest, permissions, content script, popup
|
|
||||||
├── background.js # service worker: message routing + PDF generation
|
|
||||||
├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, scraping
|
|
||||||
├── popup.html # popup UI
|
|
||||||
├── popup.js # popup logic
|
|
||||||
├── popup.css # popup styles
|
|
||||||
├── libs/pdf-lib.min.js # vendored pdf-lib library
|
|
||||||
└── icons/ # extension icons (16, 32, 48, 128)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Permissions (manifest.json)
|
|
||||||
- `storage` — persist profiles and the master toggle.
|
|
||||||
- `downloads` — save the generated Study Load PDF.
|
|
||||||
- `activeTab` — access the current tab for scraping.
|
|
||||||
- Content script matches `https://ismis.usc.edu.ph/*`.
|
|
||||||
|
|
||||||
## Component 1 — Auto-login
|
|
||||||
|
|
||||||
- Content script runs on `https://ismis.usc.edu.ph/Account/Login`.
|
|
||||||
- Reads the active (first enabled) profile from `browser.storage.local`.
|
|
||||||
- Locates the username and password input fields (by common `name`/`id` values such
|
|
||||||
as `UserName`, `Password`, `username`, `password`).
|
|
||||||
- Fills both fields, then submits the form (auto-submit).
|
|
||||||
- If no profile is enabled, or the master toggle is off, does nothing.
|
|
||||||
|
|
||||||
## Component 2 — Auto-accept Privacy Policy
|
|
||||||
|
|
||||||
- On any portal page, if the privacy modal `#myModal` is present and visible:
|
|
||||||
- Check the `#Confirmation` checkbox ("I have read and understood the Privacy
|
|
||||||
Policy Statement").
|
|
||||||
- Click the `#Submit` button (Continue), which is disabled until the checkbox is
|
|
||||||
checked.
|
|
||||||
- Gated by the master toggle.
|
|
||||||
|
|
||||||
## Component 3 — Profiles
|
|
||||||
|
|
||||||
- Stored in `browser.storage.local` under key `profiles` as an array of:
|
|
||||||
`{ id, name, username, password, enabled }`.
|
|
||||||
- Popup provides add, edit, delete, and enable/disable per profile.
|
|
||||||
- The first profile with `enabled === true` is used for auto-login.
|
|
||||||
- Master toggle stored under key `enabled` (boolean).
|
|
||||||
|
|
||||||
## Component 4 — Study Load PDF
|
|
||||||
|
|
||||||
### Scrape
|
|
||||||
- On the Study Load page, parse the course table with columns:
|
|
||||||
**Group #, Course Code, Schedule, Faculty, Units, Status**.
|
|
||||||
- Read the total-units footer (e.g. `12.00 Units / Total Units: 18.00`).
|
|
||||||
- **Header info** (full name, student ID, program, year level, semester) is scraped
|
|
||||||
from the **My Profile** page (`/Manage/Student`), which exposes: Full Name,
|
|
||||||
Student ID, Program, Year Level, and current semester. If the profile page is not
|
|
||||||
available, the header fields are entered manually in the popup before generating
|
|
||||||
the PDF.
|
|
||||||
|
|
||||||
### Description lookup
|
|
||||||
- For each unique course code, POST to
|
|
||||||
`https://ismis.usc.edu.ph/CourseSchedule/CourseScheduleOffered` with form data:
|
|
||||||
- `Courses=<course code>`
|
|
||||||
- `AcademicPeriod=<selected value>`
|
|
||||||
- `AcademicYear=<selected value>`
|
|
||||||
- `__RequestVerificationToken=<token from page>`
|
|
||||||
- plus the `AjaxFormVM.*` fields observed in the HAR capture.
|
|
||||||
- Parse the returned HTML table to extract the **Course Description** for the course.
|
|
||||||
- If lookup fails for a course, leave the description blank and continue.
|
|
||||||
|
|
||||||
### Manual fallback
|
|
||||||
- If scraping finds no table, the popup shows a manual entry form for the same fields
|
|
||||||
(including optional course descriptions).
|
|
||||||
|
|
||||||
### PDF generation
|
|
||||||
- `background.js` uses **pdf-lib** to build the PDF replicating the official layout:
|
|
||||||
- Header block: semester, "STUDENT'S STUDY LOAD", date range, date enrolled,
|
|
||||||
student ID, name, program, year level.
|
|
||||||
- Course table: Course, Course Description, Units, Teacher, Group, Note
|
|
||||||
(status: REGULAR/PARALLEL/DISSOLVED), Schedule.
|
|
||||||
- Footer: total units, printed-by line.
|
|
||||||
- Downloads the PDF via the `downloads` API.
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
```
|
|
||||||
popup ──message──▶ background ──message──▶ content (scrape + description lookup)
|
|
||||||
▲ │
|
|
||||||
└────────────── data back ◀────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
pdf-lib → download
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- Master toggle off → no extension behavior on the portal.
|
|
||||||
- No enabled profile → no auto-login.
|
|
||||||
- Description lookup fails for a course → blank description, PDF still generated.
|
|
||||||
- Scrape fails entirely → manual entry form in the popup.
|
|
||||||
- Login fields not found → log a warning, no crash.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Load via `about:debugging` → "Load Temporary Add-on".
|
|
||||||
- Verify auto-login on the login page.
|
|
||||||
- Verify privacy modal auto-accept.
|
|
||||||
- Verify profile add/edit/delete/toggle and master toggle.
|
|
||||||
- Verify Study Load PDF generation: scrape, description lookup, manual fallback.
|
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
(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 };
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
(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
@@ -0,0 +1,229 @@
|
|||||||
|
(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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 };
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 663 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+16
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "ISMIS Helper",
|
||||||
|
"version": "1.4.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"]
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://ismis.usc.edu.ph/*"],
|
||||||
|
"js": ["parsers.js", "content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "ISMIS Helper",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"32": "icons/icon32.png",
|
||||||
|
"48": "icons/icon48.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"32": "icons/icon32.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "ismis-helper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Firefox extension for the USC ISMIS portal",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test",
|
||||||
|
"build": "node scripts/build.js all",
|
||||||
|
"sign": "web-ext sign --source-dir dist/firefox --channel unlisted --artifacts-dir dist --api-key \"$AMO_JWT_ISSUER\" --api-secret \"$AMO_JWT_SECRET\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"web-ext": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) {
|
||||||
|
module.exports = factory();
|
||||||
|
} else {
|
||||||
|
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
|
||||||
|
}
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function cleanText(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRows(html) {
|
||||||
|
const rows = [];
|
||||||
|
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||||
|
let trMatch;
|
||||||
|
while ((trMatch = trRe.exec(html)) !== null) {
|
||||||
|
const rowHtml = trMatch[1];
|
||||||
|
if (/<th/i.test(rowHtml)) continue;
|
||||||
|
const tds = [];
|
||||||
|
const tdRe = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
||||||
|
let tdMatch;
|
||||||
|
while ((tdMatch = tdRe.exec(rowHtml)) !== null) {
|
||||||
|
tds.push(cleanText(tdMatch[1]));
|
||||||
|
}
|
||||||
|
rows.push(tds);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStudyLoadTable(html) {
|
||||||
|
return extractRows(html)
|
||||||
|
.filter((tds) => tds.length >= 6)
|
||||||
|
.map((tds) => ({
|
||||||
|
group: tds[0],
|
||||||
|
courseCode: tds[1],
|
||||||
|
schedule: tds[2],
|
||||||
|
faculty: tds[3],
|
||||||
|
units: tds[4],
|
||||||
|
status: tds[5]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProfilePage(html) {
|
||||||
|
const result = {};
|
||||||
|
const h3Match = /<h3[^>]*>([\s\S]*?)<\/h3>/i.exec(html);
|
||||||
|
const bMatch = (h3Match && /<b>([\s\S]*?)<\/b>/i.exec(h3Match[1])) ||
|
||||||
|
/<b>([\s\S]*?)<\/b>/i.exec(html);
|
||||||
|
if (bMatch) result.program = cleanText(bMatch[1]);
|
||||||
|
|
||||||
|
const smallMatch = /<small>([\s\S]*?)<\/small>/i.exec(html);
|
||||||
|
if (smallMatch) {
|
||||||
|
const lines = smallMatch[1].split(/<br\s*\/?>/i).map(cleanText).filter(Boolean);
|
||||||
|
result.semester = lines[lines.length - 1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const kvRe = /<td[^>]*>([\s\S]*?)<\/td>\s*<td[^>]*class=["'][^"']*\bbold\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = kvRe.exec(html)) !== null) {
|
||||||
|
const key = cleanText(m[1]);
|
||||||
|
const val = cleanText(m[2]);
|
||||||
|
if (/full name/i.test(key)) result.fullName = val;
|
||||||
|
else if (/student id/i.test(key)) result.studentId = val;
|
||||||
|
else if (/year level/i.test(key)) result.yearLevel = val;
|
||||||
|
else if (/student type/i.test(key)) result.studentType = val;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOfferedCourses(html) {
|
||||||
|
return extractRows(html)
|
||||||
|
.filter((tds) => tds.length >= 2)
|
||||||
|
.map((tds) => ({
|
||||||
|
courseCode: tds[0].replace(/\s*-\s*Group\s*\d+$/i, '').trim(),
|
||||||
|
description: tds[1]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumUnits(courses) {
|
||||||
|
return (courses || [])
|
||||||
|
.reduce((sum, c) => sum + (parseFloat(c.units) || 0), 0)
|
||||||
|
.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parseStudyLoadTable, parseProfilePage, parseOfferedCourses,
|
||||||
|
filterStudyLoadCourses, sumUnits,
|
||||||
|
parseGradeHistory, yearLevelForSemester,
|
||||||
|
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
|
||||||
|
computeYearStandingFromUnits
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) {
|
||||||
|
module.exports = factory();
|
||||||
|
} else {
|
||||||
|
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
|
||||||
|
}
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function getPDFLib() {
|
||||||
|
return (typeof self !== 'undefined' && self.PDFLib) ||
|
||||||
|
(typeof globalThis !== 'undefined' && globalThis.PDFLib);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapText(text, font, size, maxWidth) {
|
||||||
|
const words = String(text).split(/\s+/).filter(Boolean);
|
||||||
|
const lines = [];
|
||||||
|
let line = '';
|
||||||
|
for (const word of words) {
|
||||||
|
const candidate = line ? line + ' ' + word : word;
|
||||||
|
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
|
||||||
|
line = candidate;
|
||||||
|
} else {
|
||||||
|
if (line) lines.push(line);
|
||||||
|
line = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) lines.push(line);
|
||||||
|
return lines.length ? lines : [''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the raw schedule string to match the official PDF style:
|
||||||
|
// comma-separate day codes, trim duplicated room suffixes, and split
|
||||||
|
// multiple meeting segments onto separate lines.
|
||||||
|
function formatSchedule(schedule) {
|
||||||
|
let s = String(schedule || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!s) return s;
|
||||||
|
// Collapse duplicated TC anywhere in the string.
|
||||||
|
while (/TC\s+TC/.test(s)) s = s.replace(/TC\s+TC/g, 'TC');
|
||||||
|
// Join adjacent day codes with commas.
|
||||||
|
s = s.replace(/\b(M|Tu|T|W|Th|F|Sa|Su)\s+(M|Tu|T|W|Th|F|Sa|Su)\b/g, '$1,$2');
|
||||||
|
// Split concatenated meeting segments (room ending in TC followed by a
|
||||||
|
// day code) onto separate lines.
|
||||||
|
s = s.replace(/(TC)\s+(?=(?:Su|Sa|Th|Tu|M|W|F|T)\b)/g, '$1\n');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reformat a full name to "Lastname, Firstname Middle" when the source gives
|
||||||
|
// it as "Firstname Middle Lastname" (no comma). If a comma is already
|
||||||
|
// present the name is returned unchanged.
|
||||||
|
function formatFullName(name) {
|
||||||
|
const s = String(name || '').trim();
|
||||||
|
if (!s || /,/.test(s)) return s;
|
||||||
|
const parts = s.split(/\s+/);
|
||||||
|
if (parts.length < 2) return s;
|
||||||
|
const last = parts.pop();
|
||||||
|
return last + ', ' + parts.join(' ');
|
||||||
|
}
|
||||||
|
// Format a faculty/teacher name to match the official PDF's style:
|
||||||
|
// "First [MiddleInitial] Last" (no comma). Handles a source like
|
||||||
|
// "Surname, First Middle" by dropping full middle names while keeping
|
||||||
|
// honorifics/titles (e.g. FR.) and single-letter middle initials.
|
||||||
|
const HONORIFICS = new Set(['FR', 'REV', 'DR', 'SR', 'BR', 'PROF', 'ENGR', 'ATTY', 'HON', 'MR', 'MS', 'MRS', 'BRO', 'SIS']);
|
||||||
|
function formatTeacher(name) {
|
||||||
|
let v = String(name || '').trim();
|
||||||
|
if (!v) return v;
|
||||||
|
let surname = '', given = [];
|
||||||
|
if (/,/.test(v)) {
|
||||||
|
const idx = v.lastIndexOf(',');
|
||||||
|
surname = v.slice(0, idx).trim();
|
||||||
|
given = v.slice(idx + 1).trim().split(/\s+/).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
const tok = v.split(/\s+/).filter(Boolean);
|
||||||
|
if (tok.length === 0) return v;
|
||||||
|
if (tok.length === 1) return v;
|
||||||
|
given = tok.slice(0, -1);
|
||||||
|
surname = tok[tok.length - 1];
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
let seenFirstName = false;
|
||||||
|
for (const tok of given) {
|
||||||
|
const bare = tok.replace(/\.$/, '').toUpperCase();
|
||||||
|
const isTitle = HONORIFICS.has(bare);
|
||||||
|
const isInitial = tok.length === 1 || (bare.length === 1 && /\.$/.test(tok));
|
||||||
|
if (isTitle || isInitial) {
|
||||||
|
out.push(tok);
|
||||||
|
} else if (!seenFirstName) {
|
||||||
|
out.push(tok);
|
||||||
|
seenFirstName = true;
|
||||||
|
}
|
||||||
|
// full middle names (after the first name) are dropped
|
||||||
|
}
|
||||||
|
if (surname) out.push(surname);
|
||||||
|
return out.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapLines(text, font, size, maxWidth) {
|
||||||
|
const out = [];
|
||||||
|
String(text == null ? '' : text).split(/\n/).forEach((seg) => {
|
||||||
|
out.push(...wrapText(seg, font, size, maxWidth));
|
||||||
|
});
|
||||||
|
return out.length ? out : [''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate a string to the longest prefix that fits within maxWidth at the
|
||||||
|
// given size, hard-cutting mid-word with no ellipsis. Mirrors the official
|
||||||
|
// PDF which cuts off course descriptions that exceed the column width.
|
||||||
|
function truncateToWidth(text, font, size, maxWidth) {
|
||||||
|
const s = String(text == null ? '' : text).trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (font.widthOfTextAtSize(s, size) <= maxWidth) return s;
|
||||||
|
let lo = 0, hi = s.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = Math.ceil((lo + hi) / 2);
|
||||||
|
if (font.widthOfTextAtSize(s.slice(0, mid), size) <= maxWidth) lo = mid;
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return s.slice(0, lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact a degree name for display:
|
||||||
|
// "BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY" -> "BS IT".
|
||||||
|
// Multi-word fields become initials; single-word fields stay title-cased.
|
||||||
|
function compactProgram(program) {
|
||||||
|
const s = String(program || '').trim().toUpperCase();
|
||||||
|
let m = /^BACHELOR OF SCIENCE IN (.+)$/.exec(s);
|
||||||
|
let prefix = 'BS';
|
||||||
|
if (!m) {
|
||||||
|
m = /^BACHELOR OF ARTS IN (.+)$/.exec(s);
|
||||||
|
if (m) prefix = 'AB';
|
||||||
|
}
|
||||||
|
if (!m) return String(program || '').trim();
|
||||||
|
const field = m[1];
|
||||||
|
const words = field.split(/\s+/).filter((w) => !/^(AND|OF|THE|IN)$/.test(w));
|
||||||
|
if (words.length <= 1) {
|
||||||
|
const single = words[0] || field;
|
||||||
|
return prefix + ' ' + single.charAt(0).toUpperCase() + single.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
return prefix + ' ' + words.map((w) => w[0]).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a semester to the official date range label.
|
||||||
|
function deriveDateRange(semester) {
|
||||||
|
const s = String(semester || '').toUpperCase();
|
||||||
|
if (/1ST|FIRST/.test(s)) return 'AUGUST - JANUARY';
|
||||||
|
if (/2ND|SECOND/.test(s)) return 'JANUARY - MAY';
|
||||||
|
if (/SUMMER/.test(s)) return 'JUNE - JULY';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a bundled asset in the extension background page. Returns null in
|
||||||
|
// Node (tests) or if the asset is unavailable.
|
||||||
|
async function loadAssetBytes(asset) {
|
||||||
|
const ext = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL);
|
||||||
|
try {
|
||||||
|
const url = ext ? browser.runtime.getURL(asset) : null;
|
||||||
|
if (!url) return null;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return new Uint8Array(await resp.arrayBuffer());
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed the bundled Segoe UI fonts (regular/bold/italic). Fontkit is
|
||||||
|
// required by pdf-lib to embed custom TTF fonts. Falls back to Helvetica
|
||||||
|
// (standard fonts) when the assets are unavailable (e.g. Node tests).
|
||||||
|
async function loadFonts(doc, PDFLib) {
|
||||||
|
const { StandardFonts } = PDFLib;
|
||||||
|
const fontkit = (typeof self !== 'undefined' && self.fontkit) ||
|
||||||
|
(typeof globalThis !== 'undefined' && globalThis.fontkit) ||
|
||||||
|
null;
|
||||||
|
const segoe = {
|
||||||
|
regular: 'assets/fonts/Segoe-UI.ttf',
|
||||||
|
bold: 'assets/fonts/Segoe-UI-Bold.ttf',
|
||||||
|
italic: 'assets/fonts/Segoe-UI-Italic.ttf'
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (fontkit) {
|
||||||
|
doc.registerFontkit(fontkit);
|
||||||
|
const regular = await loadAssetBytes(segoe.regular);
|
||||||
|
const bold = await loadAssetBytes(segoe.bold);
|
||||||
|
const italic = await loadAssetBytes(segoe.italic);
|
||||||
|
if (regular && bold && italic) {
|
||||||
|
return {
|
||||||
|
font: await doc.embedFont(regular),
|
||||||
|
bold: await doc.embedFont(bold),
|
||||||
|
italic: await doc.embedFont(italic),
|
||||||
|
isCustom: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* fall through to Helvetica */ }
|
||||||
|
return {
|
||||||
|
font: doc.embedStandardFont(StandardFonts.Helvetica),
|
||||||
|
bold: doc.embedStandardFont(StandardFonts.HelveticaBold),
|
||||||
|
italic: doc.embedStandardFont(StandardFonts.HelveticaOblique),
|
||||||
|
isCustom: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the bundled header logo. Prefer the transparent PNG (matches the
|
||||||
|
// official PDF, which masks the JPEG's baked-in background) for a clean
|
||||||
|
// look on white; fall back to the JPEG in Node (tests) or if unavailable.
|
||||||
|
async function loadLogo() {
|
||||||
|
const ext = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL);
|
||||||
|
const candidates = ['assets/logo.png', 'assets/logo.jpeg'];
|
||||||
|
for (const asset of candidates) {
|
||||||
|
try {
|
||||||
|
const url = ext ? browser.runtime.getURL(asset) : null;
|
||||||
|
if (!url) return null;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
return { bytes: new Uint8Array(await resp.arrayBuffer()), transparent: /logo\.png$/.test(asset) };
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a Letter-size PDF replicating the official ISMIS Study Load layout:
|
||||||
|
// header logo, title, semester + date range, date enrolled, a student info
|
||||||
|
// row (ID, name, program, Year Level), the course table (GROUP, COURSE,
|
||||||
|
// COURSE DESCRIPTION, TEACHER, UNITS, NOTE), and a footer (total under the
|
||||||
|
// UNITS column, printed-by). NOTE is the status; schedule is stacked under
|
||||||
|
// the course description.
|
||||||
|
async function buildStudyLoadPdf(data) {
|
||||||
|
const PDFLib = getPDFLib();
|
||||||
|
const { PDFDocument, StandardFonts, rgb } = PDFLib;
|
||||||
|
const header = data.header || {};
|
||||||
|
const courses = data.courses || [];
|
||||||
|
// Truncate long course descriptions to a single line matching the official
|
||||||
|
// PDF (default ON). Disabling allows descriptions to wrap.
|
||||||
|
const truncateDescription = data.truncateDescription !== false;
|
||||||
|
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
let page = doc.addPage([612, 792]); // Letter
|
||||||
|
const fonts = await loadFonts(doc, PDFLib);
|
||||||
|
const { font, bold, italic } = fonts;
|
||||||
|
const margin = 36;
|
||||||
|
const contentWidth = 539.3; // matches reference right edge 575.3
|
||||||
|
let y = 792 - 10; // near the very top, matching the reference logo position
|
||||||
|
|
||||||
|
const black = rgb(0, 0, 0);
|
||||||
|
const gray = rgb(0.3, 0.3, 0.3);
|
||||||
|
|
||||||
|
const logo = await loadLogo();
|
||||||
|
let logoImg = null;
|
||||||
|
if (logo) {
|
||||||
|
try {
|
||||||
|
// Transparent PNG embeds with an alpha mask (matches official PDF);
|
||||||
|
// the JPEG is embedded as-is (no mask).
|
||||||
|
logoImg = logo.transparent
|
||||||
|
? await doc.embedPng(logo.bytes)
|
||||||
|
: await doc.embedJpg(logo.bytes);
|
||||||
|
} catch (e) { logoImg = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function center(text, size, f, dy) {
|
||||||
|
const w = f.widthOfTextAtSize(text, size);
|
||||||
|
page.drawText(text, { x: (612 - w) / 2, y, size, font: f, color: black });
|
||||||
|
y -= dy;
|
||||||
|
}
|
||||||
|
function left(text, size, f, x, dy) {
|
||||||
|
page.drawText(text, { x, y, size, font: f, color: black });
|
||||||
|
y -= dy;
|
||||||
|
}
|
||||||
|
function hline(x1, x2, atY) {
|
||||||
|
page.drawRectangle({ x: x1, y: atY, width: x2 - x1, height: 0.75, color: black });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Header ----
|
||||||
|
if (logoImg) {
|
||||||
|
const logoW = 292.5;
|
||||||
|
const logoH = logoW * (logoImg.height / logoImg.width);
|
||||||
|
page.drawImage(logoImg, { x: (612 - logoW) / 2, y: y - logoH, width: logoW, height: logoH });
|
||||||
|
y -= logoH + 8.79;
|
||||||
|
}
|
||||||
|
// Baseline advances match the reference (Segoe UI) header layout.
|
||||||
|
center("STUDENT'S STUDY LOAD", 9.5, bold, 13.84);
|
||||||
|
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.76);
|
||||||
|
const dateRange = header.dateRange || deriveDateRange(header.semester);
|
||||||
|
if (dateRange) center(dateRange, 9, font, 11.75);
|
||||||
|
// Always reserve the Date Enrolled line's vertical space (so the layout
|
||||||
|
// stays aligned with the reference) even though we never have the value.
|
||||||
|
if (header.dateEnrolled) {
|
||||||
|
left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 14.53);
|
||||||
|
} else {
|
||||||
|
y -= 14.53; // keep the same gap, drawing nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Student info row, laid out in fixed zones across the page (matching the
|
||||||
|
// reference): ID (bold) left, name at ~100, program at ~338, and
|
||||||
|
// "Year Level: N" right-aligned to the right content edge (575.3).
|
||||||
|
// Year Level is shown by default; the extension can hide it or override the
|
||||||
|
// value via settings (header.showYearLevel / header.yearLevel).
|
||||||
|
const yearLevelText = (header.showYearLevel !== false && header.yearLevel)
|
||||||
|
? 'Year Level: ' + header.yearLevel
|
||||||
|
: '';
|
||||||
|
if (header.studentId) page.drawText(header.studentId, { x: margin, y, size: 9, font: bold, color: black });
|
||||||
|
if (header.fullName) page.drawText(formatFullName(header.fullName), { x: 100, y, size: 9, font, color: black });
|
||||||
|
if (header.program) page.drawText(compactProgram(header.program), { x: 338, y, size: 9, font, color: black });
|
||||||
|
if (yearLevelText) {
|
||||||
|
const w = font.widthOfTextAtSize(yearLevelText, 9);
|
||||||
|
// Right-aligned to the content edge, nudged left ~6pt (half of 1rem).
|
||||||
|
page.drawText(yearLevelText, { x: margin + contentWidth - w - 6, y, size: 9, font, color: black });
|
||||||
|
}
|
||||||
|
if (header.studentId || header.fullName || header.program || yearLevelText) y -= 13.59;
|
||||||
|
|
||||||
|
// ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ----
|
||||||
|
// Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3).
|
||||||
|
const cols = [
|
||||||
|
{ key: 'group', label: 'GROUP', x: 36.0, w: 28.8, align: 'left' },
|
||||||
|
{ key: 'courseCode', label: 'COURSE', x: 64.8, w: 47.6, align: 'left' },
|
||||||
|
{ key: 'description', label: 'COURSE DESCRIPTION', x: 112.4, w: 201.4, align: 'left' },
|
||||||
|
{ key: 'teacher', label: 'TEACHER', x: 313.8, w: 127.6, align: 'left' },
|
||||||
|
{ key: 'units', label: 'UNITS', x: 441.4, w: 29.5, align: 'right' },
|
||||||
|
{ key: 'status', label: 'NOTE', x: 470.9, w: 104.4, align: 'right' }
|
||||||
|
];
|
||||||
|
const cellSize = 8;
|
||||||
|
const headerSize = 8;
|
||||||
|
const lineH = 12;
|
||||||
|
|
||||||
|
// Header label positions tuned for Segoe UI (matches the reference):
|
||||||
|
// left/center aligned within each column so labels never overlap.
|
||||||
|
function headerLabelXFor() {
|
||||||
|
const widths = {};
|
||||||
|
for (const c of cols) {
|
||||||
|
const key = c.label.toUpperCase().replace(/\s+/g, '_');
|
||||||
|
widths[key] = font.widthOfTextAtSize(c.label, headerSize);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
GROUP: cols[0].x + 1.3,
|
||||||
|
COURSE: cols[1].x + 9,
|
||||||
|
COURSE_DESCRIPTION: cols[2].x + (cols[2].w - widths.COURSE_DESCRIPTION) / 2,
|
||||||
|
TEACHER: cols[3].x + (cols[3].w - widths.TEACHER) / 2,
|
||||||
|
UNITS: cols[4].x + (cols[4].w - widths.UNITS) / 2,
|
||||||
|
NOTE: cols[5].x + (cols[5].w - widths.NOTE) / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowLines(cells) {
|
||||||
|
return cols.map((c) => {
|
||||||
|
let lines;
|
||||||
|
if (c.key === 'description') {
|
||||||
|
if (truncateDescription) {
|
||||||
|
// Single line, hard-truncated to fit the column (official PDF style).
|
||||||
|
lines = [truncateToWidth(cells.description, font, cellSize, c.w - 3)];
|
||||||
|
} else {
|
||||||
|
lines = wrapLines(cells.description, font, cellSize, c.w - 6);
|
||||||
|
}
|
||||||
|
const sched = String(cells.schedule || '');
|
||||||
|
if (sched) lines.push(...wrapLines(sched, font, cellSize, c.w - 6));
|
||||||
|
} else {
|
||||||
|
lines = wrapLines(cells[c.key], font, cellSize, c.w - 6);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRow(cells, isHeader) {
|
||||||
|
// Header labels and data all use the body font (header is not bold).
|
||||||
|
const f = font;
|
||||||
|
const size = isHeader ? headerSize : cellSize;
|
||||||
|
const linesPerCol = rowLines(cells);
|
||||||
|
|
||||||
|
if (isHeader) {
|
||||||
|
// Reference header row: only a top and bottom horizontal rule; no
|
||||||
|
// vertical dividers. Text baseline at y; top rule 1.8pt above the
|
||||||
|
// text bbox top; bottom rule 11.5pt below the text bbox top. For
|
||||||
|
// 8pt Helvetica the text bbox top sits ~8.6pt above the baseline,
|
||||||
|
// so the lines are offset by +9.6 (top) and -3.7 (bottom) relative
|
||||||
|
// to y. Data rows start 13pt below the header text bbox top.
|
||||||
|
const topY = y + 9.6;
|
||||||
|
const botY = y - 3.7;
|
||||||
|
if (botY < 50) {
|
||||||
|
page = doc.addPage([612, 792]);
|
||||||
|
y = 792 - 10;
|
||||||
|
return drawHeaderRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
hline(margin, margin + contentWidth, topY);
|
||||||
|
|
||||||
|
const labelXs = headerLabelXFor();
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
const c = cols[i];
|
||||||
|
const labelKey = c.label.toUpperCase().replace(/\s+/g, '_');
|
||||||
|
const lx = labelXs[labelKey];
|
||||||
|
const lines = linesPerCol[i];
|
||||||
|
page.drawText(lines[0], { x: lx, y, size, font: f, color: black });
|
||||||
|
}
|
||||||
|
|
||||||
|
hline(margin, margin + contentWidth, botY);
|
||||||
|
y -= 13.0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows: fixed 24.5pt baseline-to-baseline spacing. Description +
|
||||||
|
// schedule stack with 12pt line spacing. Expand the row height when
|
||||||
|
// the schedule or wrapped description needs more lines.
|
||||||
|
const maxLines = Math.max(...linesPerCol.map((l) => l.length));
|
||||||
|
const rowHeight = Math.max(24.5, maxLines * lineH + 0.5);
|
||||||
|
if (y - rowHeight < 50) {
|
||||||
|
page = doc.addPage([612, 792]);
|
||||||
|
y = 792 - 10;
|
||||||
|
drawHeaderRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
const c = cols[i];
|
||||||
|
const lines = linesPerCol[i];
|
||||||
|
let ty = y;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (c.key === 'units') {
|
||||||
|
// Center unit values under the UNITS header, matching the reference.
|
||||||
|
const w = f.widthOfTextAtSize(line, size);
|
||||||
|
page.drawText(line, { x: c.x + (c.w - w) / 2, y: ty, size, font: f, color: black });
|
||||||
|
} else if (c.key === 'status') {
|
||||||
|
page.drawText(line, { x: c.x + 35, y: ty, size, font: f, color: black });
|
||||||
|
} else {
|
||||||
|
let dx = 3;
|
||||||
|
if (c.key === 'group') dx = 12;
|
||||||
|
else if (c.key === 'courseCode') dx = 8;
|
||||||
|
else if (c.key === 'teacher') dx = 3.5;
|
||||||
|
else if (c.key === 'description') dx = 3.5;
|
||||||
|
page.drawText(line, { x: c.x + dx, y: ty, size, font: f, color: black });
|
||||||
|
}
|
||||||
|
ty -= lineH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
y -= rowHeight;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHeaderRow() {
|
||||||
|
const cells = {};
|
||||||
|
cols.forEach((c) => { cells[c.key] = c.label; });
|
||||||
|
drawRow(cells, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHeaderRow();
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
const cells = {
|
||||||
|
group: course.group,
|
||||||
|
courseCode: course.courseCode,
|
||||||
|
description: course.description,
|
||||||
|
schedule: formatSchedule(course.schedule),
|
||||||
|
teacher: formatTeacher(course.teacher || course.faculty),
|
||||||
|
units: course.units,
|
||||||
|
status: course.status
|
||||||
|
};
|
||||||
|
drawRow(cells, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Footer ----
|
||||||
|
if (y < 90) {
|
||||||
|
page = doc.addPage([612, 792]);
|
||||||
|
y = 792 - 10;
|
||||||
|
}
|
||||||
|
if (data.totalUnits) {
|
||||||
|
// Total sits one standard row height below the last course row (matching
|
||||||
|
// the reference, which has a ~24.5pt gap), centered within the UNITS col.
|
||||||
|
const unitsCol = cols[4];
|
||||||
|
const w = font.widthOfTextAtSize(data.totalUnits, 8);
|
||||||
|
// Short underline above the total, spanning the UNITS column.
|
||||||
|
hline(unitsCol.x, unitsCol.x + unitsCol.w, y + 9);
|
||||||
|
page.drawText(data.totalUnits, { x: unitsCol.x + (unitsCol.w - w) / 2, y, size: 8, font, color: black });
|
||||||
|
y -= 12;
|
||||||
|
}
|
||||||
|
hline(margin, margin + contentWidth, y);
|
||||||
|
y -= 10.1;
|
||||||
|
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
|
||||||
|
const pw = font.widthOfTextAtSize(printedBy, 8);
|
||||||
|
page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black });
|
||||||
|
|
||||||
|
return doc.save(); // returns Promise<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth };
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
width: 360px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #006699;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.top h1 { margin: 0; font-size: 16px; }
|
||||||
|
.master { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
|
||||||
|
.switch input { display: none; }
|
||||||
|
.slider {
|
||||||
|
position: absolute; cursor: pointer; inset: 0;
|
||||||
|
background: #ccc; border-radius: 22px; transition: 0.2s;
|
||||||
|
}
|
||||||
|
.slider::before {
|
||||||
|
content: ""; position: absolute; height: 18px; width: 18px;
|
||||||
|
left: 2px; top: 2px; background: #fff; border-radius: 50%; transition: 0.2s;
|
||||||
|
}
|
||||||
|
.switch input:checked + .slider { background: #4caf50; }
|
||||||
|
.switch input:checked + .slider::before { transform: translateX(18px); }
|
||||||
|
.tab { padding: 12px; }
|
||||||
|
.tab h2 { margin: 0 0 8px; font-size: 14px; }
|
||||||
|
.profile-list { list-style: none; margin: 0 0 8px; padding: 0; }
|
||||||
|
.profile-list li {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.profile-list .name { flex: 1; }
|
||||||
|
.profile-list .user { color: #777; font-size: 11px; }
|
||||||
|
.btn {
|
||||||
|
padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
background: #fff; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn.primary { background: #006699; color: #fff; border-color: #006699; }
|
||||||
|
.btn-row { display: flex; gap: 6px; margin-top: 6px; }
|
||||||
|
.form input {
|
||||||
|
width: 100%; padding: 6px; margin-bottom: 6px;
|
||||||
|
border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.form .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||||
|
.tab .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||||
|
.tab .row input[type="text"] {
|
||||||
|
flex: 1; min-width: 0; padding: 4px 6px;
|
||||||
|
border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.hint { font-size: 11px; color: #777; margin-top: 4px; }
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top">
|
||||||
|
<h1>ISMIS Helper</h1>
|
||||||
|
<div class="master">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="masterToggle">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span id="masterLabel">Enabled</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tab">
|
||||||
|
<h2>Profiles</h2>
|
||||||
|
<ul id="profileList" class="profile-list"></ul>
|
||||||
|
<button id="addProfile" class="btn">+ Add Profile</button>
|
||||||
|
<div id="profileForm" class="form" hidden>
|
||||||
|
<input id="pName" placeholder="Profile name">
|
||||||
|
<input id="pUsername" placeholder="Username">
|
||||||
|
<input id="pPassword" type="password" placeholder="Password">
|
||||||
|
<label class="row"><input type="checkbox" id="pEnabled"> Enabled</label>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="saveProfile" class="btn primary">Save</button>
|
||||||
|
<button id="cancelProfile" class="btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab">
|
||||||
|
<h2>PDF Settings</h2>
|
||||||
|
<label class="row"><input type="checkbox" id="truncateToggle"> Truncate long course descriptions (official PDF style)</label>
|
||||||
|
<label class="row"><input type="checkbox" id="showYearToggle"> Show Year Level on the PDF</label>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Cross-browser API namespace (see background.js). No-op in Firefox.
|
||||||
|
if (typeof browser === 'undefined' || browser == null) {
|
||||||
|
var browser = (typeof chrome !== 'undefined' ? chrome : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
|
||||||
|
const MASTER_KEY = 'enabled';
|
||||||
|
const PROFILES_KEY = 'profiles';
|
||||||
|
|
||||||
|
let editingProfileId = null;
|
||||||
|
|
||||||
|
// ---- Master toggle ----
|
||||||
|
async function loadMaster() {
|
||||||
|
const { enabled } = await browser.storage.local.get(MASTER_KEY);
|
||||||
|
$('#masterToggle').checked = enabled !== false;
|
||||||
|
$('#masterLabel').textContent = enabled === false ? 'Disabled' : 'Enabled';
|
||||||
|
}
|
||||||
|
$('#masterToggle').addEventListener('change', async (e) => {
|
||||||
|
await browser.storage.local.set({ [MASTER_KEY]: e.target.checked });
|
||||||
|
$('#masterLabel').textContent = e.target.checked ? 'Enabled' : 'Disabled';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- PDF settings: truncate long course descriptions (default ON) ----
|
||||||
|
async function loadPdfSettings() {
|
||||||
|
const s = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel', '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);
|
||||||
|
return profiles || [];
|
||||||
|
}
|
||||||
|
async function saveProfiles(profiles) {
|
||||||
|
await browser.storage.local.set({ [PROFILES_KEY]: profiles });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfiles(profiles) {
|
||||||
|
const list = $('#profileList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const p of profiles) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'name';
|
||||||
|
name.innerHTML = '<strong>' + escapeHtml(p.name) + '</strong><br><span class="user">' + escapeHtml(p.username) + '</span>';
|
||||||
|
const toggle = document.createElement('input');
|
||||||
|
toggle.type = 'checkbox';
|
||||||
|
toggle.checked = !!p.enabled;
|
||||||
|
toggle.title = 'Enable/disable';
|
||||||
|
toggle.addEventListener('change', async () => {
|
||||||
|
if (toggle.checked) {
|
||||||
|
profiles.forEach((x) => { x.enabled = x.id === p.id; });
|
||||||
|
} else {
|
||||||
|
p.enabled = false;
|
||||||
|
}
|
||||||
|
await saveProfiles(profiles);
|
||||||
|
renderProfiles(await getProfiles());
|
||||||
|
});
|
||||||
|
const edit = document.createElement('button');
|
||||||
|
edit.className = 'btn';
|
||||||
|
edit.textContent = 'Edit';
|
||||||
|
edit.addEventListener('click', () => openProfileForm(p));
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.className = 'btn';
|
||||||
|
del.textContent = 'Delete';
|
||||||
|
del.addEventListener('click', async () => {
|
||||||
|
await saveProfiles(profiles.filter((x) => x.id !== p.id));
|
||||||
|
renderProfiles(await getProfiles());
|
||||||
|
});
|
||||||
|
li.append(toggle, name, edit, del);
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
$('#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const profile = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
enabled,
|
||||||
|
passwordEnc
|
||||||
|
};
|
||||||
|
profiles.push(profile);
|
||||||
|
}
|
||||||
|
await saveProfiles(profiles);
|
||||||
|
$('#profileForm').hidden = true;
|
||||||
|
renderProfiles(profiles);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Init ----
|
||||||
|
(async function init() {
|
||||||
|
await loadMaster();
|
||||||
|
await loadPdfSettings();
|
||||||
|
renderProfiles(await getProfiles());
|
||||||
|
})();
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'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',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Directories never copied into a build target.
|
||||||
|
const EXCLUDED_DIRS = new Set([
|
||||||
|
'.git', 'node_modules', 'dist', 'docs', 'scripts', 'test', 'web-ext-artifacts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Repo-root files that are for contributors, not for the shipped extension.
|
||||||
|
// manifest.json is here because it is generated per target, not copied.
|
||||||
|
const DEV_ONLY_FILES = new Set([
|
||||||
|
'manifest.json', 'AGENTS.md', 'README.md', 'package.json', 'package-lock.json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Everything to copy from ROOT into a target, relative paths.
|
||||||
|
// `excludeDev` is true when reading ROOT (skip contributor files) and false when
|
||||||
|
// reading a built target (its generated manifest.json MUST be packaged).
|
||||||
|
function collectFiles(srcDir, { dir = '', excludeDev = false, out = [] } = {}) {
|
||||||
|
for (const entry of fs.readdirSync(srcDir)) {
|
||||||
|
const abs = path.join(srcDir, entry);
|
||||||
|
const rel = dir ? dir + '/' + entry : entry;
|
||||||
|
if (entry.startsWith('.') || EXCLUDED_DIRS.has(entry)) continue;
|
||||||
|
if (excludeDev && DEV_ONLY_FILES.has(entry)) continue;
|
||||||
|
if (fs.statSync(abs).isDirectory()) {
|
||||||
|
collectFiles(abs, { dir: rel, excludeDev, out });
|
||||||
|
} 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, { excludeDev: true });
|
||||||
|
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();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'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');
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { parseStudyLoadTable } = require('../parsers.js');
|
||||||
|
const { parseProfilePage } = require('../parsers.js');
|
||||||
|
const { parseOfferedCourses } = require('../parsers.js');
|
||||||
|
|
||||||
|
const fixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'study-load-table.html'), 'utf8');
|
||||||
|
const profileFixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'profile.html'), 'utf8');
|
||||||
|
const offeredFixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'offered-courses.html'), 'utf8');
|
||||||
|
|
||||||
|
test('parseStudyLoadTable extracts all course rows', () => {
|
||||||
|
const rows = parseStudyLoadTable(fixture);
|
||||||
|
assert.strictEqual(rows.length, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStudyLoadTable maps columns correctly', () => {
|
||||||
|
const rows = parseStudyLoadTable(fixture);
|
||||||
|
const first = rows[0];
|
||||||
|
assert.strictEqual(first.group, '1');
|
||||||
|
assert.strictEqual(first.courseCode, 'CS 5105');
|
||||||
|
assert.strictEqual(first.units, '3.00');
|
||||||
|
assert.strictEqual(first.status, 'DISSOLVED');
|
||||||
|
assert.ok(first.schedule.includes('F 01:30 PM'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStudyLoadTable handles empty faculty', () => {
|
||||||
|
const rows = parseStudyLoadTable(fixture);
|
||||||
|
const it4102 = rows.find(r => r.courseCode === 'IT 4102N');
|
||||||
|
assert.strictEqual(it4102.faculty, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseProfilePage extracts header fields', () => {
|
||||||
|
const p = parseProfilePage(profileFixture);
|
||||||
|
assert.strictEqual(p.fullName, 'ADRIAN ALFRED CHIONG BONPIN');
|
||||||
|
assert.strictEqual(p.studentId, '22103625');
|
||||||
|
assert.strictEqual(p.yearLevel, '4');
|
||||||
|
assert.strictEqual(p.studentType, 'CONTINUING');
|
||||||
|
assert.strictEqual(p.program, 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY');
|
||||||
|
assert.strictEqual(p.semester, '1ST Semester 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseProfilePage matches bold class token with single quotes and extra classes', () => {
|
||||||
|
const html = `
|
||||||
|
<h3><b>BACHELOR OF SCIENCE</b></h3>
|
||||||
|
<table>
|
||||||
|
<tr><td>Full Name</td><td class='bold text-right'>JOHN DOE</td></tr>
|
||||||
|
<tr><td>Student Type</td><td class="bold">CONTINUING</td></tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
const p = parseProfilePage(html);
|
||||||
|
assert.strictEqual(p.fullName, 'JOHN DOE');
|
||||||
|
assert.strictEqual(p.studentType, 'CONTINUING');
|
||||||
|
assert.strictEqual(p.program, 'BACHELOR OF SCIENCE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseProfilePage degrades gracefully on empty input', () => {
|
||||||
|
const p = parseProfilePage('');
|
||||||
|
assert.deepStrictEqual(p, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseOfferedCourses extracts course code and description', () => {
|
||||||
|
const courses = parseOfferedCourses(offeredFixture);
|
||||||
|
const it3206 = courses.find(c => c.courseCode === 'IT 3206');
|
||||||
|
assert.ok(it3206, 'should find IT 3206');
|
||||||
|
assert.strictEqual(it3206.description, 'INTEGRATIVE PROGRAMMING AND TECHNOLOGY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseOfferedCourses strips group suffix from code', () => {
|
||||||
|
const courses = parseOfferedCourses(offeredFixture);
|
||||||
|
assert.ok(courses.every(c => !/Group/.test(c.courseCode)));
|
||||||
|
const it3206n = courses.find(c => c.courseCode === 'IT 3206N');
|
||||||
|
assert.ok(it3206n, 'should find IT 3206N');
|
||||||
|
});
|
||||||
|
|
||||||
|
const { filterStudyLoadCourses, sumUnits } = require('../parsers.js');
|
||||||
|
|
||||||
|
test('filterStudyLoadCourses excludes DISSOLVED courses', () => {
|
||||||
|
const courses = [
|
||||||
|
{ courseCode: 'CS 5105', status: 'DISSOLVED', units: '3.00' },
|
||||||
|
{ courseCode: 'IT 3206', status: 'PARALLEL', units: '3.00' },
|
||||||
|
{ courseCode: 'IT 4102N', status: 'REGULAR', units: '3.00' }
|
||||||
|
];
|
||||||
|
const filtered = filterStudyLoadCourses(courses);
|
||||||
|
assert.deepStrictEqual(filtered.map((c) => c.courseCode), ['IT 3206', 'IT 4102N']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterStudyLoadCourses handles missing status', () => {
|
||||||
|
const filtered = filterStudyLoadCourses([{ courseCode: 'X', status: '', units: '1' }]);
|
||||||
|
assert.strictEqual(filtered.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sumUnits computes total from course units', () => {
|
||||||
|
const courses = [{ units: '3.00' }, { units: '3.00' }, { units: '2.00' }];
|
||||||
|
assert.strictEqual(sumUnits(courses), '8.00');
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
global.PDFLib = require('pdf-lib');
|
||||||
|
const { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth } = require('../pdf.js');
|
||||||
|
|
||||||
|
const sampleData = {
|
||||||
|
header: {
|
||||||
|
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
|
||||||
|
studentId: '22103625',
|
||||||
|
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
|
||||||
|
yearLevel: '4',
|
||||||
|
semester: '1ST Semester 2026',
|
||||||
|
dateEnrolled: '8/5/2024',
|
||||||
|
dateRange: 'AUGUST - JANUARY'
|
||||||
|
},
|
||||||
|
courses: [
|
||||||
|
{ group: '7', courseCode: 'CIS 2101', description: 'DATA STRUCTURES AND ALGORITHMS', units: '3.00', teacher: 'CHRISTIAN MADERAZO', schedule: 'M,W 03:00 PM - 05:30 PM LB443TC', status: 'REGULAR' },
|
||||||
|
{ group: '2', courseCode: 'IT 3103N', description: 'SYSTEMS INTEGRATION AND ARCHITECTURE', units: '3.00', teacher: 'KHENT DELA PAZ', schedule: 'T,Th 01:30 PM - 03:00 PM DCISMVR3TC', status: 'REGULAR' }
|
||||||
|
],
|
||||||
|
totalUnits: '6.00'
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractPdfText(bytes) {
|
||||||
|
const tmp = path.join(os.tmpdir(), 'study-load-test.pdf');
|
||||||
|
fs.writeFileSync(tmp, Buffer.from(bytes));
|
||||||
|
const script = [
|
||||||
|
'import fitz',
|
||||||
|
`doc = fitz.open('${tmp}')`,
|
||||||
|
'text = ""',
|
||||||
|
'for page in doc:',
|
||||||
|
' text += page.get_text()',
|
||||||
|
'print(text)'
|
||||||
|
].join('\n');
|
||||||
|
return execFileSync('python3', ['-c', script], { encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PyMuPDF (fitz) is only needed for the text-extraction assertions below. It is
|
||||||
|
// installed in CI, but if it is missing locally we skip those assertions rather
|
||||||
|
// than hard-failing.
|
||||||
|
function fitzAvailable() {
|
||||||
|
try {
|
||||||
|
execFileSync('python3', ['-c', 'import fitz'], { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildStudyLoadPdf returns valid PDF bytes', async (t) => {
|
||||||
|
const bytes = await buildStudyLoadPdf(sampleData);
|
||||||
|
assert.ok(bytes instanceof Uint8Array);
|
||||||
|
const head = Buffer.from(bytes.slice(0, 5)).toString('latin1');
|
||||||
|
assert.strictEqual(head, '%PDF-');
|
||||||
|
const doc = await PDFLib.PDFDocument.load(bytes);
|
||||||
|
assert.strictEqual(doc.getPageCount(), 1);
|
||||||
|
|
||||||
|
if (!fitzAvailable()) {
|
||||||
|
t.skip('PyMuPDF (fitz) not available; skipping PDF text assertions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = extractPdfText(bytes);
|
||||||
|
assert.ok(text.includes('CIS 2101'), 'PDF text should contain course code CIS 2101');
|
||||||
|
assert.ok(text.includes("STUDENT'S STUDY LOAD"), 'PDF text should contain the title');
|
||||||
|
assert.ok(text.includes('CHRISTIAN MADERAZO'), 'PDF text should contain the teacher');
|
||||||
|
assert.ok(text.includes('BS IT'), 'PDF text should contain the compacted program');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compactProgram shortens degree names', () => {
|
||||||
|
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY'), 'BS IT');
|
||||||
|
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN COMPUTER SCIENCE'), 'BS CS');
|
||||||
|
assert.strictEqual(compactProgram('BACHELOR OF ARTS IN POLITICAL SCIENCE'), 'AB PS');
|
||||||
|
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN ACCOUNTANCY'), 'BS Accountancy');
|
||||||
|
assert.strictEqual(compactProgram('UNKNOWN PROGRAM'), 'UNKNOWN PROGRAM');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSchedule normalizes schedule text', () => {
|
||||||
|
assert.strictEqual(formatSchedule('M W 07:30 AM - 10:00 AM LB447TC TC'), 'M,W 07:30 AM - 10:00 AM LB447TC');
|
||||||
|
assert.strictEqual(formatSchedule('T Th 09:00 AM - 10:30 AM LB443TC TC'), 'T,Th 09:00 AM - 10:30 AM LB443TC');
|
||||||
|
assert.strictEqual(formatSchedule('M,W 03:00 PM - 05:30 PM LB443TC'), 'M,W 03:00 PM - 05:30 PM LB443TC');
|
||||||
|
assert.strictEqual(
|
||||||
|
formatSchedule('T 07:30 AM - 10:30 AM LB448TC TC Th 07:30 AM - 10:30 AM LB448TC'),
|
||||||
|
'T 07:30 AM - 10:30 AM LB448TC\nTh 07:30 AM - 10:30 AM LB448TC'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatSchedule('W 01:30 PM - 04:30 PM LB448TC TC M 01:30 PM - 04:30 PM LB448TC'),
|
||||||
|
'W 01:30 PM - 04:30 PM LB448TC\nM 01:30 PM - 04:30 PM LB448TC'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatFullName reorders first-last names to last-first', () => {
|
||||||
|
assert.strictEqual(formatFullName('ADRIAN ALFRED CHIONG BONPIN'), 'BONPIN, ADRIAN ALFRED CHIONG');
|
||||||
|
assert.strictEqual(formatFullName('BONPIN, ADRIAN ALFRED CHIONG'), 'BONPIN, ADRIAN ALFRED CHIONG');
|
||||||
|
assert.strictEqual(formatFullName('JOHN DOE'), 'DOE, JOHN');
|
||||||
|
assert.strictEqual(formatFullName(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatTeacher renders names First [MiddleInitial] Last like the reference', () => {
|
||||||
|
assert.strictEqual(formatTeacher('FILLO, FR. PETER'), 'FR. PETER FILLO');
|
||||||
|
assert.strictEqual(formatTeacher('BACALSO, REUELLA J. AWAYAN'), 'REUELLA J. BACALSO');
|
||||||
|
assert.strictEqual(formatTeacher('PEPITO, GLENN BULLECER'), 'GLENN PEPITO');
|
||||||
|
assert.strictEqual(formatTeacher('MADERAZO, CHRISTIAN'), 'CHRISTIAN MADERAZO');
|
||||||
|
assert.strictEqual(formatTeacher('DELA PAZ, KHENT'), 'KHENT DELA PAZ');
|
||||||
|
assert.strictEqual(formatTeacher('CHRISTIAN MADERAZO'), 'CHRISTIAN MADERAZO');
|
||||||
|
assert.strictEqual(formatTeacher(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveDateRange maps semesters', () => {
|
||||||
|
assert.strictEqual(deriveDateRange('1ST Semester 2026'), 'AUGUST - JANUARY');
|
||||||
|
assert.strictEqual(deriveDateRange('2ND Semester 2026'), 'JANUARY - MAY');
|
||||||
|
assert.strictEqual(deriveDateRange('Summer 2026'), 'JUNE - JULY');
|
||||||
|
assert.strictEqual(deriveDateRange(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncateToWidth hard-cuts text to fit within maxWidth', async () => {
|
||||||
|
const doc = await global.PDFLib.PDFDocument.create();
|
||||||
|
const font = doc.embedStandardFont(global.PDFLib.StandardFonts.Helvetica);
|
||||||
|
const longText = 'APPLICATION DEVELOPMENT AND EMERGING TECHNOLOGIES';
|
||||||
|
const tr = truncateToWidth(longText, font, 8, 100);
|
||||||
|
assert.ok(tr.length < longText.length, 'long text should be cut down');
|
||||||
|
assert.ok(longText.startsWith(tr), 'result must be a prefix of the input');
|
||||||
|
assert.ok(font.widthOfTextAtSize(tr, 8) <= 100, 'truncated line must fit maxWidth');
|
||||||
|
const short = 'DATA STRUCTURES';
|
||||||
|
assert.strictEqual(truncateToWidth(short, font, 8, 200), short);
|
||||||
|
assert.strictEqual(truncateToWidth('', font, 8, 200), '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user