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.
This commit was merged in pull request #4.
This commit is contained in:
2026-08-20 04:39:08 +00:00
parent 89fd587c21
commit 0cb70ecee0
19 changed files with 5442 additions and 91 deletions
+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
};
});