v1.4.0: CSV/Excel/JSON/NextUp exports, units-first smart year standing, live export progress bar, AES-GCM at-rest password encryption.
205 lines
7.4 KiB
JavaScript
205 lines
7.4 KiB
JavaScript
(function (root, factory) {
|
|
if (typeof module === 'object' && module.exports) {
|
|
module.exports = factory();
|
|
} else {
|
|
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
|
|
}
|
|
})(typeof self !== 'undefined' ? self : this, function () {
|
|
'use strict';
|
|
|
|
function cleanText(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /gi, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function extractRows(html) {
|
|
const rows = [];
|
|
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
let trMatch;
|
|
while ((trMatch = trRe.exec(html)) !== null) {
|
|
const rowHtml = trMatch[1];
|
|
if (/<th/i.test(rowHtml)) continue;
|
|
const tds = [];
|
|
const tdRe = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
|
let tdMatch;
|
|
while ((tdMatch = tdRe.exec(rowHtml)) !== null) {
|
|
tds.push(cleanText(tdMatch[1]));
|
|
}
|
|
rows.push(tds);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function parseStudyLoadTable(html) {
|
|
return extractRows(html)
|
|
.filter((tds) => tds.length >= 6)
|
|
.map((tds) => ({
|
|
group: tds[0],
|
|
courseCode: tds[1],
|
|
schedule: tds[2],
|
|
faculty: tds[3],
|
|
units: tds[4],
|
|
status: tds[5]
|
|
}));
|
|
}
|
|
|
|
function parseProfilePage(html) {
|
|
const result = {};
|
|
const h3Match = /<h3[^>]*>([\s\S]*?)<\/h3>/i.exec(html);
|
|
const bMatch = (h3Match && /<b>([\s\S]*?)<\/b>/i.exec(h3Match[1])) ||
|
|
/<b>([\s\S]*?)<\/b>/i.exec(html);
|
|
if (bMatch) result.program = cleanText(bMatch[1]);
|
|
|
|
const smallMatch = /<small>([\s\S]*?)<\/small>/i.exec(html);
|
|
if (smallMatch) {
|
|
const lines = smallMatch[1].split(/<br\s*\/?>/i).map(cleanText).filter(Boolean);
|
|
result.semester = lines[lines.length - 1] || '';
|
|
}
|
|
|
|
const kvRe = /<td[^>]*>([\s\S]*?)<\/td>\s*<td[^>]*class=["'][^"']*\bbold\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/gi;
|
|
let m;
|
|
while ((m = kvRe.exec(html)) !== null) {
|
|
const key = cleanText(m[1]);
|
|
const val = cleanText(m[2]);
|
|
if (/full name/i.test(key)) result.fullName = val;
|
|
else if (/student id/i.test(key)) result.studentId = val;
|
|
else if (/year level/i.test(key)) result.yearLevel = val;
|
|
else if (/student type/i.test(key)) result.studentType = val;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseOfferedCourses(html) {
|
|
return extractRows(html)
|
|
.filter((tds) => tds.length >= 2)
|
|
.map((tds) => ({
|
|
courseCode: tds[0].replace(/\s*-\s*Group\s*\d+$/i, '').trim(),
|
|
description: tds[1]
|
|
}));
|
|
}
|
|
|
|
// ---- Year standing helpers ----
|
|
|
|
// Parse the View Grades page into an ordered list of academic terms:
|
|
// [{ semester: 'FIRST'|'SECOND'|'SUMMER', year: 2024 }, ...]
|
|
// Terms appear in chronological order in the page. Regular semesters appear
|
|
// as "FIRST SEMESTER - YYYY"; summer appears as "SUMMER - YYYY".
|
|
function parseGradeHistory(html) {
|
|
const history = [];
|
|
const re = /\b(FIRST|SECOND)\s+SEMESTER\s*-\s*(\d{4})\b|\bSUMMER\s*-\s*(\d{4})\b/gi;
|
|
let m;
|
|
while ((m = re.exec(html)) !== null) {
|
|
if (m[1]) {
|
|
history.push({ semester: m[1].toUpperCase(), year: parseInt(m[2], 10) });
|
|
} else {
|
|
history.push({ semester: 'SUMMER', year: parseInt(m[3], 10) });
|
|
}
|
|
}
|
|
return history;
|
|
}
|
|
|
|
// Standing for a study load printed in `year` from the View Grades history.
|
|
// It is the number of distinct academic years strictly before that year + 1
|
|
// (FIRST/SECOND/SUMMER of a year share the same standing), clamped to at most
|
|
// the number of distinct years in history. NOTE: this is only an
|
|
// approximation for the printed semester — it counts calendar years present,
|
|
// not units passed — so a repeated/failed year can over-advance. It is used
|
|
// only as a fallback when the units-based standing is unavailable.
|
|
function yearLevelForSemester(history, year) {
|
|
if (!history || !history.length || !Number.isFinite(year)) return null;
|
|
const distinctYears = [...new Set(history.map((t) => t.year))].sort((a, b) => a - b);
|
|
const yearsBefore = distinctYears.filter((y) => y < year).length;
|
|
const level = Math.min(yearsBefore + 1, distinctYears.length);
|
|
return Number.isFinite(level) && level >= 1 ? level : null;
|
|
}
|
|
|
|
// Parse the Student Prospectus into per-year unit totals. Each table is
|
|
// preceded by a "Year Level: N" heading. Returns [{ year, units }].
|
|
function parseProspectusYearUnits(html) {
|
|
const segs = String(html || '').split(/(Year Level:\s*\d)/i);
|
|
const yearUnits = {};
|
|
let cur = null;
|
|
for (let i = 0; i < segs.length; i++) {
|
|
const s = segs[i];
|
|
const ym = /Year Level:\s*(\d+)/i.exec(s);
|
|
if (ym) { cur = parseInt(ym[1], 10); continue; }
|
|
if (cur == null) continue;
|
|
const tm = /<table[^>]*>([\s\S]*?)<\/table>/i.exec(s);
|
|
if (!tm) continue;
|
|
const rows = extractRows(tm[1]);
|
|
let units = 0;
|
|
for (const r of rows) {
|
|
if (r.length >= 3 && /^[A-Z]/.test(r[0]) && /[\d.]+/.test(r[2])) {
|
|
const u = parseFloat(String(r[2]).replace(/[^\d.]/g, ''));
|
|
if (Number.isFinite(u)) units += u;
|
|
}
|
|
}
|
|
yearUnits[cur] = (yearUnits[cur] || 0) + units;
|
|
cur = null; // only the immediately-following table belongs to this heading
|
|
}
|
|
const years = Object.keys(yearUnits).map(Number).sort((a, b) => a - b);
|
|
return years.map((y) => ({ year: y, units: yearUnits[y] }));
|
|
}
|
|
|
|
// Parse the Lacking Subjects page for total remaining units.
|
|
function parseLackingSubjectsUnits(html) {
|
|
return parseLackingSubjects(html).units;
|
|
}
|
|
function parseLackingSubjects(html) {
|
|
const rows = extractRows(String(html || ''));
|
|
let units = 0;
|
|
const courses = [];
|
|
for (const r of rows) {
|
|
if (r.length >= 3 && /^[A-Z]/.test(r[0]) && /[\d.]+/.test(r[2])) {
|
|
const u = parseFloat(String(r[2]).replace(/[^\d.]/g, ''));
|
|
if (Number.isFinite(u)) {
|
|
units += u;
|
|
courses.push({ courseCode: r[0], description: r[1], units: String(u) });
|
|
}
|
|
}
|
|
}
|
|
return { units: units.toFixed(2), courses };
|
|
}
|
|
|
|
// Year standing from units: finished = totalProspectusUnits - lackingUnits;
|
|
// standing = first N in [1..maxYear] where finished < cumulativeUnits(N);
|
|
// if finished passes every threshold, standing = maxYear (program length).
|
|
// yearUnits is the array [{year, units}] from parseProspectusYearUnits.
|
|
function computeYearStandingFromUnits(yearUnits, lackingUnits) {
|
|
const yrs = (yearUnits || []).slice().sort((a, b) => a.year - b.year);
|
|
if (!yrs.length) return null;
|
|
const total = yrs.reduce((s, y) => s + y.units, 0);
|
|
const finished = total - (parseFloat(lackingUnits) || 0);
|
|
if (finished < 0) return yrs[0].year;
|
|
let cum = 0;
|
|
for (const y of yrs) {
|
|
cum += y.units;
|
|
if (finished < cum) return y.year;
|
|
}
|
|
return yrs[yrs.length - 1].year; // passed everything -> final level
|
|
}
|
|
|
|
function filterStudyLoadCourses(courses) {
|
|
return (courses || []).filter(
|
|
(c) => String(c.status || '').trim().toUpperCase() !== 'DISSOLVED'
|
|
);
|
|
}
|
|
|
|
function sumUnits(courses) {
|
|
return (courses || [])
|
|
.reduce((sum, c) => sum + (parseFloat(c.units) || 0), 0)
|
|
.toFixed(2);
|
|
}
|
|
|
|
return {
|
|
parseStudyLoadTable, parseProfilePage, parseOfferedCourses,
|
|
filterStudyLoadCourses, sumUnits,
|
|
parseGradeHistory, yearLevelForSemester,
|
|
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
|
|
computeYearStandingFromUnits
|
|
};
|
|
});
|