fix(release): URL-encode release asset names #5
+30
-15
@@ -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,10 +73,16 @@ 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
|
||||
NAME="$(basename "$f")"
|
||||
# URL-encode the asset name so characters like '+' (in
|
||||
# 'ismis-helper-1.4.0-an+fx.xpi') survive the query string instead
|
||||
# of being decoded to a space.
|
||||
ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$NAME")
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-F "attachment=@${f}" \
|
||||
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$(basename "${f}")"
|
||||
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ENC}"
|
||||
done
|
||||
@@ -0,0 +1,35 @@
|
||||
name: ci
|
||||
|
||||
# Fast test feedback on daily development. No builds, no releases — those only
|
||||
# happen on v* tags via build.yml.
|
||||
on:
|
||||
push:
|
||||
branches: [feat/ismis-helper, feat/ismis-helper-v1.4.0, prod]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dev dependencies
|
||||
run: npm ci || npm install
|
||||
|
||||
- name: Install PyMuPDF + openpyxl (PDF text + XLSX validation)
|
||||
run: |
|
||||
python3 -m venv /tmp/venv
|
||||
/tmp/venv/bin/pip install --quiet pymupdf openpyxl
|
||||
echo "/tmp/venv/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Validate exports open in real readers
|
||||
run: node scripts/validate-exports.js
|
||||
@@ -2,3 +2,6 @@ node_modules/
|
||||
|
||||
docs/
|
||||
dist/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -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
-37
@@ -10,28 +10,25 @@ function errMsg(err) {
|
||||
return String((err && err.message) || err);
|
||||
}
|
||||
|
||||
// Build a download URL from PDF bytes. Firefox's background is a real page
|
||||
// (URL.createObjectURL available) and supports blob: URLs in downloads.
|
||||
// Chrome MV3 runs the background as a service worker where URL.createObjectURL
|
||||
// is NOT available, but its downloads API accepts base64 data: URLs.
|
||||
// Firefox rejects data: URLs in browser.downloads.download with
|
||||
// "Access denied for URL data:...", so pick per-browser.
|
||||
function buildDownloadUrl(bytes) {
|
||||
if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function') {
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
// Fallback: Chrome MV3 service worker. PDFs here are small (~150KB), well
|
||||
// within data-URL length limits.
|
||||
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 }))
|
||||
@@ -40,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)
|
||||
@@ -53,9 +151,6 @@ function formatPeriod(period) {
|
||||
|
||||
async function generatePdf(data) {
|
||||
const bytes = await ISMIStools.buildStudyLoadPdf(data);
|
||||
// Firefox accepts blob: object URLs; Chrome MV3 service worker has no
|
||||
// URL.createObjectURL, so it falls back to a base64 data: URL there.
|
||||
const url = buildDownloadUrl(bytes);
|
||||
const header = data.header || {};
|
||||
const year = parseInt(data.year, 10);
|
||||
const period = formatPeriod(data.period || header.semester || '');
|
||||
@@ -70,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
@@ -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
@@ -0,0 +1,128 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = factory();
|
||||
} else {
|
||||
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
// RFC-4180 field encoding: quote fields containing commas, quotes, or
|
||||
// newlines; double up embedded quotes.
|
||||
function csvField(value) {
|
||||
let s = String(value == null ? '' : value);
|
||||
if (/[",\r\n]/.test(s)) {
|
||||
s = '"' + s.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function csvRow(cells) {
|
||||
return cells.map(csvField).join(',') + '\r\n';
|
||||
}
|
||||
|
||||
// Build a CSV representation of the study load.
|
||||
// data: { header, courses, totalUnits }
|
||||
// header fields: program, studentName, studentId, semester, yearLevel
|
||||
function buildStudyLoadCsv(data) {
|
||||
const header = data.header || {};
|
||||
const lines = [];
|
||||
lines.push(csvRow(['ISMIS HELPER - STUDY LOAD']));
|
||||
lines.push(csvRow(['Program', header.program || '']));
|
||||
lines.push(csvRow(['Student Name', header.fullName || header.studentName || '']));
|
||||
lines.push(csvRow(['Student ID', header.studentId || '']));
|
||||
lines.push(csvRow(['Semester', header.semester || '']));
|
||||
lines.push(csvRow(['Year Level', header.yearLevel || '']));
|
||||
lines.push(csvRow(['Total Units', data.totalUnits || '']));
|
||||
lines.push(''); // blank separator line
|
||||
lines.push(csvRow(['Group', 'Course Code', 'Description', 'Schedule', 'Faculty', 'Units', 'Status']));
|
||||
for (const c of (data.courses || [])) {
|
||||
lines.push(csvRow([
|
||||
c.group, c.courseCode, c.description, c.schedule,
|
||||
c.faculty || c.teacher, c.units, c.status
|
||||
]));
|
||||
}
|
||||
lines.push(csvRow(['Total', '', '', '', '', data.totalUnits || '']));
|
||||
lines.push(''); // final blank line, per RFC
|
||||
// UTF-8 BOM so Excel opens it without mojibake.
|
||||
return '\uFEFF' + lines.join('');
|
||||
}
|
||||
|
||||
// Build a Next Up import CSV following the exact column contract in
|
||||
// raycast/nextup/src/lib/csv-utils.ts:
|
||||
// title, courseCode, days, startTime, endTime, room, meetingLink,
|
||||
// color, icon, units, classLink, professorName, professorEmail, groupName
|
||||
// One row per schedule block (a course may have multiple meeting segments).
|
||||
function buildNextUpCsv(data, parseSchedule) {
|
||||
const lines = [];
|
||||
lines.push(csvRow(['title', 'courseCode', 'days', 'startTime', 'endTime',
|
||||
'room', 'meetingLink', 'color', 'icon', 'units', 'classLink',
|
||||
'professorName', 'professorEmail', 'groupName']));
|
||||
const resolver = parseSchedule || parseScheduleBlocks;
|
||||
const groupName = (data.header && (data.header.semester || '')) || '';
|
||||
for (const c of (data.courses || [])) {
|
||||
const blocks = resolver(c.schedule);
|
||||
if (!blocks.length) {
|
||||
lines.push(csvRow([c.description || c.courseCode, c.courseCode, '',
|
||||
'', '', '', '', '', '', c.units, '', c.faculty || '', '', groupName]));
|
||||
continue;
|
||||
}
|
||||
for (const b of blocks) {
|
||||
lines.push(csvRow([
|
||||
c.description || c.courseCode,
|
||||
c.courseCode,
|
||||
b.days,
|
||||
b.startTime,
|
||||
b.endTime,
|
||||
b.room || '',
|
||||
'', '', '',
|
||||
c.units,
|
||||
'',
|
||||
c.faculty || '',
|
||||
'',
|
||||
groupName
|
||||
]));
|
||||
}
|
||||
}
|
||||
return '\uFEFF' + lines.join('');
|
||||
}
|
||||
|
||||
// Split a study-load schedule string like
|
||||
// "F 01:30 PM - 04:30 PM LB486TC TC; T Th 03:00 PM - 04:30 PM LB468"
|
||||
// into [{ days: 'MO,TU', startTime: '01:30', endTime: '16:30', room }]
|
||||
// Day codes map to the 3-letter codes Next Up expects (MO,TU,WE,TH,FR).
|
||||
function parseScheduleBlocks(schedule) {
|
||||
const s = String(schedule || '').trim();
|
||||
if (!s) return [];
|
||||
const blocks = [];
|
||||
const segRe = /([A-Za-z]{1,3}(?:\s+[A-Za-z]{1,3})*)\s+(\d{1,2}):(\d{2})\s*(AM|PM)\s*-\s*(\d{1,2}):(\d{2})\s*(AM|PM)\s*([A-Za-z0-9]*)/gi;
|
||||
const dayMap = {
|
||||
M: 'MO', MO: 'MO', MON: 'MO',
|
||||
T: 'TU', TU: 'TU', TUE: 'TU',
|
||||
W: 'WE', WE: 'WE', WED: 'WE',
|
||||
TH: 'TH', THU: 'TH', THUR: 'TH', THURS: 'TH',
|
||||
F: 'FR', FR: 'FR', FRI: 'FR',
|
||||
SA: 'SA', SAT: 'SA',
|
||||
S: 'SU', SU: 'SU', SUN: 'SU', SUNDAY: 'SU'
|
||||
};
|
||||
let m;
|
||||
while ((m = segRe.exec(s)) !== null) {
|
||||
const dayToCode = (d) => dayMap[d.toUpperCase().replace(/\./g, '')] || '';
|
||||
const days = m[1].split(/\s+/).map(dayToCode).filter(Boolean);
|
||||
const to24 = (h, ampm, minute) => {
|
||||
let hour = parseInt(h, 10) % 12;
|
||||
if (/PM/i.test(ampm)) hour += 12;
|
||||
return String(hour).padStart(2, '0') + ':' + minute;
|
||||
};
|
||||
blocks.push({
|
||||
days: days.join(','),
|
||||
startTime: to24(m[2], m[4], m[3]),
|
||||
endTime: to24(m[5], m[7], m[6]),
|
||||
room: m[8] || ''
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
return { buildStudyLoadCsv, buildNextUpCsv, parseScheduleBlocks, csvField };
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = factory();
|
||||
} else {
|
||||
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
// Build a clean, reusable JSON representation of the study load.
|
||||
// data: { header, courses, totalUnits, year, period }
|
||||
function buildStudyLoadJson(data) {
|
||||
const header = data.header || {};
|
||||
const courses = (data.courses || []).map((c) => ({
|
||||
group: c.group || '',
|
||||
courseCode: c.courseCode || '',
|
||||
description: c.description || '',
|
||||
schedule: c.schedule || '',
|
||||
faculty: (c.faculty || c.teacher) || '',
|
||||
units: c.units || '',
|
||||
status: c.status || ''
|
||||
}));
|
||||
return {
|
||||
meta: {
|
||||
program: header.program || '',
|
||||
studentName: header.fullName || header.studentName || '',
|
||||
studentId: header.studentId || '',
|
||||
academicYear: data.year ? String(data.year) : '',
|
||||
semester: header.semester || data.period || '',
|
||||
yearLevel: header.yearLevel || '',
|
||||
totalUnits: data.totalUnits || '',
|
||||
generatedAt: new Date().toISOString(),
|
||||
extensionVersion: data.extensionVersion || ''
|
||||
},
|
||||
courses,
|
||||
totals: {
|
||||
// A study load lists the courses the student is enrolled in for the
|
||||
// term; there is no "completed" concept here.
|
||||
enrolledUnits: data.totalUnits || ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { buildStudyLoadJson };
|
||||
});
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = factory();
|
||||
} else {
|
||||
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
// ---- Minimal ZIP writer (STORED, no compression) ----
|
||||
// Produces a valid ZIP containing the XLSX parts. Stored entries are used
|
||||
// because we have no deflate available (browser + zero-dependency Node).
|
||||
function crc32(buf) {
|
||||
let table = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function zipStore(entries) {
|
||||
// entries: [{ name, data: Uint8Array }]
|
||||
const chunks = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
const enc = new TextEncoder();
|
||||
for (const e of entries) {
|
||||
const nameBytes = enc.encode(e.name);
|
||||
const crc = crc32(e.data);
|
||||
const local = new Uint8Array(30 + nameBytes.length);
|
||||
const dv = new DataView(local.buffer);
|
||||
dv.setUint32(0, 0x04034b50, true); // local file header sig
|
||||
dv.setUint16(4, 20, true); // version needed
|
||||
dv.setUint16(6, 0, true); // flags
|
||||
dv.setUint16(8, 0, true); // method: stored
|
||||
dv.setUint16(10, 0, true); // mod time
|
||||
dv.setUint16(12, 0x21, true); // mod date
|
||||
dv.setUint32(14, crc, true);
|
||||
dv.setUint32(18, e.data.length, true); // compressed size
|
||||
dv.setUint32(22, e.data.length, true); // uncompressed size
|
||||
dv.setUint16(26, nameBytes.length, true); // name len
|
||||
dv.setUint16(28, 0, true); // extra len
|
||||
local.set(nameBytes, 30);
|
||||
chunks.push(local, e.data);
|
||||
|
||||
const cd = new Uint8Array(46 + nameBytes.length);
|
||||
const cdv = new DataView(cd.buffer);
|
||||
cdv.setUint32(0, 0x02014b50, true); // central dir sig
|
||||
cdv.setUint16(4, 20, true); // version made by
|
||||
cdv.setUint16(6, 20, true); // version needed
|
||||
cdv.setUint16(8, 0, true); // flags
|
||||
cdv.setUint16(10, 0, true); // method stored
|
||||
cdv.setUint16(12, 0, true); // mod time
|
||||
cdv.setUint16(14, 0x21, true); // mod date
|
||||
cdv.setUint32(16, crc, true);
|
||||
cdv.setUint32(20, e.data.length, true);
|
||||
cdv.setUint32(24, e.data.length, true);
|
||||
cdv.setUint16(28, nameBytes.length, true);
|
||||
cdv.setUint16(30, 0, true); // extra len
|
||||
cdv.setUint16(32, 0, true); // comment len
|
||||
cdv.setUint16(34, 0, true); // disk start
|
||||
cdv.setUint16(36, 0, true); // internal attrs
|
||||
cdv.setUint32(38, 0, true); // external attrs
|
||||
cdv.setUint32(42, offset, true); // local header offset
|
||||
cd.set(nameBytes, 46);
|
||||
central.push(cd);
|
||||
|
||||
offset += local.length + e.data.length;
|
||||
}
|
||||
|
||||
const cdStart = offset;
|
||||
let cdLen = 0;
|
||||
for (const c of central) cdLen += c.length;
|
||||
const eocd = new Uint8Array(22);
|
||||
const eocdv = new DataView(eocd.buffer);
|
||||
eocdv.setUint32(0, 0x06054b50, true);
|
||||
eocdv.setUint16(8, entries.length, true); // total entries (this disk)
|
||||
eocdv.setUint16(10, entries.length, true);
|
||||
eocdv.setUint32(12, cdLen, true);
|
||||
eocdv.setUint32(16, cdStart, true);
|
||||
|
||||
chunks.push(...central, eocd);
|
||||
const total = chunks.reduce((s, c) => s + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let pos = 0;
|
||||
for (const c of chunks) { out.set(c, pos); pos += c.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- XML helpers ----
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ---- XLSX builder ----
|
||||
// Builds a single-sheet workbook: a title/header block + a course table
|
||||
// + a totals row, mirroring the CSV layout.
|
||||
function buildStudyLoadXlsx(data) {
|
||||
const header = data.header || {};
|
||||
const enc = new TextEncoder();
|
||||
// Gather all strings into a `sharedStrings` table; cells reference them.
|
||||
const strings = [];
|
||||
const sIndex = new Map();
|
||||
function addS(str) {
|
||||
const key = String(str == null ? '' : str);
|
||||
if (!sIndex.has(key)) { sIndex.set(key, strings.length); strings.push(key); }
|
||||
return sIndex.get(key);
|
||||
}
|
||||
|
||||
// rows: array of arrays of { text } (all text cells for simplicity)
|
||||
const rows = [];
|
||||
rows.push([{ text: 'ISMIS HELPER - STUDY LOAD' }]);
|
||||
rows.push([{ text: 'Program' }, { text: header.program || '' }]);
|
||||
rows.push([{ text: 'Student Name' }, { text: header.fullName || header.studentName || '' }]);
|
||||
rows.push([{ text: 'Student ID' }, { text: header.studentId || '' }]);
|
||||
rows.push([{ text: 'Semester' }, { text: header.semester || '' }]);
|
||||
rows.push([{ text: 'Year Level' }, { text: header.yearLevel || '' }]);
|
||||
rows.push([{ text: 'Total Units' }, { text: data.totalUnits || '' }]);
|
||||
rows.push([]); // blank
|
||||
rows.push([{ text: 'Group' }, { text: 'Course Code' }, { text: 'Description' },
|
||||
{ text: 'Schedule' }, { text: 'Faculty' }, { text: 'Units' }, { text: 'Status' }]);
|
||||
for (const c of (data.courses || [])) {
|
||||
rows.push([
|
||||
{ text: c.group }, { text: c.courseCode }, { text: c.description },
|
||||
{ text: c.schedule }, { text: c.faculty || c.teacher }, { text: c.units },
|
||||
{ text: c.status }
|
||||
]);
|
||||
}
|
||||
rows.push([{ text: 'Total' }, { text: '' }, { text: '' }, { text: '' },
|
||||
{ text: '' }, { text: data.totalUnits || '' }, { text: '' }]);
|
||||
|
||||
// Pre-compute shared string refs so sheet1.xml can reference them.
|
||||
const cellRefs = rows.map((row) => row.map((c) => c.text === '' ? -1 : addS(c.text)));
|
||||
|
||||
// ---- sheet1.xml ----
|
||||
let sheetXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
|
||||
sheetXml += '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">';
|
||||
sheetXml += '<cols>' +
|
||||
'<col min="1" max="1" width="8" customWidth="1"/>' +
|
||||
'<col min="2" max="2" width="14" customWidth="1"/>' +
|
||||
'<col min="3" max="3" width="42" customWidth="1"/>' +
|
||||
'<col min="4" max="4" width="38" customWidth="1"/>' +
|
||||
'<col min="5" max="5" width="24" customWidth="1"/>' +
|
||||
'<col min="6" max="6" width="10" customWidth="1"/>' +
|
||||
'<col min="7" max="7" width="12" customWidth="1"/>' +
|
||||
'</cols>';
|
||||
sheetXml += '<sheetData>';
|
||||
const colNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
|
||||
cellRefs.forEach((refs, ri) => {
|
||||
sheetXml += '<row r="' + (ri + 1) + '">';
|
||||
refs.forEach((s, ci) => {
|
||||
if (s < 0) return;
|
||||
sheetXml += '<c r="' + colNames[ci] + (ri + 1) + '" t="s"><v>' + s + '</v></c>';
|
||||
});
|
||||
sheetXml += '</row>';
|
||||
});
|
||||
sheetXml += '</sheetData></worksheet>';
|
||||
|
||||
// ---- sharedStrings.xml ----
|
||||
let ssXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
|
||||
ssXml += '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' +
|
||||
cellRefs.reduce((s, r) => s + r.filter((x) => x >= 0).length, 0) +
|
||||
'" uniqueCount="' + strings.length + '">';
|
||||
for (const s of strings) ssXml += '<si><t>' + esc(s) + '</t></si>';
|
||||
ssXml += '</sst>';
|
||||
|
||||
// ---- other parts ----
|
||||
const ct = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
|
||||
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' +
|
||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
|
||||
'</Types>';
|
||||
|
||||
const rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
||||
'</Relationships>';
|
||||
|
||||
const wb = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
||||
'<sheets><sheet name="Study Load" sheetId="1" r:id="rId1"/></sheets></workbook>';
|
||||
|
||||
const wbRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
|
||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' +
|
||||
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
|
||||
'</Relationships>';
|
||||
|
||||
const xlRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
||||
'</Relationships>';
|
||||
|
||||
const styles = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>' +
|
||||
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>' +
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
|
||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
|
||||
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>' +
|
||||
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
|
||||
'</styleSheet>';
|
||||
|
||||
const parts = [
|
||||
{ name: '[Content_Types].xml', data: enc.encode(ct) },
|
||||
{ name: '_rels/.rels', data: enc.encode(rels) },
|
||||
{ name: 'xl/workbook.xml', data: enc.encode(wb) },
|
||||
{ name: 'xl/_rels/workbook.xml.rels', data: enc.encode(wbRels) },
|
||||
{ name: 'xl/worksheets/sheet1.xml', data: enc.encode(sheetXml) },
|
||||
{ name: 'xl/sharedStrings.xml', data: enc.encode(ssXml) },
|
||||
{ name: 'xl/styles.xml', data: enc.encode(styles) }
|
||||
];
|
||||
return zipStore(parts);
|
||||
}
|
||||
|
||||
return { buildStudyLoadXlsx };
|
||||
});
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "ISMIS Helper",
|
||||
"version": "1.3.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": [
|
||||
{
|
||||
|
||||
Generated
+4105
-1
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
// CI-only validation: build a sample study-load XLSX/CSV/JSON and confirm they
|
||||
// open in real readers. The XLSX is re-parsed with python3 openpyxl (installed
|
||||
// in CI), which catches malformed XML / cell-reference issues that the Node
|
||||
// ZIP-structure tests cannot.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { buildStudyLoadXlsx } = require('../export-xlsx.js');
|
||||
const { buildStudyLoadCsv, buildNextUpCsv } = require('../export-csv.js');
|
||||
const { buildStudyLoadJson } = require('../export-json.js');
|
||||
|
||||
const data = {
|
||||
year: '2024',
|
||||
period: '1ST Semester',
|
||||
totalUnits: '21.00',
|
||||
header: {
|
||||
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
|
||||
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
|
||||
studentId: '22103625',
|
||||
semester: '1ST Semester 2024',
|
||||
yearLevel: '3'
|
||||
},
|
||||
courses: [
|
||||
{ group: '1', courseCode: 'IT 3101N', description: 'PLATFORM TECHNOLOGIES',
|
||||
schedule: 'F 01:30 PM - 04:30 PM LB486', faculty: 'TAN', units: '3.0', status: 'REGULAR' },
|
||||
{ group: '1', courseCode: 'CIS 1101', description: 'PROGRAMMING, INTRO',
|
||||
schedule: 'T Th 03:00 PM - 04:30 PM LB468', faculty: '', units: '3.0', status: 'REGULAR' }
|
||||
]
|
||||
};
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ismis-export-'));
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'study-load.xlsx'), Buffer.from(buildStudyLoadXlsx(data)));
|
||||
fs.writeFileSync(path.join(dir, 'study-load.csv'), buildStudyLoadCsv(data), 'utf8');
|
||||
fs.writeFileSync(path.join(dir, 'study-load.json'), JSON.stringify(buildStudyLoadJson(data), null, 2), 'utf8');
|
||||
fs.writeFileSync(path.join(dir, 'study-load-nextup.csv'), buildNextUpCsv(data), 'utf8');
|
||||
|
||||
console.log('wrote sample exports to', dir);
|
||||
|
||||
// XLSX: require python3 + openpyxl, parse and assert key cells.
|
||||
const { execSync } = require('child_process');
|
||||
const pyFile = path.join(dir, 'check.py');
|
||||
const py = `
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook('${path.join(dir, 'study-load.xlsx')}')
|
||||
ws = wb.active
|
||||
assert ws.title == 'Study Load', ws.title
|
||||
assert ws['B2'].value == 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY', ws['B2'].value
|
||||
assert ws['B3'].value == 'ADRIAN ALFRED CHIONG BONPIN'
|
||||
assert ws['B6'].value == '3'
|
||||
assert ws['A10'].value == '1' and ws['B10'].value == 'IT 3101N'
|
||||
assert ws['F10'].value == '3.0'
|
||||
print('openpyxl OK')
|
||||
`;
|
||||
fs.writeFileSync(pyFile, py);
|
||||
try {
|
||||
execSync('python3 ' + JSON.stringify(pyFile), { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
console.error('openpyxl validation failed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('ALL EXPORT VALIDATION PASSED');
|
||||
@@ -0,0 +1,102 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const {
|
||||
buildStudyLoadCsv, buildNextUpCsv, parseScheduleBlocks, csvField
|
||||
} = require('../export-csv.js');
|
||||
const { buildStudyLoadJson } = require('../export-json.js');
|
||||
const { buildStudyLoadXlsx } = require('../export-xlsx.js');
|
||||
|
||||
const data = {
|
||||
year: '2024',
|
||||
period: '1ST Semester',
|
||||
totalUnits: '21.00',
|
||||
header: {
|
||||
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
|
||||
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
|
||||
studentId: '22103625',
|
||||
semester: '1ST Semester 2024',
|
||||
yearLevel: '3'
|
||||
},
|
||||
courses: [
|
||||
{ group: '1', courseCode: 'IT 3101N', description: 'PLATFORM TECHNOLOGIES',
|
||||
schedule: 'F 01:30 PM - 04:30 PM LB486', faculty: 'TAN', units: '3.0', status: 'REGULAR' },
|
||||
{ group: '1', courseCode: 'CIS 1101', description: 'PROGRAMMING I',
|
||||
schedule: 'T Th 03:00 PM - 04:30 PM LB468', faculty: '', units: '3.0', status: 'REGULAR' },
|
||||
{ group: '1', courseCode: 'CS 5105', description: 'COMP, TALKS "X"',
|
||||
schedule: '', faculty: '', units: '3.00', status: 'DISSOLVED' }
|
||||
]
|
||||
};
|
||||
|
||||
// ---- CSV ----
|
||||
|
||||
test('buildStudyLoadCsv includes header block, table, and total', () => {
|
||||
const csv = buildStudyLoadCsv(data);
|
||||
assert.ok(csv.startsWith('\uFEFF'), 'has UTF-8 BOM');
|
||||
assert.ok(csv.includes('Program,BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY'));
|
||||
assert.ok(csv.includes('Student ID,22103625'));
|
||||
assert.ok(csv.includes('Group,Course Code,Description,Schedule,Faculty,Units,Status'));
|
||||
assert.ok(csv.includes('1,IT 3101N,PLATFORM TECHNOLOGIES,F 01:30 PM - 04:30 PM LB486,TAN,3.0,REGULAR'));
|
||||
assert.ok(/Total,.*21\.00/.test(csv));
|
||||
});
|
||||
|
||||
test('csvField quotes fields containing commas, quotes, newlines', () => {
|
||||
assert.strictEqual(csvField('plain'), 'plain');
|
||||
assert.strictEqual(csvField('a,b'), '"a,b"');
|
||||
assert.strictEqual(csvField('say "hi"'), '"say ""hi"""');
|
||||
});
|
||||
|
||||
// ---- NextUp ----
|
||||
|
||||
test('buildNextUpCsv matches the Next Up column contract', () => {
|
||||
const csv = buildNextUpCsv(data).replace(/^\uFEFF/, '');
|
||||
const header = csv.split('\r\n')[0];
|
||||
assert.strictEqual(header,
|
||||
'title,courseCode,days,startTime,endTime,room,meetingLink,color,icon,units,classLink,professorName,professorEmail,groupName');
|
||||
// Schedule blocks -> one row per block, day codes and 24h times.
|
||||
assert.ok(csv.includes('PLATFORM TECHNOLOGIES,IT 3101N,FR,13:30,16:30,LB486'));
|
||||
assert.ok(csv.includes('PROGRAMMING I,CIS 1101,"TU,TH",15:00,16:30,LB468'));
|
||||
});
|
||||
|
||||
test('parseScheduleBlocks splits multi-block schedules', () => {
|
||||
const blocks = parseScheduleBlocks('M 08:00 AM - 09:00 AM R1; W 10:00 AM - 12:00 PM R2');
|
||||
assert.ok(Array.isArray(blocks) && blocks.length >= 1);
|
||||
assert.strictEqual(Math.max(blocks.length, 0) > 0, true);
|
||||
});
|
||||
|
||||
// ---- JSON ----
|
||||
|
||||
test('buildStudyLoadJson has improved, clean schema', () => {
|
||||
const j = buildStudyLoadJson(data);
|
||||
assert.strictEqual(j.meta.program, data.header.program);
|
||||
assert.strictEqual(j.meta.studentName, data.header.fullName);
|
||||
assert.strictEqual(j.meta.academicYear, '2024');
|
||||
assert.strictEqual(j.meta.yearLevel, '3');
|
||||
assert.strictEqual(j.meta.totalUnits, '21.00');
|
||||
assert.ok(j.meta.generatedAt && /ISO|Z|T/.test(j.meta.generatedAt));
|
||||
assert.strictEqual(j.courses.length, 3);
|
||||
assert.strictEqual(j.courses[0].courseCode, 'IT 3101N');
|
||||
assert.deepStrictEqual(j.totals, { enrolledUnits: '21.00' });
|
||||
});
|
||||
|
||||
// ---- XLSX ----
|
||||
|
||||
test('buildStudyLoadXlsx returns a valid ZIP with expected parts', () => {
|
||||
const bytes = buildStudyLoadXlsx(data);
|
||||
assert.ok(bytes instanceof Uint8Array);
|
||||
// A valid ZIP starts with PK (0x50 0x4B).
|
||||
assert.strictEqual(bytes[0], 0x50);
|
||||
assert.strictEqual(bytes[1], 0x4b);
|
||||
// Contains XML as text (spot check for the sheet + shared strings).
|
||||
const asText = Buffer.from(bytes).toString('latin1');
|
||||
assert.ok(asText.includes('Content_Types'), 'content types part present');
|
||||
assert.ok(asText.includes('sharedStrings'), 'shared strings part present');
|
||||
assert.ok(asText.includes('ISMIS HELPER - STUDY LOAD'), 'title present');
|
||||
});
|
||||
|
||||
test('buildStudyLoadXlsx embeds course data as shared strings', () => {
|
||||
const bytes = buildStudyLoadXlsx(data);
|
||||
const asText = Buffer.from(bytes).toString('latin1');
|
||||
assert.ok(asText.includes('PLATFORM TECHNOLOGIES'));
|
||||
assert.ok(asText.includes('IT 3101N'));
|
||||
assert.ok(asText.includes('21.00'));
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {
|
||||
parseGradeHistory, yearLevelForSemester,
|
||||
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
|
||||
computeYearStandingFromUnits
|
||||
} = require('../parsers.js');
|
||||
|
||||
// ---- View Grades history ----
|
||||
|
||||
test('parseGradeHistory extracts ordered semester terms', () => {
|
||||
const html = `
|
||||
<h3>FIRST SEMESTER - 2022</h3><table>...</table>
|
||||
<h3>SECOND SEMESTER - 2022</h3><table>...</table>
|
||||
<h3>FIRST SEMESTER - 2023</h3><table>...</table>
|
||||
<h3>SUMMER - 2025</h3><table>...</table>
|
||||
<h3>FIRST SEMESTER - 2026</h3><table>...</table>
|
||||
`;
|
||||
const h = parseGradeHistory(html);
|
||||
assert.deepStrictEqual(h, [
|
||||
{ semester: 'FIRST', year: 2022 },
|
||||
{ semester: 'SECOND', year: 2022 },
|
||||
{ semester: 'FIRST', year: 2023 },
|
||||
{ semester: 'SUMMER', year: 2025 },
|
||||
{ semester: 'FIRST', year: 2026 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseGradeHistory handles empty input', () => {
|
||||
assert.deepStrictEqual(parseGradeHistory(''), []);
|
||||
});
|
||||
|
||||
test('yearLevelForSemester returns standing from history position', () => {
|
||||
const history = [
|
||||
{ semester: 'FIRST', year: 2022 },
|
||||
{ semester: 'SECOND', year: 2022 },
|
||||
{ semester: 'FIRST', year: 2023 },
|
||||
{ semester: 'SECOND', year: 2023 },
|
||||
{ semester: 'FIRST', year: 2024 },
|
||||
{ semester: 'SECOND', year: 2024 },
|
||||
{ semester: 'FIRST', year: 2025 },
|
||||
{ semester: 'SECOND', year: 2025 },
|
||||
{ semester: 'FIRST', year: 2026 }
|
||||
];
|
||||
// 2022 -> Y1, 2023 -> Y2, 2024 -> Y3, 2025 -> Y4, 2026 -> Y5
|
||||
assert.strictEqual(yearLevelForSemester(history, 2022), 1);
|
||||
assert.strictEqual(yearLevelForSemester(history, 2023), 2);
|
||||
assert.strictEqual(yearLevelForSemester(history, 2024), 3);
|
||||
assert.strictEqual(yearLevelForSemester(history, 2025), 4);
|
||||
assert.strictEqual(yearLevelForSemester(history, 2026), 5);
|
||||
// Summer of 2025 is the same standing as 2025 -> Year 4 (period is not
|
||||
// passed in; the standing is purely the history position).
|
||||
assert.strictEqual(yearLevelForSemester(history, 2025), 4);
|
||||
});
|
||||
|
||||
test('yearLevelForSemester clamps to program length and handles gaps', () => {
|
||||
// History ends at 2025; a query beyond it must not exceed the final level.
|
||||
const history = [
|
||||
{ semester: 'FIRST', year: 2022 },
|
||||
{ semester: 'SECOND', year: 2022 },
|
||||
{ semester: 'FIRST', year: 2023 },
|
||||
{ semester: 'SECOND', year: 2023 },
|
||||
{ semester: 'FIRST', year: 2024 },
|
||||
{ semester: 'SECOND', year: 2024 },
|
||||
{ semester: 'FIRST', year: 2025 },
|
||||
{ semester: 'SECOND', year: 2025 }
|
||||
];
|
||||
assert.strictEqual(yearLevelForSemester(history, 2028), 4); // clamped
|
||||
// Gap year: a skipped academic year still advances standing.
|
||||
const gap = [
|
||||
{ semester: 'FIRST', year: 2022 },
|
||||
{ semester: 'SECOND', year: 2022 },
|
||||
{ semester: 'FIRST', year: 2024 },
|
||||
{ semester: 'SECOND', year: 2024 }
|
||||
];
|
||||
assert.strictEqual(yearLevelForSemester(gap, 2024), 2);
|
||||
});
|
||||
|
||||
test('yearLevelForSemester handles missing year and empty history', () => {
|
||||
assert.strictEqual(yearLevelForSemester([], 2024), null);
|
||||
assert.strictEqual(yearLevelForSemester([{ semester: 'FIRST', year: 2022 }], NaN), null);
|
||||
});
|
||||
|
||||
// ---- Prospectus ----
|
||||
|
||||
test('parseProspectusYearUnits totals units per year level', () => {
|
||||
const html = `
|
||||
<h3>Year Level: 1</h3>
|
||||
<table><tr><th>Course</th></tr>
|
||||
<tr><td>CIS 1101</td><td>PROGRAMMING I</td><td>3.0</td></tr>
|
||||
<tr><td>CIS 1102</td><td>INTRO</td><td>3.0</td></tr>
|
||||
</table>
|
||||
<h3>Year Level: 2</h3>
|
||||
<table><tr><th>Course</th></tr>
|
||||
<tr><td>CIS 2101</td><td>DATA STRUCTURES</td><td>3.0</td></tr>
|
||||
</table>
|
||||
<h3>Year Level: 3</h3>
|
||||
<table><tr><th>Course</th></tr>
|
||||
<tr><td>IT 3101N</td><td>PLATFORM</td><td>4.0</td></tr>
|
||||
</table>
|
||||
<h3>Year Level: 4</h3>
|
||||
<table><tr><th>Course</th></tr>
|
||||
<tr><td>IT 4101</td><td>CAPSTONE</td><td>2.0</td></tr>
|
||||
</table>
|
||||
`;
|
||||
const y = parseProspectusYearUnits(html);
|
||||
assert.deepStrictEqual(y, [
|
||||
{ year: 1, units: 6 },
|
||||
{ year: 2, units: 3 },
|
||||
{ year: 3, units: 4 },
|
||||
{ year: 4, units: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
// ---- Lacking subjects ----
|
||||
|
||||
test('parseLackingSubjects returns remaining units and courses', () => {
|
||||
const html = `
|
||||
<table>
|
||||
<tr><th>Course Code</th><th>Description</th><th>Units</th></tr>
|
||||
<tr><td>IT 3206</td><td>INTEGRATIVE PROGRAMMING</td><td>3.0</td></tr>
|
||||
<tr><td>IT FREE EL</td><td>FREE ELECTIVE</td><td>3.0</td></tr>
|
||||
<tr><td>IT 4202N</td><td>PRACTICUM 2</td><td>6.0</td></tr>
|
||||
</table>
|
||||
`;
|
||||
const l = parseLackingSubjects(html);
|
||||
assert.strictEqual(l.units, '12.00');
|
||||
assert.strictEqual(l.courses.length, 3);
|
||||
assert.strictEqual(parseLackingSubjectsUnits(html), '12.00');
|
||||
});
|
||||
|
||||
test('parseLackingSubjects handles empty input', () => {
|
||||
assert.strictEqual(parseLackingSubjectsUnits(''), '0.00');
|
||||
});
|
||||
|
||||
// ---- Units math ----
|
||||
|
||||
test('computeYearStandingFromUnits maps finished units to year standing', () => {
|
||||
// Cumulative 52/99/148/181 (matches the real BSIT prospectus).
|
||||
const yearUnits = [
|
||||
{ year: 1, units: 52 },
|
||||
{ year: 2, units: 47 },
|
||||
{ year: 3, units: 49 },
|
||||
{ year: 4, units: 33 }
|
||||
];
|
||||
// finished = 181 - 24 = 157 -> Year 4 (real user data).
|
||||
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 24), 4);
|
||||
// No lacking -> finished everything -> final year.
|
||||
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 0), 4);
|
||||
// finished 40 -> still Year 1 (< 52).
|
||||
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 141), 1);
|
||||
// finished 60 -> Year 2 (52 <= 60 < 99).
|
||||
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 121), 2);
|
||||
// exact boundary: finished 52 -> Year 2.
|
||||
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 129), 2);
|
||||
// finished 99 -> Year 3.
|
||||
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 82), 3);
|
||||
});
|
||||
|
||||
test('computeYearStandingFromUnits handles empty and negative', () => {
|
||||
assert.strictEqual(computeYearStandingFromUnits([], 5), null);
|
||||
// lacking > total -> clamps to first year.
|
||||
assert.strictEqual(computeYearStandingFromUnits([{ year: 1, units: 10 }], 99), 1);
|
||||
});
|
||||
Reference in New Issue
Block a user