Author SHA1 Message Date
adrianbonpin dfa63f5157 ci: use Gitea API for releases (softprops/action-gh-release targets GitHub, fails on Gitea with 405)
build-extension / build (push) Successful in 37s
build-extension / build (pull_request) Successful in 35s
2026-08-19 03:17:42 +08:00
adrianbonpin 7c578d2d16 ci: install PyMuPDF in a venv (PEP 668 externally-managed env blocks system pip)
build-extension / build (push) Failing after 35s
build-extension / build (pull_request) Successful in 32s
2026-08-19 03:10:47 +08:00
adrianbonpin 41e7a4b81e ci: install PyMuPDF in runner; skip PDF text assertions when fitz is unavailable
build-extension / build (push) Failing after 20s
2026-08-19 03:08:46 +08:00
adrianbonpin 92a7408100 docs: move Install section to top of README
build-extension / build (push) Failing after 18s
2026-08-19 03:06:57 +08:00
adrianbonpin c1567aca00 docs: add self-hosted Firefox install route (about:config) while AMO is under maintenance
build-extension / build (push) Failing after 16s
2026-08-19 03:04:34 +08:00
adrianbonpin 40fa9cc9b5 fix(chrome): replace URL.createObjectURL with base64 data URL for downloads (Chrome MV3 service worker has no createObjectURL)
build-extension / build (push) Failing after 15s
2026-08-19 02:57:20 +08:00
adrianbonpin cd418f513d docs: add README with install instructions for both browsers
build-extension / build (push) Failing after 2m49s
2026-08-19 02:48:40 +08:00
adrianbonpin f68aa305fa docs: add AGENTS.md agent guide 2026-08-19 02:48:31 +08:00
adrianbonpin 9edaa687a5 feat: add cross-browser browser.*/chrome.* shim; exclude test/ from builds 2026-08-19 02:48:15 +08:00
adrianbonpin b571953f10 ci: add Gitea Actions build + tag release pipeline 2026-08-19 02:46:32 +08:00
adrianbonpin 71ef7fa36f feat: add zero-dependency build script (firefox+chrome zips, version flag) 2026-08-19 02:46:15 +08:00
adrianbonpin 73c68cc1e8 chore: ignore docs/ and dist/, untrack spec/plan 2026-08-19 02:45:42 +08:00
adrianbonpin e11a7aad83 fix: leave Date Enrolled line empty (reserve space), nudge Printed by closer to bottom border 2026-08-19 02:15:16 +08:00
adrianbonpin cb47d6faac fix: teacher name format, non-bold total, total row spacing, year-level show/custom/smart settings 2026-08-19 02:12:06 +08:00
adrianbonpin 106900860a feat: add option to truncate long course descriptions to single line (default on, matches official PDF) 2026-08-19 02:02:50 +08:00
adrianbonpin b3b4495427 feat: embed bundled Segoe UI fonts (with fontkit) for 1:1 typography match with reference PDF 2026-08-19 01:49:14 +08:00
adrianbonpin 79d58f994d fix: Year Level nudge reduced to 6pt (half) 2026-08-19 01:28:48 +08:00
adrianbonpin 161f540e40 fix: non-bold table headers, nudge Year Level left, center units + total to match reference 2026-08-19 01:25:50 +08:00
adrianbonpin 79702cb847 fix: embed logo as transparent PNG (SMask) to remove green badge background, matching official PDF 2026-08-19 01:16:30 +08:00
adrianbonpin 2fa4064c7c feat: format download filename as ID - Program - S.Y. YYYY-YYYY - Semester - Study Load 2026-08-19 00:57:27 +08:00
adrianbonpin 4ef392423a fix: raise total underline above the number 2026-08-19 00:54:27 +08:00
adrianbonpin e6249ae330 fix: draw total underline above the total 2026-08-19 00:52:32 +08:00
adrianbonpin 51d467fbe7 fix: center NOTE header, draw total underline below total 2026-08-19 00:48:13 +08:00
adrianbonpin 52360ab559 fix: split multi-segment schedules and expand row height to prevent text overlap 2026-08-19 00:44:17 +08:00
adrianbonpin c75850da2e fix: remove header vertical dividers, fix header label positions, correct total underline 2026-08-19 00:29:51 +08:00
adrianbonpin 17aeab9592 feat: 1:1 reference styling for Study Load PDF (header box, spacing, columns, schedule format, name format) 2026-08-18 23:57:31 +08:00
adrianbonpin 053c6aaa98 feat: pull semester/year from the study load search dropdowns 2026-08-18 22:19:55 +08:00
adrianbonpin 57c4b6f204 fix: match reference layout geometry (margins, student row zones, column spread, spacing) 2026-08-18 19:56:06 +08:00
adrianbonpin 792974bd32 feat: add header logo, fix teacher field, compact program, date range, total under UNITS 2026-08-18 19:47:59 +08:00
adrianbonpin 631ff4e9d2 fix: render new Study Load layout (GROUP-first, student row, stacked schedule) in PDF 2026-08-18 19:41:00 +08:00
adrianbonpin 1537881ebc feat: restore direct PDF download via background; drop printdoc 2026-08-18 19:38:26 +08:00
adrianbonpin 8bcfee341c style: place Download PDF button inline with total units, left-aligned 2026-08-18 19:34:31 +08:00
adrianbonpin b09e0cea76 fix: open printable Study Load via blob URL to avoid cross-origin document error 2026-08-18 19:32:24 +08:00
adrianbonpin bf4e7170a6 style: add top and bottom margin to Download PDF button 2026-08-18 19:31:27 +08:00
adrianbonpin cb2beecc0e feat: on-page Download PDF opens Letter-size printable Study Load; drop pdf-lib/background path 2026-08-18 19:28:44 +08:00
adrianbonpin d6bacef1ec feat: filter DISSOLVED courses and sum units 2026-08-18 19:24:00 +08:00
adrianbonpin 9f02bebdea feat: use USC ISMIS logo on portal blue icon 2026-08-18 19:14:15 +08:00
adrianbonpin 0128476c37 fix: match actual login field name (Username) 2026-08-18 19:11:08 +08:00
adrianbonpin f7e72ddff3 fix: use background.scripts for Firefox MV3 compatibility 2026-08-18 19:09:02 +08:00
adrianbonpin 2cf3b71e27 chore: ignore node_modules 2026-08-18 17:14:59 +08:00
adrianbonpin 8020a24655 fix: single active profile and accurate download message 2026-08-18 17:13:51 +08:00
adrianbonpin 78570c4088 feat: popup UI for profiles and study load 2026-08-18 17:09:54 +08:00
adrianbonpin 4d7ff1a0e7 fix: revoke object URL on download completion 2026-08-18 15:55:48 +08:00
adrianbonpin a072edd6d1 feat: background message routing and PDF download 2026-08-18 15:51:30 +08:00
adrianbonpin d12a7da646 fix: check response status in content script fetches 2026-08-18 15:50:19 +08:00
adrianbonpin c4f379ff1c feat: content script for auto-login, privacy, scraping 2026-08-18 15:47:15 +08:00
adrianbonpin 0d29f0116a fix: widen NOTE column and add pagination to study load PDF 2026-08-18 15:44:12 +08:00
adrianbonpin cefad72416 feat: build study load PDF 2026-08-18 15:38:37 +08:00
adrianbonpin 544d4aab1a fix: robust group-suffix stripping in parseOfferedCourses 2026-08-18 15:35:08 +08:00
adrianbonpin 5a9c185a48 feat: parse offered courses descriptions 2026-08-18 15:33:07 +08:00
adrianbonpin 94d9b7a879 fix: harden parseProfilePage class and program extraction 2026-08-18 15:30:49 +08:00
adrianbonpin 3c409ea048 feat: parse profile page header 2026-08-18 15:28:12 +08:00
adrianbonpin 06d9a4be11 docs: note node --test fix in plan 2026-08-18 15:26:43 +08:00
adrianbonpin b920bf6f61 fix: use node --test for Node 26 compatibility 2026-08-18 15:26:37 +08:00
adrianbonpin a57d7ac44c feat: parse study load table 2026-08-18 15:26:13 +08:00
adrianbonpin a0178aa0ab chore: scaffold ISMIS Helper extension 2026-08-18 15:23:54 +08:00
19 changed files with 83 additions and 5442 deletions
+14 -24
View File
@@ -1,9 +1,10 @@
name: build-extension name: build-extension
# Build + release only on version tags. Daily dev/test runs live in ci.yml.
on: on:
push: push:
branches: [feat/ismis-helper, prod]
tags: ['v*'] tags: ['v*']
pull_request:
# Ensure the job token can create releases and upload assets. # Ensure the job token can create releases and upload assets.
permissions: permissions:
@@ -25,37 +26,27 @@ jobs:
- name: Install dev dependencies - name: Install dev dependencies
run: npm ci || npm install run: npm ci || npm install
- name: Install PyMuPDF + openpyxl (PDF text + XLSX validation) - name: Install PyMuPDF (PDF text assertions)
run: | run: |
python3 -m venv /tmp/venv python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet pymupdf openpyxl /tmp/venv/bin/pip install --quiet pymupdf
echo "/tmp/venv/bin" >> "$GITHUB_PATH" echo "/tmp/venv/bin" >> "$GITHUB_PATH"
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: Validate exports open in real readers
run: node scripts/validate-exports.js
- name: Build both variants - name: Build both variants
run: node scripts/build.js all run: node scripts/build.js all
- name: Sign Firefox build (xpi for permanent install) - name: Upload zip artifacts
# Optional: only runs when the AMO credentials exist as repo secrets. uses: actions/upload-artifact@v4
# Signing requires a version AMO has not seen before — bump with:
# manifest.json before tagging (1.2.0 and 1.3.0 are already on AMO). name: ismis-helper-zips
if: env.AMO_JWT_ISSUER != '' && env.AMO_JWT_SECRET != '' path: dist/*.zip
env: if-no-files-found: error
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 - name: Publish release on version tag
if: startsWith(github.ref, 'refs/tags/v')
# Gitea-native release: softprops/action-gh-release targets GitHub's API # Gitea-native release: softprops/action-gh-release targets GitHub's API
# and fails on Gitea (405). Instead we call the Gitea API directly with # and fails on Gitea (405). Instead we call the Gitea API directly with
# the runner-provided GITHUB_TOKEN and GITHUB_API_URL. # the runner-provided GITHUB_TOKEN and GITHUB_API_URL.
@@ -73,9 +64,8 @@ jobs:
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"ISMIS Helper ${TAG}\"}" \ -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"ISMIS Helper ${TAG}\"}" \
"${API}/repos/${REPO}/releases") "${API}/repos/${REPO}/releases")
RELEASE_ID=$(echo "${RELEASE_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") 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 # Attach both zips as release assets
for f in dist/*.zip dist/*.xpi; do for f in dist/*.zip; do
[ -f "$f" ] || continue
curl -sS -X POST \ curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \ -H "Authorization: token ${GITHUB_TOKEN}" \
-F "attachment=@${f}" \ -F "attachment=@${f}" \
-35
View File
@@ -1,35 +0,0 @@
name: ci
# Fast test feedback on daily development. No builds, no releases — those only
# happen on v* tags via build.yml.
on:
push:
branches: [feat/ismis-helper, feat/ismis-helper-v1.4.0, prod]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dev dependencies
run: npm ci || npm install
- name: Install PyMuPDF + openpyxl (PDF text + XLSX validation)
run: |
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet pymupdf openpyxl
echo "/tmp/venv/bin" >> "$GITHUB_PATH"
- name: Run tests
run: npm test
- name: Validate exports open in real readers
run: node scripts/validate-exports.js
-3
View File
@@ -2,6 +2,3 @@ node_modules/
docs/ docs/
dist/ dist/
.env
.env.*
+4 -27
View File
@@ -84,27 +84,12 @@ built `ismis-helper-chrome.zip` directly:
- **Saved login profiles** (stored in `browser.storage.local`) — only one enabled at - **Saved login profiles** (stored in `browser.storage.local`) — only one enabled at
a time, with a master on/off toggle. a time, with a master on/off toggle.
- **One-click Download PDF** button on the Study Load page, plus **CSV / Excel / - **One-click Download PDF** button on the Study Load page.
JSON / Next Up** export buttons.
- PDF settings in the popup: - PDF settings in the popup:
- Truncate long course descriptions to match the official PDF (default on). - Truncate long course descriptions to match the official PDF (default on).
- Show/hide `Year Level`, and set a custom Year Level value. - 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. - **Bundled Segoe UI fonts** for a 1:1 look with the official document.
- Builds and packages automatically via Gitea Actions (zips + versioned releases - Builds and packages automatically via Gitea Actions (zips + versioned releases).
with a signed `.xpi`).
## Build ## Build
@@ -129,19 +114,11 @@ additionally creates a **Gitea Release** with both versioned zips attached.
Open the extension popup to: Open the extension popup to:
- Manage login profiles (add / edit / enable). - Manage login profiles (add / edit / enable).
- Toggle the master on/off switch. - Toggle the master on/off switch.
- Set PDF options: truncate descriptions, show/hide `Year Level`, Year Level - Set PDF options: truncate descriptions, show/hide `Year Level`, custom 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 ## Data & privacy
- Profile **passwords are encrypted at rest** with Web Crypto AES-GCM (256-bit). - Auto-login stores your portal credentials **in plaintext** on your own device.
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 - This is a personal-use helper for the university portal; use it only with your
own account. own account.
+29 -120
View File
@@ -10,25 +10,20 @@ function errMsg(err) {
return String((err && err.message) || err); return String((err && err.message) || err);
} }
// Build a download URL from PDF bytes. Chrome MV3 runs the background as a
// service worker, where URL.createObjectURL is NOT available, so we use a
// base64 data URL (works in both Firefox and Chrome). PDFs here are small
// (~150KB), well within data-URL length limits.
function pdfDataUrl(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return 'data:application/pdf;base64,' + btoa(binary);
}
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { 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') { if (msg && msg.type === 'generate-pdf') {
generatePdf(msg.data) generatePdf(msg.data)
.then(() => sendResponse({ ok: true })) .then(() => sendResponse({ ok: true }))
@@ -37,107 +32,6 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
}); });
// ---- Password encryption (at-rest) ----
// AES-GCM with a random 256-bit key. The key is stored in browser.storage.local
// so it survives browser/service-worker restarts (otherwise previously-encrypted
// passwords could never be decrypted again). This removes the plaintext-password
// smell from storage — it is NOT strong protection against an attacker who can
// read both the storage and the extension source (the user's threat model is
// only the school portal, no third-party exfiltration).
const CRYPTO_KEY_STORE = 'ismisCryptoKey';
async function getCryptoKey() {
const { [CRYPTO_KEY_STORE]: stored } = await browser.storage.local.get(CRYPTO_KEY_STORE);
if (stored) {
return crypto.subtle.importKey('raw', new Uint8Array(stored), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const raw = await crypto.subtle.exportKey('raw', key);
await browser.storage.local.set({ [CRYPTO_KEY_STORE]: Array.from(new Uint8Array(raw)) });
return key;
}
async function encryptPassword(password) {
const key = await getCryptoKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(password));
return { iv: Array.from(iv), data: Array.from(new Uint8Array(data)) };
}
async function decryptPassword(cipher) {
if (!cipher || !cipher.iv || !cipher.data) throw new Error('Invalid encrypted password');
const key = await getCryptoKey();
const pt = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(cipher.iv) }, key, new Uint8Array(cipher.data));
return new TextDecoder().decode(pt);
}
// Generic downloader used by every export format. `filename` is used with
// saveAs so the user picks the location, mirroring the PDF flow.
async function downloadBytes(bytes, mime, filename, fallback) {
const safeFilename = filename || fallback;
const blob = new Blob([bytes], { type: mime });
const url = (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function')
? URL.createObjectURL(blob)
: null;
let downloadUrl = url;
if (!downloadUrl) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
downloadUrl = 'data:' + mime + ';base64,' + btoa(binary);
}
try {
const id = await browser.downloads.download({ url: downloadUrl, filename: safeFilename, saveAs: true });
const onChanged = (delta) => {
if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) {
browser.downloads.onChanged.removeListener(onChanged);
}
};
browser.downloads.onChanged.addListener(onChanged);
} catch (err) {
if (url) { try { URL.revokeObjectURL(url); } catch (e) {} }
throw err;
}
}
async function runExport(format, data) {
const header = data.header || {};
const year = parseInt(data.year, 10);
const period = formatPeriod(data.period || header.semester || '');
const compactProgram = ISMIStools.compactProgram(header.program || '');
const schoolYear = Number.isFinite(year) ? year + '-' + (year + 1) : '';
const core = [
header.studentId || (header.fullName ? header.fullName.replace(/\s+/g, ' ') : ''),
compactProgram,
schoolYear ? 'S.Y. ' + schoolYear : '',
period
].filter(Boolean);
const base = core.join(' - ') || 'Study Load';
if (format === 'csv') {
const text = ISMIStools.buildStudyLoadCsv(data);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'text/csv', base + '.csv', 'Study Load.csv');
} else if (format === 'json') {
const text = JSON.stringify(ISMIStools.buildStudyLoadJson(data), null, 2);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'application/json', base + '.json', 'Study Load.json');
} else if (format === 'xlsx') {
const bytes = ISMIStools.buildStudyLoadXlsx(data);
await downloadBytes(bytes, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', base + '.xlsx', 'Study Load.xlsx');
} else if (format === 'nextup') {
const text = ISMIStools.buildNextUpCsv(data);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'text/csv', base + '-nextup.csv', 'Study Load - nextup.csv');
} else {
throw new Error('Unknown export format: ' + format);
}
}
function formatPeriod(period) { function formatPeriod(period) {
if (!period) return ''; if (!period) return '';
return String(period) return String(period)
@@ -151,6 +45,9 @@ function formatPeriod(period) {
async function generatePdf(data) { async function generatePdf(data) {
const bytes = await ISMIStools.buildStudyLoadPdf(data); const bytes = await ISMIStools.buildStudyLoadPdf(data);
// Chrome MV3 background is a service worker (no URL.createObjectURL), so we
// use a base64 data URL that works in both browsers.
const url = pdfDataUrl(bytes);
const header = data.header || {}; const header = data.header || {};
const year = parseInt(data.year, 10); const year = parseInt(data.year, 10);
const period = formatPeriod(data.period || header.semester || ''); const period = formatPeriod(data.period || header.semester || '');
@@ -165,5 +62,17 @@ async function generatePdf(data) {
].filter(Boolean); ].filter(Boolean);
const filename = parts.join(' - ') + '.pdf'; const filename = parts.join(' - ') + '.pdf';
const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf'; const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf';
await downloadBytes(bytes, 'application/pdf', filename, fallback); const safeFilename = filename || fallback;
try {
const id = await browser.downloads.download({ url, filename: safeFilename, saveAs: true });
const onChanged = (delta) => {
if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) {
browser.downloads.onChanged.removeListener(onChanged);
}
};
browser.downloads.onChanged.addListener(onChanged);
} catch (err) {
throw err;
}
} }
+16 -227
View File
@@ -39,20 +39,7 @@
const passField = document.querySelector('input[type="password"]'); const passField = document.querySelector('input[type="password"]');
if (!userField || !passField) return; if (!userField || !passField) return;
userField.value = active.username; userField.value = active.username;
// Password is encrypted at rest; decrypt via the background. Legacy profiles passField.value = active.password;
// 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 })); userField.dispatchEvent(new Event('input', { bubbles: true }));
passField.dispatchEvent(new Event('input', { bubbles: true })); passField.dispatchEvent(new Event('input', { bubbles: true }));
const form = userField.closest('form') || passField.closest('form'); const form = userField.closest('form') || passField.closest('form');
@@ -107,10 +94,9 @@
return m ? m[1] : ''; return m ? m[1] : '';
} }
async function scrapeStudyLoad(onProgress) { async function scrapeStudyLoad() {
const table = document.querySelector('table.table-forum'); const table = document.querySelector('table.table-forum');
if (!table) throw new Error('No study load table found on this page.'); 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); const courses = ISMIStools.parseStudyLoadTable(table.outerHTML);
if (!courses.length) throw new Error('No courses found in study load table.'); if (!courses.length) throw new Error('No courses found in study load table.');
@@ -131,21 +117,16 @@
const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))]; const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))];
const descMap = {}; const descMap = {};
if (onProgress && uniqueCodes.length) onProgress('Fetching course descriptions (0/' + uniqueCodes.length + ')', 10); for (const code of uniqueCodes) {
for (let i = 0; i < uniqueCodes.length; i++) {
const code = uniqueCodes[i];
try { try {
descMap[code] = await fetchCourseDescription(code, academicPeriod, academicYear, token); descMap[code] = await fetchCourseDescription(code, academicPeriod, academicYear, token);
} catch (e) { } catch (e) {
descMap[code] = ''; 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] || ''; }); courses.forEach((c) => { c.description = descMap[c.courseCode] || ''; });
let header = {}; let header = {};
if (onProgress) onProgress('Reading profile', 75);
try { try {
const resp = await fetch('/Manage/Student', { credentials: 'same-origin' }); const resp = await fetch('/Manage/Student', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Profile request failed: ' + resp.status); if (!resp.ok) throw new Error('Profile request failed: ' + resp.status);
@@ -160,77 +141,9 @@
? selectedPeriod + ' ' + selectedYear ? selectedPeriod + ' ' + selectedYear
: (header.semester || ''); : (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 }; 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 ---- // ---- On-page Download PDF button ----
const BTN_ID = 'ismisHelperDownloadBtn'; const BTN_ID = 'ismisHelperDownloadBtn';
@@ -253,148 +166,39 @@
async function onDownloadPdf() { async function onDownloadPdf() {
const btn = document.getElementById(BTN_ID); const btn = document.getElementById(BTN_ID);
setExportButtonsDisabled(true);
try { try {
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...'; btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...';
setProgress('Reading study load', 2); const { header, courses, period: selectedPeriod } = await scrapeStudyLoad();
const { header, courses } = await scrapeStudyLoad((l, p) => setProgress(l, p));
const included = ISMIStools.filterStudyLoadCourses(courses); const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.'); if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included); const totalUnits = ISMIStools.sumUnits(included);
const settings = await browser.storage.local.get(['truncateDescription', 'showYearLevel']); const settings = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
// Resolve the Year Level: custom value wins; otherwise prefer the scraped
// value, smart-adjusted (minus one) during SUMMER when the year level has
// not yet advanced.
header.showYearLevel = settings.showYearLevel !== false; header.showYearLevel = settings.showYearLevel !== false;
setProgress('Building PDF', 95); const custom = String(settings.customYearLevel || '').trim();
if (custom) {
header.yearLevel = custom;
} else if (/summer/i.test(selectedPeriod || '')) {
const n = parseInt(header.yearLevel, 10);
if (Number.isFinite(n) && n > 1) header.yearLevel = String(n - 1);
}
const res = await browser.runtime.sendMessage({ const res = await browser.runtime.sendMessage({
type: 'generate-pdf', type: 'generate-pdf',
data: { header, courses: included, totalUnits, truncateDescription: settings.truncateDescription !== false } data: { header, courses: included, totalUnits, truncateDescription: settings.truncateDescription !== false }
}); });
if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed'); if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed');
setProgress('Done ✓', 100);
showToast('PDF downloaded.'); showToast('PDF downloaded.');
setTimeout(clearProgress, 3000);
} catch (err) { } catch (err) {
setProgress('Error: ' + ((err && err.message) || err), 100);
showToast('Error: ' + ((err && err.message) || err), true); showToast('Error: ' + ((err && err.message) || err), true);
} finally { } finally {
setExportButtonsDisabled(false);
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF'; 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() { function injectDownloadButton() {
if (document.getElementById(BTN_ID)) return; if (document.getElementById(BTN_ID)) return;
const unitsP = findTotalUnitsP(); const unitsP = findTotalUnitsP();
@@ -405,7 +209,6 @@
wrapper.style.alignItems = 'center'; wrapper.style.alignItems = 'center';
wrapper.style.justifyContent = 'space-between'; wrapper.style.justifyContent = 'space-between';
wrapper.style.margin = '10px 0'; wrapper.style.margin = '10px 0';
wrapper.style.flexWrap = 'wrap';
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.id = BTN_ID; btn.id = BTN_ID;
@@ -414,22 +217,8 @@
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF'; btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
btn.addEventListener('click', onDownloadPdf); 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); unitsP.parentNode.insertBefore(wrapper, unitsP);
wrapper.appendChild(btnGroup); wrapper.appendChild(btn);
wrapper.appendChild(makeProgressEl());
wrapper.appendChild(unitsP); wrapper.appendChild(unitsP);
} }
-128
View File
@@ -1,128 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// RFC-4180 field encoding: quote fields containing commas, quotes, or
// newlines; double up embedded quotes.
function csvField(value) {
let s = String(value == null ? '' : value);
if (/[",\r\n]/.test(s)) {
s = '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function csvRow(cells) {
return cells.map(csvField).join(',') + '\r\n';
}
// Build a CSV representation of the study load.
// data: { header, courses, totalUnits }
// header fields: program, studentName, studentId, semester, yearLevel
function buildStudyLoadCsv(data) {
const header = data.header || {};
const lines = [];
lines.push(csvRow(['ISMIS HELPER - STUDY LOAD']));
lines.push(csvRow(['Program', header.program || '']));
lines.push(csvRow(['Student Name', header.fullName || header.studentName || '']));
lines.push(csvRow(['Student ID', header.studentId || '']));
lines.push(csvRow(['Semester', header.semester || '']));
lines.push(csvRow(['Year Level', header.yearLevel || '']));
lines.push(csvRow(['Total Units', data.totalUnits || '']));
lines.push(''); // blank separator line
lines.push(csvRow(['Group', 'Course Code', 'Description', 'Schedule', 'Faculty', 'Units', 'Status']));
for (const c of (data.courses || [])) {
lines.push(csvRow([
c.group, c.courseCode, c.description, c.schedule,
c.faculty || c.teacher, c.units, c.status
]));
}
lines.push(csvRow(['Total', '', '', '', '', data.totalUnits || '']));
lines.push(''); // final blank line, per RFC
// UTF-8 BOM so Excel opens it without mojibake.
return '\uFEFF' + lines.join('');
}
// Build a Next Up import CSV following the exact column contract in
// raycast/nextup/src/lib/csv-utils.ts:
// title, courseCode, days, startTime, endTime, room, meetingLink,
// color, icon, units, classLink, professorName, professorEmail, groupName
// One row per schedule block (a course may have multiple meeting segments).
function buildNextUpCsv(data, parseSchedule) {
const lines = [];
lines.push(csvRow(['title', 'courseCode', 'days', 'startTime', 'endTime',
'room', 'meetingLink', 'color', 'icon', 'units', 'classLink',
'professorName', 'professorEmail', 'groupName']));
const resolver = parseSchedule || parseScheduleBlocks;
const groupName = (data.header && (data.header.semester || '')) || '';
for (const c of (data.courses || [])) {
const blocks = resolver(c.schedule);
if (!blocks.length) {
lines.push(csvRow([c.description || c.courseCode, c.courseCode, '',
'', '', '', '', '', '', c.units, '', c.faculty || '', '', groupName]));
continue;
}
for (const b of blocks) {
lines.push(csvRow([
c.description || c.courseCode,
c.courseCode,
b.days,
b.startTime,
b.endTime,
b.room || '',
'', '', '',
c.units,
'',
c.faculty || '',
'',
groupName
]));
}
}
return '\uFEFF' + lines.join('');
}
// Split a study-load schedule string like
// "F 01:30 PM - 04:30 PM LB486TC TC; T Th 03:00 PM - 04:30 PM LB468"
// into [{ days: 'MO,TU', startTime: '01:30', endTime: '16:30', room }]
// Day codes map to the 3-letter codes Next Up expects (MO,TU,WE,TH,FR).
function parseScheduleBlocks(schedule) {
const s = String(schedule || '').trim();
if (!s) return [];
const blocks = [];
const segRe = /([A-Za-z]{1,3}(?:\s+[A-Za-z]{1,3})*)\s+(\d{1,2}):(\d{2})\s*(AM|PM)\s*-\s*(\d{1,2}):(\d{2})\s*(AM|PM)\s*([A-Za-z0-9]*)/gi;
const dayMap = {
M: 'MO', MO: 'MO', MON: 'MO',
T: 'TU', TU: 'TU', TUE: 'TU',
W: 'WE', WE: 'WE', WED: 'WE',
TH: 'TH', THU: 'TH', THUR: 'TH', THURS: 'TH',
F: 'FR', FR: 'FR', FRI: 'FR',
SA: 'SA', SAT: 'SA',
S: 'SU', SU: 'SU', SUN: 'SU', SUNDAY: 'SU'
};
let m;
while ((m = segRe.exec(s)) !== null) {
const dayToCode = (d) => dayMap[d.toUpperCase().replace(/\./g, '')] || '';
const days = m[1].split(/\s+/).map(dayToCode).filter(Boolean);
const to24 = (h, ampm, minute) => {
let hour = parseInt(h, 10) % 12;
if (/PM/i.test(ampm)) hour += 12;
return String(hour).padStart(2, '0') + ':' + minute;
};
blocks.push({
days: days.join(','),
startTime: to24(m[2], m[4], m[3]),
endTime: to24(m[5], m[7], m[6]),
room: m[8] || ''
});
}
return blocks;
}
return { buildStudyLoadCsv, buildNextUpCsv, parseScheduleBlocks, csvField };
});
-45
View File
@@ -1,45 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// Build a clean, reusable JSON representation of the study load.
// data: { header, courses, totalUnits, year, period }
function buildStudyLoadJson(data) {
const header = data.header || {};
const courses = (data.courses || []).map((c) => ({
group: c.group || '',
courseCode: c.courseCode || '',
description: c.description || '',
schedule: c.schedule || '',
faculty: (c.faculty || c.teacher) || '',
units: c.units || '',
status: c.status || ''
}));
return {
meta: {
program: header.program || '',
studentName: header.fullName || header.studentName || '',
studentId: header.studentId || '',
academicYear: data.year ? String(data.year) : '',
semester: header.semester || data.period || '',
yearLevel: header.yearLevel || '',
totalUnits: data.totalUnits || '',
generatedAt: new Date().toISOString(),
extensionVersion: data.extensionVersion || ''
},
courses,
totals: {
// A study load lists the courses the student is enrolled in for the
// term; there is no "completed" concept here.
enrolledUnits: data.totalUnits || ''
}
};
}
return { buildStudyLoadJson };
});
-229
View File
@@ -1,229 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// ---- Minimal ZIP writer (STORED, no compression) ----
// Produces a valid ZIP containing the XLSX parts. Stored entries are used
// because we have no deflate available (browser + zero-dependency Node).
function crc32(buf) {
let table = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function zipStore(entries) {
// entries: [{ name, data: Uint8Array }]
const chunks = [];
const central = [];
let offset = 0;
const enc = new TextEncoder();
for (const e of entries) {
const nameBytes = enc.encode(e.name);
const crc = crc32(e.data);
const local = new Uint8Array(30 + nameBytes.length);
const dv = new DataView(local.buffer);
dv.setUint32(0, 0x04034b50, true); // local file header sig
dv.setUint16(4, 20, true); // version needed
dv.setUint16(6, 0, true); // flags
dv.setUint16(8, 0, true); // method: stored
dv.setUint16(10, 0, true); // mod time
dv.setUint16(12, 0x21, true); // mod date
dv.setUint32(14, crc, true);
dv.setUint32(18, e.data.length, true); // compressed size
dv.setUint32(22, e.data.length, true); // uncompressed size
dv.setUint16(26, nameBytes.length, true); // name len
dv.setUint16(28, 0, true); // extra len
local.set(nameBytes, 30);
chunks.push(local, e.data);
const cd = new Uint8Array(46 + nameBytes.length);
const cdv = new DataView(cd.buffer);
cdv.setUint32(0, 0x02014b50, true); // central dir sig
cdv.setUint16(4, 20, true); // version made by
cdv.setUint16(6, 20, true); // version needed
cdv.setUint16(8, 0, true); // flags
cdv.setUint16(10, 0, true); // method stored
cdv.setUint16(12, 0, true); // mod time
cdv.setUint16(14, 0x21, true); // mod date
cdv.setUint32(16, crc, true);
cdv.setUint32(20, e.data.length, true);
cdv.setUint32(24, e.data.length, true);
cdv.setUint16(28, nameBytes.length, true);
cdv.setUint16(30, 0, true); // extra len
cdv.setUint16(32, 0, true); // comment len
cdv.setUint16(34, 0, true); // disk start
cdv.setUint16(36, 0, true); // internal attrs
cdv.setUint32(38, 0, true); // external attrs
cdv.setUint32(42, offset, true); // local header offset
cd.set(nameBytes, 46);
central.push(cd);
offset += local.length + e.data.length;
}
const cdStart = offset;
let cdLen = 0;
for (const c of central) cdLen += c.length;
const eocd = new Uint8Array(22);
const eocdv = new DataView(eocd.buffer);
eocdv.setUint32(0, 0x06054b50, true);
eocdv.setUint16(8, entries.length, true); // total entries (this disk)
eocdv.setUint16(10, entries.length, true);
eocdv.setUint32(12, cdLen, true);
eocdv.setUint32(16, cdStart, true);
chunks.push(...central, eocd);
const total = chunks.reduce((s, c) => s + c.length, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const c of chunks) { out.set(c, pos); pos += c.length; }
return out;
}
// ---- XML helpers ----
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
// ---- XLSX builder ----
// Builds a single-sheet workbook: a title/header block + a course table
// + a totals row, mirroring the CSV layout.
function buildStudyLoadXlsx(data) {
const header = data.header || {};
const enc = new TextEncoder();
// Gather all strings into a `sharedStrings` table; cells reference them.
const strings = [];
const sIndex = new Map();
function addS(str) {
const key = String(str == null ? '' : str);
if (!sIndex.has(key)) { sIndex.set(key, strings.length); strings.push(key); }
return sIndex.get(key);
}
// rows: array of arrays of { text } (all text cells for simplicity)
const rows = [];
rows.push([{ text: 'ISMIS HELPER - STUDY LOAD' }]);
rows.push([{ text: 'Program' }, { text: header.program || '' }]);
rows.push([{ text: 'Student Name' }, { text: header.fullName || header.studentName || '' }]);
rows.push([{ text: 'Student ID' }, { text: header.studentId || '' }]);
rows.push([{ text: 'Semester' }, { text: header.semester || '' }]);
rows.push([{ text: 'Year Level' }, { text: header.yearLevel || '' }]);
rows.push([{ text: 'Total Units' }, { text: data.totalUnits || '' }]);
rows.push([]); // blank
rows.push([{ text: 'Group' }, { text: 'Course Code' }, { text: 'Description' },
{ text: 'Schedule' }, { text: 'Faculty' }, { text: 'Units' }, { text: 'Status' }]);
for (const c of (data.courses || [])) {
rows.push([
{ text: c.group }, { text: c.courseCode }, { text: c.description },
{ text: c.schedule }, { text: c.faculty || c.teacher }, { text: c.units },
{ text: c.status }
]);
}
rows.push([{ text: 'Total' }, { text: '' }, { text: '' }, { text: '' },
{ text: '' }, { text: data.totalUnits || '' }, { text: '' }]);
// Pre-compute shared string refs so sheet1.xml can reference them.
const cellRefs = rows.map((row) => row.map((c) => c.text === '' ? -1 : addS(c.text)));
// ---- sheet1.xml ----
let sheetXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
sheetXml += '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">';
sheetXml += '<cols>' +
'<col min="1" max="1" width="8" customWidth="1"/>' +
'<col min="2" max="2" width="14" customWidth="1"/>' +
'<col min="3" max="3" width="42" customWidth="1"/>' +
'<col min="4" max="4" width="38" customWidth="1"/>' +
'<col min="5" max="5" width="24" customWidth="1"/>' +
'<col min="6" max="6" width="10" customWidth="1"/>' +
'<col min="7" max="7" width="12" customWidth="1"/>' +
'</cols>';
sheetXml += '<sheetData>';
const colNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
cellRefs.forEach((refs, ri) => {
sheetXml += '<row r="' + (ri + 1) + '">';
refs.forEach((s, ci) => {
if (s < 0) return;
sheetXml += '<c r="' + colNames[ci] + (ri + 1) + '" t="s"><v>' + s + '</v></c>';
});
sheetXml += '</row>';
});
sheetXml += '</sheetData></worksheet>';
// ---- sharedStrings.xml ----
let ssXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
ssXml += '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' +
cellRefs.reduce((s, r) => s + r.filter((x) => x >= 0).length, 0) +
'" uniqueCount="' + strings.length + '">';
for (const s of strings) ssXml += '<si><t>' + esc(s) + '</t></si>';
ssXml += '</sst>';
// ---- other parts ----
const ct = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' +
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
'</Types>';
const rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const wb = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
'<sheets><sheet name="Study Load" sheetId="1" r:id="rId1"/></sheets></workbook>';
const wbRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' +
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
'</Relationships>';
const xlRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const styles = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>' +
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>' +
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>' +
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
'</styleSheet>';
const parts = [
{ name: '[Content_Types].xml', data: enc.encode(ct) },
{ name: '_rels/.rels', data: enc.encode(rels) },
{ name: 'xl/workbook.xml', data: enc.encode(wb) },
{ name: 'xl/_rels/workbook.xml.rels', data: enc.encode(wbRels) },
{ name: 'xl/worksheets/sheet1.xml', data: enc.encode(sheetXml) },
{ name: 'xl/sharedStrings.xml', data: enc.encode(ssXml) },
{ name: 'xl/styles.xml', data: enc.encode(styles) }
];
return zipStore(parts);
}
return { buildStudyLoadXlsx };
});
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "ISMIS Helper", "name": "ISMIS Helper",
"version": "1.4.0", "version": "1.2.0",
"description": "Auto-login, auto-accept privacy policy, and Study Load PDF for the USC ISMIS portal.", "description": "Auto-login, auto-accept privacy policy, and Study Load PDF for the USC ISMIS portal.",
"permissions": ["storage", "downloads"], "permissions": ["storage", "downloads"],
"host_permissions": ["https://ismis.usc.edu.ph/*"], "host_permissions": ["https://ismis.usc.edu.ph/*"],
"background": { "background": {
"scripts": ["libs/fontkit.min.js", "libs/pdf-lib.min.js", "pdf.js", "export-csv.js", "export-json.js", "export-xlsx.js", "background.js"] "scripts": ["libs/fontkit.min.js", "libs/pdf-lib.min.js", "pdf.js", "background.js"]
}, },
"content_scripts": [ "content_scripts": [
{ {
+1 -4105
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -5,11 +5,9 @@
"description": "Firefox extension for the USC ISMIS portal", "description": "Firefox extension for the USC ISMIS portal",
"scripts": { "scripts": {
"test": "node --test", "test": "node --test",
"build": "node scripts/build.js all", "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": { "devDependencies": {
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1"
"web-ext": "^8.3.0"
} }
} }
+1 -108
View File
@@ -81,107 +81,6 @@
})); }));
} }
// ---- Year standing helpers ----
// Parse the View Grades page into an ordered list of academic terms:
// [{ semester: 'FIRST'|'SECOND'|'SUMMER', year: 2024 }, ...]
// Terms appear in chronological order in the page. Regular semesters appear
// as "FIRST SEMESTER - YYYY"; summer appears as "SUMMER - YYYY".
function parseGradeHistory(html) {
const history = [];
const re = /\b(FIRST|SECOND)\s+SEMESTER\s*-\s*(\d{4})\b|\bSUMMER\s*-\s*(\d{4})\b/gi;
let m;
while ((m = re.exec(html)) !== null) {
if (m[1]) {
history.push({ semester: m[1].toUpperCase(), year: parseInt(m[2], 10) });
} else {
history.push({ semester: 'SUMMER', year: parseInt(m[3], 10) });
}
}
return history;
}
// Standing for a study load printed in `year` from the View Grades history.
// It is the number of distinct academic years strictly before that year + 1
// (FIRST/SECOND/SUMMER of a year share the same standing), clamped to at most
// the number of distinct years in history. NOTE: this is only an
// approximation for the printed semester — it counts calendar years present,
// not units passed — so a repeated/failed year can over-advance. It is used
// only as a fallback when the units-based standing is unavailable.
function yearLevelForSemester(history, year) {
if (!history || !history.length || !Number.isFinite(year)) return null;
const distinctYears = [...new Set(history.map((t) => t.year))].sort((a, b) => a - b);
const yearsBefore = distinctYears.filter((y) => y < year).length;
const level = Math.min(yearsBefore + 1, distinctYears.length);
return Number.isFinite(level) && level >= 1 ? level : null;
}
// Parse the Student Prospectus into per-year unit totals. Each table is
// preceded by a "Year Level: N" heading. Returns [{ year, units }].
function parseProspectusYearUnits(html) {
const segs = String(html || '').split(/(Year Level:\s*\d)/i);
const yearUnits = {};
let cur = null;
for (let i = 0; i < segs.length; i++) {
const s = segs[i];
const ym = /Year Level:\s*(\d+)/i.exec(s);
if (ym) { cur = parseInt(ym[1], 10); continue; }
if (cur == null) continue;
const tm = /<table[^>]*>([\s\S]*?)<\/table>/i.exec(s);
if (!tm) continue;
const rows = extractRows(tm[1]);
let units = 0;
for (const r of rows) {
if (r.length >= 3 && /^[A-Z]/.test(r[0]) && /[\d.]+/.test(r[2])) {
const u = parseFloat(String(r[2]).replace(/[^\d.]/g, ''));
if (Number.isFinite(u)) units += u;
}
}
yearUnits[cur] = (yearUnits[cur] || 0) + units;
cur = null; // only the immediately-following table belongs to this heading
}
const years = Object.keys(yearUnits).map(Number).sort((a, b) => a - b);
return years.map((y) => ({ year: y, units: yearUnits[y] }));
}
// Parse the Lacking Subjects page for total remaining units.
function parseLackingSubjectsUnits(html) {
return parseLackingSubjects(html).units;
}
function parseLackingSubjects(html) {
const rows = extractRows(String(html || ''));
let units = 0;
const courses = [];
for (const r of rows) {
if (r.length >= 3 && /^[A-Z]/.test(r[0]) && /[\d.]+/.test(r[2])) {
const u = parseFloat(String(r[2]).replace(/[^\d.]/g, ''));
if (Number.isFinite(u)) {
units += u;
courses.push({ courseCode: r[0], description: r[1], units: String(u) });
}
}
}
return { units: units.toFixed(2), courses };
}
// Year standing from units: finished = totalProspectusUnits - lackingUnits;
// standing = first N in [1..maxYear] where finished < cumulativeUnits(N);
// if finished passes every threshold, standing = maxYear (program length).
// yearUnits is the array [{year, units}] from parseProspectusYearUnits.
function computeYearStandingFromUnits(yearUnits, lackingUnits) {
const yrs = (yearUnits || []).slice().sort((a, b) => a.year - b.year);
if (!yrs.length) return null;
const total = yrs.reduce((s, y) => s + y.units, 0);
const finished = total - (parseFloat(lackingUnits) || 0);
if (finished < 0) return yrs[0].year;
let cum = 0;
for (const y of yrs) {
cum += y.units;
if (finished < cum) return y.year;
}
return yrs[yrs.length - 1].year; // passed everything -> final level
}
function filterStudyLoadCourses(courses) { function filterStudyLoadCourses(courses) {
return (courses || []).filter( return (courses || []).filter(
(c) => String(c.status || '').trim().toUpperCase() !== 'DISSOLVED' (c) => String(c.status || '').trim().toUpperCase() !== 'DISSOLVED'
@@ -194,11 +93,5 @@
.toFixed(2); .toFixed(2);
} }
return { return { parseStudyLoadTable, parseProfilePage, parseOfferedCourses, filterStudyLoadCourses, sumUnits };
parseStudyLoadTable, parseProfilePage, parseOfferedCourses,
filterStudyLoadCourses, sumUnits,
parseGradeHistory, yearLevelForSemester,
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
computeYearStandingFromUnits
};
}); });
+1 -9
View File
@@ -36,18 +36,10 @@
<h2>PDF Settings</h2> <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="truncateToggle"> Truncate long course descriptions (official PDF style)</label>
<label class="row"><input type="checkbox" id="showYearToggle"> Show Year Level on the PDF</label> <label class="row"><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): <label class="row">Custom Year Level (optional):
<input type="text" id="customYear" placeholder="e.g. 3"> <input type="text" id="customYear" placeholder="e.g. 3">
</label> </label>
<p class="hint">Auto derives the standing of the semester being printed (from <p class="hint">Leave blank to use the scraped value (auto-adjusted in Summer).</p>
View Grades, or remaining units). Profile value only disables derivation.</p>
</section> </section>
<script src="popup.js"></script> <script src="popup.js"></script>
+12 -41
View File
@@ -25,11 +25,10 @@ $('#masterToggle').addEventListener('change', async (e) => {
// ---- PDF settings: truncate long course descriptions (default ON) ---- // ---- PDF settings: truncate long course descriptions (default ON) ----
async function loadPdfSettings() { async function loadPdfSettings() {
const s = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel', 'yearMode']); const s = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
$('#truncateToggle').checked = s.truncateDescription !== false; $('#truncateToggle').checked = s.truncateDescription !== false;
$('#showYearToggle').checked = s.showYearLevel !== false; $('#showYearToggle').checked = s.showYearLevel !== false;
$('#customYear').value = s.customYearLevel || ''; $('#customYear').value = s.customYearLevel || '';
$('#yearMode').value = s.yearMode || 'auto';
} }
$('#truncateToggle').addEventListener('change', async (e) => { $('#truncateToggle').addEventListener('change', async (e) => {
await browser.storage.local.set({ truncateDescription: e.target.checked }); await browser.storage.local.set({ truncateDescription: e.target.checked });
@@ -37,9 +36,6 @@ $('#truncateToggle').addEventListener('change', async (e) => {
$('#showYearToggle').addEventListener('change', async (e) => { $('#showYearToggle').addEventListener('change', async (e) => {
await browser.storage.local.set({ showYearLevel: e.target.checked }); 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) => { $('#customYear').addEventListener('change', async (e) => {
await browser.storage.local.set({ customYearLevel: e.target.value.trim() }); await browser.storage.local.set({ customYearLevel: e.target.value.trim() });
}); });
@@ -100,52 +96,27 @@ function openProfileForm(p) {
editingProfileId = p ? p.id : null; editingProfileId = p ? p.id : null;
$('#pName').value = p ? p.name : ''; $('#pName').value = p ? p.name : '';
$('#pUsername').value = p ? p.username : ''; $('#pUsername').value = p ? p.username : '';
// The stored password is encrypted and not revealed; leave the field blank. $('#pPassword').value = p ? p.password : '';
// On save, a blank password preserves the existing one.
$('#pPassword').value = '';
$('#pEnabled').checked = p ? !!p.enabled : true; $('#pEnabled').checked = p ? !!p.enabled : true;
$('#profileForm').hidden = false; $('#profileForm').hidden = false;
if (p) $('#pPassword').placeholder = 'Leave blank to keep existing password';
else $('#pPassword').placeholder = 'Password';
} }
$('#addProfile').addEventListener('click', () => openProfileForm(null)); $('#addProfile').addEventListener('click', () => openProfileForm(null));
$('#cancelProfile').addEventListener('click', () => { $('#profileForm').hidden = true; }); $('#cancelProfile').addEventListener('click', () => { $('#profileForm').hidden = true; });
$('#saveProfile').addEventListener('click', async () => { $('#saveProfile').addEventListener('click', async () => {
const profiles = await getProfiles(); const profiles = await getProfiles();
const name = $('#pName').value.trim(); const data = {
const username = $('#pUsername').value.trim(); id: editingProfileId || Date.now().toString(),
const typedPassword = $('#pPassword').value; name: $('#pName').value.trim(),
const enabled = $('#pEnabled').checked; username: $('#pUsername').value.trim(),
if (!name || !username) return; password: $('#pPassword').value,
enabled: $('#pEnabled').checked
// Encrypt a newly-typed password via the background. };
let passwordEnc = null; if (!data.name || !data.username) return;
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) { if (editingProfileId) {
const i = profiles.findIndex((x) => x.id === editingProfileId); const i = profiles.findIndex((x) => x.id === editingProfileId);
if (i >= 0) { if (i >= 0) profiles[i] = data;
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 { } else {
const profile = { profiles.push(data);
id: Date.now().toString(),
name,
username,
enabled,
passwordEnc
};
profiles.push(profile);
} }
await saveProfiles(profiles); await saveProfiles(profiles);
$('#profileForm').hidden = true; $('#profileForm').hidden = true;
-3
View File
@@ -14,9 +14,6 @@ const CHROME_BG_PARTS = [
'libs/fontkit.min.js', 'libs/fontkit.min.js',
'libs/pdf-lib.min.js', 'libs/pdf-lib.min.js',
'pdf.js', 'pdf.js',
'export-csv.js',
'export-json.js',
'export-xlsx.js',
'background.js', 'background.js',
]; ];
-64
View File
@@ -1,64 +0,0 @@
'use strict';
// CI-only validation: build a sample study-load XLSX/CSV/JSON and confirm they
// open in real readers. The XLSX is re-parsed with python3 openpyxl (installed
// in CI), which catches malformed XML / cell-reference issues that the Node
// ZIP-structure tests cannot.
const fs = require('fs');
const path = require('path');
const os = require('os');
const { buildStudyLoadXlsx } = require('../export-xlsx.js');
const { buildStudyLoadCsv, buildNextUpCsv } = require('../export-csv.js');
const { buildStudyLoadJson } = require('../export-json.js');
const data = {
year: '2024',
period: '1ST Semester',
totalUnits: '21.00',
header: {
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
semester: '1ST Semester 2024',
yearLevel: '3'
},
courses: [
{ group: '1', courseCode: 'IT 3101N', description: 'PLATFORM TECHNOLOGIES',
schedule: 'F 01:30 PM - 04:30 PM LB486', faculty: 'TAN', units: '3.0', status: 'REGULAR' },
{ group: '1', courseCode: 'CIS 1101', description: 'PROGRAMMING, INTRO',
schedule: 'T Th 03:00 PM - 04:30 PM LB468', faculty: '', units: '3.0', status: 'REGULAR' }
]
};
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ismis-export-'));
fs.writeFileSync(path.join(dir, 'study-load.xlsx'), Buffer.from(buildStudyLoadXlsx(data)));
fs.writeFileSync(path.join(dir, 'study-load.csv'), buildStudyLoadCsv(data), 'utf8');
fs.writeFileSync(path.join(dir, 'study-load.json'), JSON.stringify(buildStudyLoadJson(data), null, 2), 'utf8');
fs.writeFileSync(path.join(dir, 'study-load-nextup.csv'), buildNextUpCsv(data), 'utf8');
console.log('wrote sample exports to', dir);
// XLSX: require python3 + openpyxl, parse and assert key cells.
const { execSync } = require('child_process');
const pyFile = path.join(dir, 'check.py');
const py = `
import openpyxl
wb = openpyxl.load_workbook('${path.join(dir, 'study-load.xlsx')}')
ws = wb.active
assert ws.title == 'Study Load', ws.title
assert ws['B2'].value == 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY', ws['B2'].value
assert ws['B3'].value == 'ADRIAN ALFRED CHIONG BONPIN'
assert ws['B6'].value == '3'
assert ws['A10'].value == '1' and ws['B10'].value == 'IT 3101N'
assert ws['F10'].value == '3.0'
print('openpyxl OK')
`;
fs.writeFileSync(pyFile, py);
try {
execSync('python3 ' + JSON.stringify(pyFile), { stdio: 'inherit' });
} catch (e) {
console.error('openpyxl validation failed');
process.exit(1);
}
console.log('ALL EXPORT VALIDATION PASSED');
-102
View File
@@ -1,102 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const {
buildStudyLoadCsv, buildNextUpCsv, parseScheduleBlocks, csvField
} = require('../export-csv.js');
const { buildStudyLoadJson } = require('../export-json.js');
const { buildStudyLoadXlsx } = require('../export-xlsx.js');
const data = {
year: '2024',
period: '1ST Semester',
totalUnits: '21.00',
header: {
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
semester: '1ST Semester 2024',
yearLevel: '3'
},
courses: [
{ group: '1', courseCode: 'IT 3101N', description: 'PLATFORM TECHNOLOGIES',
schedule: 'F 01:30 PM - 04:30 PM LB486', faculty: 'TAN', units: '3.0', status: 'REGULAR' },
{ group: '1', courseCode: 'CIS 1101', description: 'PROGRAMMING I',
schedule: 'T Th 03:00 PM - 04:30 PM LB468', faculty: '', units: '3.0', status: 'REGULAR' },
{ group: '1', courseCode: 'CS 5105', description: 'COMP, TALKS "X"',
schedule: '', faculty: '', units: '3.00', status: 'DISSOLVED' }
]
};
// ---- CSV ----
test('buildStudyLoadCsv includes header block, table, and total', () => {
const csv = buildStudyLoadCsv(data);
assert.ok(csv.startsWith('\uFEFF'), 'has UTF-8 BOM');
assert.ok(csv.includes('Program,BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY'));
assert.ok(csv.includes('Student ID,22103625'));
assert.ok(csv.includes('Group,Course Code,Description,Schedule,Faculty,Units,Status'));
assert.ok(csv.includes('1,IT 3101N,PLATFORM TECHNOLOGIES,F 01:30 PM - 04:30 PM LB486,TAN,3.0,REGULAR'));
assert.ok(/Total,.*21\.00/.test(csv));
});
test('csvField quotes fields containing commas, quotes, newlines', () => {
assert.strictEqual(csvField('plain'), 'plain');
assert.strictEqual(csvField('a,b'), '"a,b"');
assert.strictEqual(csvField('say "hi"'), '"say ""hi"""');
});
// ---- NextUp ----
test('buildNextUpCsv matches the Next Up column contract', () => {
const csv = buildNextUpCsv(data).replace(/^\uFEFF/, '');
const header = csv.split('\r\n')[0];
assert.strictEqual(header,
'title,courseCode,days,startTime,endTime,room,meetingLink,color,icon,units,classLink,professorName,professorEmail,groupName');
// Schedule blocks -> one row per block, day codes and 24h times.
assert.ok(csv.includes('PLATFORM TECHNOLOGIES,IT 3101N,FR,13:30,16:30,LB486'));
assert.ok(csv.includes('PROGRAMMING I,CIS 1101,"TU,TH",15:00,16:30,LB468'));
});
test('parseScheduleBlocks splits multi-block schedules', () => {
const blocks = parseScheduleBlocks('M 08:00 AM - 09:00 AM R1; W 10:00 AM - 12:00 PM R2');
assert.ok(Array.isArray(blocks) && blocks.length >= 1);
assert.strictEqual(Math.max(blocks.length, 0) > 0, true);
});
// ---- JSON ----
test('buildStudyLoadJson has improved, clean schema', () => {
const j = buildStudyLoadJson(data);
assert.strictEqual(j.meta.program, data.header.program);
assert.strictEqual(j.meta.studentName, data.header.fullName);
assert.strictEqual(j.meta.academicYear, '2024');
assert.strictEqual(j.meta.yearLevel, '3');
assert.strictEqual(j.meta.totalUnits, '21.00');
assert.ok(j.meta.generatedAt && /ISO|Z|T/.test(j.meta.generatedAt));
assert.strictEqual(j.courses.length, 3);
assert.strictEqual(j.courses[0].courseCode, 'IT 3101N');
assert.deepStrictEqual(j.totals, { enrolledUnits: '21.00' });
});
// ---- XLSX ----
test('buildStudyLoadXlsx returns a valid ZIP with expected parts', () => {
const bytes = buildStudyLoadXlsx(data);
assert.ok(bytes instanceof Uint8Array);
// A valid ZIP starts with PK (0x50 0x4B).
assert.strictEqual(bytes[0], 0x50);
assert.strictEqual(bytes[1], 0x4b);
// Contains XML as text (spot check for the sheet + shared strings).
const asText = Buffer.from(bytes).toString('latin1');
assert.ok(asText.includes('Content_Types'), 'content types part present');
assert.ok(asText.includes('sharedStrings'), 'shared strings part present');
assert.ok(asText.includes('ISMIS HELPER - STUDY LOAD'), 'title present');
});
test('buildStudyLoadXlsx embeds course data as shared strings', () => {
const bytes = buildStudyLoadXlsx(data);
const asText = Buffer.from(bytes).toString('latin1');
assert.ok(asText.includes('PLATFORM TECHNOLOGIES'));
assert.ok(asText.includes('IT 3101N'));
assert.ok(asText.includes('21.00'));
});
-165
View File
@@ -1,165 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const {
parseGradeHistory, yearLevelForSemester,
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
computeYearStandingFromUnits
} = require('../parsers.js');
// ---- View Grades history ----
test('parseGradeHistory extracts ordered semester terms', () => {
const html = `
<h3>FIRST SEMESTER - 2022</h3><table>...</table>
<h3>SECOND SEMESTER - 2022</h3><table>...</table>
<h3>FIRST SEMESTER - 2023</h3><table>...</table>
<h3>SUMMER - 2025</h3><table>...</table>
<h3>FIRST SEMESTER - 2026</h3><table>...</table>
`;
const h = parseGradeHistory(html);
assert.deepStrictEqual(h, [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SUMMER', year: 2025 },
{ semester: 'FIRST', year: 2026 }
]);
});
test('parseGradeHistory handles empty input', () => {
assert.deepStrictEqual(parseGradeHistory(''), []);
});
test('yearLevelForSemester returns standing from history position', () => {
const history = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SECOND', year: 2023 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 },
{ semester: 'FIRST', year: 2025 },
{ semester: 'SECOND', year: 2025 },
{ semester: 'FIRST', year: 2026 }
];
// 2022 -> Y1, 2023 -> Y2, 2024 -> Y3, 2025 -> Y4, 2026 -> Y5
assert.strictEqual(yearLevelForSemester(history, 2022), 1);
assert.strictEqual(yearLevelForSemester(history, 2023), 2);
assert.strictEqual(yearLevelForSemester(history, 2024), 3);
assert.strictEqual(yearLevelForSemester(history, 2025), 4);
assert.strictEqual(yearLevelForSemester(history, 2026), 5);
// Summer of 2025 is the same standing as 2025 -> Year 4 (period is not
// passed in; the standing is purely the history position).
assert.strictEqual(yearLevelForSemester(history, 2025), 4);
});
test('yearLevelForSemester clamps to program length and handles gaps', () => {
// History ends at 2025; a query beyond it must not exceed the final level.
const history = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SECOND', year: 2023 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 },
{ semester: 'FIRST', year: 2025 },
{ semester: 'SECOND', year: 2025 }
];
assert.strictEqual(yearLevelForSemester(history, 2028), 4); // clamped
// Gap year: a skipped academic year still advances standing.
const gap = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 }
];
assert.strictEqual(yearLevelForSemester(gap, 2024), 2);
});
test('yearLevelForSemester handles missing year and empty history', () => {
assert.strictEqual(yearLevelForSemester([], 2024), null);
assert.strictEqual(yearLevelForSemester([{ semester: 'FIRST', year: 2022 }], NaN), null);
});
// ---- Prospectus ----
test('parseProspectusYearUnits totals units per year level', () => {
const html = `
<h3>Year Level: 1</h3>
<table><tr><th>Course</th></tr>
<tr><td>CIS 1101</td><td>PROGRAMMING I</td><td>3.0</td></tr>
<tr><td>CIS 1102</td><td>INTRO</td><td>3.0</td></tr>
</table>
<h3>Year Level: 2</h3>
<table><tr><th>Course</th></tr>
<tr><td>CIS 2101</td><td>DATA STRUCTURES</td><td>3.0</td></tr>
</table>
<h3>Year Level: 3</h3>
<table><tr><th>Course</th></tr>
<tr><td>IT 3101N</td><td>PLATFORM</td><td>4.0</td></tr>
</table>
<h3>Year Level: 4</h3>
<table><tr><th>Course</th></tr>
<tr><td>IT 4101</td><td>CAPSTONE</td><td>2.0</td></tr>
</table>
`;
const y = parseProspectusYearUnits(html);
assert.deepStrictEqual(y, [
{ year: 1, units: 6 },
{ year: 2, units: 3 },
{ year: 3, units: 4 },
{ year: 4, units: 2 }
]);
});
// ---- Lacking subjects ----
test('parseLackingSubjects returns remaining units and courses', () => {
const html = `
<table>
<tr><th>Course Code</th><th>Description</th><th>Units</th></tr>
<tr><td>IT 3206</td><td>INTEGRATIVE PROGRAMMING</td><td>3.0</td></tr>
<tr><td>IT FREE EL</td><td>FREE ELECTIVE</td><td>3.0</td></tr>
<tr><td>IT 4202N</td><td>PRACTICUM 2</td><td>6.0</td></tr>
</table>
`;
const l = parseLackingSubjects(html);
assert.strictEqual(l.units, '12.00');
assert.strictEqual(l.courses.length, 3);
assert.strictEqual(parseLackingSubjectsUnits(html), '12.00');
});
test('parseLackingSubjects handles empty input', () => {
assert.strictEqual(parseLackingSubjectsUnits(''), '0.00');
});
// ---- Units math ----
test('computeYearStandingFromUnits maps finished units to year standing', () => {
// Cumulative 52/99/148/181 (matches the real BSIT prospectus).
const yearUnits = [
{ year: 1, units: 52 },
{ year: 2, units: 47 },
{ year: 3, units: 49 },
{ year: 4, units: 33 }
];
// finished = 181 - 24 = 157 -> Year 4 (real user data).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 24), 4);
// No lacking -> finished everything -> final year.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 0), 4);
// finished 40 -> still Year 1 (< 52).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 141), 1);
// finished 60 -> Year 2 (52 <= 60 < 99).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 121), 2);
// exact boundary: finished 52 -> Year 2.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 129), 2);
// finished 99 -> Year 3.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 82), 3);
});
test('computeYearStandingFromUnits handles empty and negative', () => {
assert.strictEqual(computeYearStandingFromUnits([], 5), null);
// lacking > total -> clamps to first year.
assert.strictEqual(computeYearStandingFromUnits([{ year: 1, units: 10 }], 99), 1);
});