feat(v1.4.0): CSV/Excel/JSON/NextUp exports + hybrid smart year standing
- Export modules (export-csv/json/xlsx) mirroring the PDF layout, plus a Next Up import CSV matching the raycast/nextup column contract. - Hybrid Year Level: View Grades history (exact per-semester standing), falling back to units math (prospectus total - lacking), then profile. Popup adds a year-mode setting (auto/custom/off). - On-page button row now offers PDF / CSV / Excel / JSON / Next Up. - Bump version to 1.4.0. - Tag-only build workflow with an optional AMO sign step for a signed .xpi; separate ci.yml keeps tests on pushes/PRs. - Tests: year-standing parser tests + export tests (CSV quoting, JSON schema, XLSX structure); CI validates XLSX via openpyxl.
This commit is contained in:
+107
-1
@@ -81,6 +81,106 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- 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 the study load printed at (semester, year): it is the number
|
||||
// of distinct academic years strictly before that term's year + 1. SUMMER of
|
||||
// year Y is the same standing as the rest of year Y (the student has just
|
||||
// finished the preceding level during Y's regular semesters). Clamped to at
|
||||
// most the number of distinct years in history (a student cannot be past the
|
||||
// final level recorded).
|
||||
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 +193,11 @@
|
||||
.toFixed(2);
|
||||
}
|
||||
|
||||
return { parseStudyLoadTable, parseProfilePage, parseOfferedCourses, filterStudyLoadCourses, sumUnits };
|
||||
return {
|
||||
parseStudyLoadTable, parseProfilePage, parseOfferedCourses,
|
||||
filterStudyLoadCourses, sumUnits,
|
||||
parseGradeHistory, yearLevelForSemester,
|
||||
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
|
||||
computeYearStandingFromUnits
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user