4 Commits
Author SHA1 Message Date
adrianbonpin 0cb70ecee0 feat(v1.4.0): exports + smart year level + progress + encrypted passwords
ci / test (push) Successful in 43s
build-extension / build (push) Successful in 6m7s
v1.4.0: CSV/Excel/JSON/NextUp exports, units-first smart year standing, live export progress bar, AES-GCM at-rest password encryption.
2026-08-20 04:39:08 +00:00
adrianbonpin 89fd587c21 fix: use blob URL for Firefox downloads (data: URLs access-denied) (#3)
build-extension / build (push) Successful in 34s
2026-08-18 20:12:20 +00:00
adrianbonpin d08b1d75aa ci: use Gitea API for releases (softprops/action-gh-release targets GitHub, fails on Gitea with 405)
build-extension / build (push) Successful in 36s
2026-08-19 03:25:12 +08:00
adrianbonpin 9cc4fa5c97 ISMIS Helper: cross-browser build, CI, PDF fixes, Chrome download fix
build-extension / build (push) Successful in 37s
Squash merge of feat/ismis-helper into prod.
2026-08-18 19:13:49 +00:00
19 changed files with 5442 additions and 83 deletions
+24 -14
View File
@@ -1,10 +1,9 @@
name: build-extension
# Build + release only on version tags. Daily dev/test runs live in ci.yml.
on:
push:
branches: [feat/ismis-helper, prod]
tags: ['v*']
pull_request:
# Ensure the job token can create releases and upload assets.
permissions:
@@ -26,27 +25,37 @@ jobs:
- name: Install dev dependencies
run: npm ci || npm install
- name: Install PyMuPDF (PDF text assertions)
- name: Install PyMuPDF + openpyxl (PDF text + XLSX validation)
run: |
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet pymupdf
/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: Upload zip artifacts
uses: actions/upload-artifact@v4
with:
name: ismis-helper-zips
path: dist/*.zip
if-no-files-found: error
- 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 on version tag
if: startsWith(github.ref, 'refs/tags/v')
- 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.
@@ -64,8 +73,9 @@ jobs:
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"ISMIS Helper ${TAG}\"}" \
"${API}/repos/${REPO}/releases")
RELEASE_ID=$(echo "${RELEASE_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Attach both zips as release assets
for f in dist/*.zip; do
# Attach zips and the signed xpi (if present) as release assets
for f in dist/*.zip dist/*.xpi; do
[ -f "$f" ] || continue
curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-F "attachment=@${f}" \
+35
View File
@@ -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
+3
View File
@@ -2,3 +2,6 @@ node_modules/
docs/
dist/
.env
.env.*
+27 -4
View File
@@ -84,12 +84,27 @@ built `ismis-helper-chrome.zip` directly:
- **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.
- **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).
- Builds and packages automatically via Gitea Actions (zips + versioned releases
with a signed `.xpi`).
## Build
@@ -114,11 +129,19 @@ additionally creates a **Gitea Release** with both versioned zips attached.
Open the extension popup to:
- Manage login profiles (add / edit / enable).
- Toggle the master on/off switch.
- Set PDF options: truncate descriptions, show/hide `Year Level`, custom Year Level.
- 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
- Auto-login stores your portal credentials **in plaintext** on your own device.
- 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.
+120 -29
View File
@@ -10,20 +10,25 @@ function errMsg(err) {
return String((err && err.message) || err);
}
// Build a download URL from PDF bytes. Chrome MV3 runs the background as a
// service worker, where URL.createObjectURL is NOT available, so we use a
// base64 data URL (works in both Firefox and Chrome). PDFs here are small
// (~150KB), well within data-URL length limits.
function pdfDataUrl(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return 'data:application/pdf;base64,' + btoa(binary);
}
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === '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 }))
@@ -32,6 +37,107 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
});
// ---- Password encryption (at-rest) ----
// AES-GCM with a random 256-bit key. The key is stored in browser.storage.local
// so it survives browser/service-worker restarts (otherwise previously-encrypted
// passwords could never be decrypted again). This removes the plaintext-password
// smell from storage — it is NOT strong protection against an attacker who can
// read both the storage and the extension source (the user's threat model is
// only the school portal, no third-party exfiltration).
const CRYPTO_KEY_STORE = 'ismisCryptoKey';
async function getCryptoKey() {
const { [CRYPTO_KEY_STORE]: stored } = await browser.storage.local.get(CRYPTO_KEY_STORE);
if (stored) {
return crypto.subtle.importKey('raw', new Uint8Array(stored), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const raw = await crypto.subtle.exportKey('raw', key);
await browser.storage.local.set({ [CRYPTO_KEY_STORE]: Array.from(new Uint8Array(raw)) });
return key;
}
async function encryptPassword(password) {
const key = await getCryptoKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(password));
return { iv: Array.from(iv), data: Array.from(new Uint8Array(data)) };
}
async function decryptPassword(cipher) {
if (!cipher || !cipher.iv || !cipher.data) throw new Error('Invalid encrypted password');
const key = await getCryptoKey();
const pt = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(cipher.iv) }, key, new Uint8Array(cipher.data));
return new TextDecoder().decode(pt);
}
// Generic downloader used by every export format. `filename` is used with
// saveAs so the user picks the location, mirroring the PDF flow.
async function downloadBytes(bytes, mime, filename, fallback) {
const safeFilename = filename || fallback;
const blob = new Blob([bytes], { type: mime });
const url = (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function')
? URL.createObjectURL(blob)
: null;
let downloadUrl = url;
if (!downloadUrl) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
downloadUrl = 'data:' + mime + ';base64,' + btoa(binary);
}
try {
const id = await browser.downloads.download({ url: downloadUrl, filename: safeFilename, saveAs: true });
const onChanged = (delta) => {
if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) {
browser.downloads.onChanged.removeListener(onChanged);
}
};
browser.downloads.onChanged.addListener(onChanged);
} catch (err) {
if (url) { try { URL.revokeObjectURL(url); } catch (e) {} }
throw err;
}
}
async function runExport(format, data) {
const header = data.header || {};
const year = parseInt(data.year, 10);
const period = formatPeriod(data.period || header.semester || '');
const compactProgram = ISMIStools.compactProgram(header.program || '');
const schoolYear = Number.isFinite(year) ? year + '-' + (year + 1) : '';
const core = [
header.studentId || (header.fullName ? header.fullName.replace(/\s+/g, ' ') : ''),
compactProgram,
schoolYear ? 'S.Y. ' + schoolYear : '',
period
].filter(Boolean);
const base = core.join(' - ') || 'Study Load';
if (format === 'csv') {
const text = ISMIStools.buildStudyLoadCsv(data);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'text/csv', base + '.csv', 'Study Load.csv');
} else if (format === 'json') {
const text = JSON.stringify(ISMIStools.buildStudyLoadJson(data), null, 2);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'application/json', base + '.json', 'Study Load.json');
} else if (format === 'xlsx') {
const bytes = ISMIStools.buildStudyLoadXlsx(data);
await downloadBytes(bytes, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', base + '.xlsx', 'Study Load.xlsx');
} else if (format === 'nextup') {
const text = ISMIStools.buildNextUpCsv(data);
const bytes = new TextEncoder().encode(text);
await downloadBytes(bytes, 'text/csv', base + '-nextup.csv', 'Study Load - nextup.csv');
} else {
throw new Error('Unknown export format: ' + format);
}
}
function formatPeriod(period) {
if (!period) return '';
return String(period)
@@ -45,9 +151,6 @@ function formatPeriod(period) {
async function generatePdf(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 year = parseInt(data.year, 10);
const period = formatPeriod(data.period || header.semester || '');
@@ -62,17 +165,5 @@ async function generatePdf(data) {
].filter(Boolean);
const filename = parts.join(' - ') + '.pdf';
const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf';
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;
}
await downloadBytes(bytes, 'application/pdf', filename, fallback);
}
+227 -16
View File
@@ -39,7 +39,20 @@
const passField = document.querySelector('input[type="password"]');
if (!userField || !passField) return;
userField.value = active.username;
passField.value = active.password;
// 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');
@@ -94,9 +107,10 @@
return m ? m[1] : '';
}
async function scrapeStudyLoad() {
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.');
@@ -117,16 +131,21 @@
const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))];
const descMap = {};
for (const code of uniqueCodes) {
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);
@@ -141,9 +160,77 @@
? 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';
@@ -166,39 +253,148 @@
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...';
const { header, courses, period: selectedPeriod } = await scrapeStudyLoad();
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', '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.
const settings = await browser.storage.local.get(['truncateDescription', 'showYearLevel']);
header.showYearLevel = settings.showYearLevel !== false;
const custom = String(settings.customYearLevel || '').trim();
if (custom) {
header.yearLevel = custom;
} else if (/summer/i.test(selectedPeriod || '')) {
const n = parseInt(header.yearLevel, 10);
if (Number.isFinite(n) && n > 1) header.yearLevel = String(n - 1);
}
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();
@@ -209,6 +405,7 @@
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;
@@ -217,8 +414,22 @@
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(btn);
wrapper.appendChild(btnGroup);
wrapper.appendChild(makeProgressEl());
wrapper.appendChild(unitsP);
}
+128
View File
@@ -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 };
});
+45
View File
@@ -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
View File
@@ -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, '&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,
"name": "ISMIS Helper",
"version": "1.2.0",
"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", "background.js"]
"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": [
{
+4105 -1
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -5,9 +5,11 @@
"description": "Firefox extension for the USC ISMIS portal",
"scripts": {
"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": {
"pdf-lib": "^1.17.1"
"pdf-lib": "^1.17.1",
"web-ext": "^8.3.0"
}
}
+108 -1
View File
@@ -81,6 +81,107 @@
}));
}
// ---- 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'
@@ -93,5 +194,11 @@
.toFixed(2);
}
return { parseStudyLoadTable, parseProfilePage, parseOfferedCourses, filterStudyLoadCourses, sumUnits };
return {
parseStudyLoadTable, parseProfilePage, parseOfferedCourses,
filterStudyLoadCourses, sumUnits,
parseGradeHistory, yearLevelForSemester,
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
computeYearStandingFromUnits
};
});
+9 -1
View File
@@ -36,10 +36,18 @@
<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">Leave blank to use the scraped value (auto-adjusted in Summer).</p>
<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>
+41 -12
View File
@@ -25,10 +25,11 @@ $('#masterToggle').addEventListener('change', async (e) => {
// ---- PDF settings: truncate long course descriptions (default ON) ----
async function loadPdfSettings() {
const s = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
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 });
@@ -36,6 +37,9 @@ $('#truncateToggle').addEventListener('change', async (e) => {
$('#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() });
});
@@ -96,27 +100,52 @@ function openProfileForm(p) {
editingProfileId = p ? p.id : null;
$('#pName').value = p ? p.name : '';
$('#pUsername').value = p ? p.username : '';
$('#pPassword').value = p ? p.password : '';
// 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 data = {
id: editingProfileId || Date.now().toString(),
name: $('#pName').value.trim(),
username: $('#pUsername').value.trim(),
password: $('#pPassword').value,
enabled: $('#pEnabled').checked
};
if (!data.name || !data.username) return;
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] = data;
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 {
profiles.push(data);
const profile = {
id: Date.now().toString(),
name,
username,
enabled,
passwordEnc
};
profiles.push(profile);
}
await saveProfiles(profiles);
$('#profileForm').hidden = true;
+3
View File
@@ -14,6 +14,9 @@ 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',
];
+64
View File
@@ -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');
+102
View File
@@ -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'));
});
+165
View File
@@ -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);
});