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
+227 -16
View File
@@ -39,7 +39,20 @@
const passField = document.querySelector('input[type="password"]');
if (!userField || !passField) return;
userField.value = active.username;
passField.value = active.password;
// Password is encrypted at rest; decrypt via the background. Legacy profiles
// saved before encryption keep a plaintext `password` field as a fallback.
let password = '';
if (active.passwordEnc) {
try {
const res = await browser.runtime.sendMessage({ type: 'decrypt-password', cipher: active.passwordEnc });
if (res && res.ok) password = res.password;
} catch (e) {
password = '';
}
} else {
password = active.password || '';
}
passField.value = password;
userField.dispatchEvent(new Event('input', { bubbles: true }));
passField.dispatchEvent(new Event('input', { bubbles: true }));
const form = userField.closest('form') || passField.closest('form');
@@ -94,9 +107,10 @@
return m ? m[1] : '';
}
async function scrapeStudyLoad() {
async function scrapeStudyLoad(onProgress) {
const table = document.querySelector('table.table-forum');
if (!table) throw new Error('No study load table found on this page.');
if (onProgress) onProgress('Reading study load table', 5);
const courses = ISMIStools.parseStudyLoadTable(table.outerHTML);
if (!courses.length) throw new Error('No courses found in study load table.');
@@ -117,16 +131,21 @@
const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))];
const descMap = {};
for (const code of uniqueCodes) {
if (onProgress && uniqueCodes.length) onProgress('Fetching course descriptions (0/' + uniqueCodes.length + ')', 10);
for (let i = 0; i < uniqueCodes.length; i++) {
const code = uniqueCodes[i];
try {
descMap[code] = await fetchCourseDescription(code, academicPeriod, academicYear, token);
} catch (e) {
descMap[code] = '';
}
if (onProgress) onProgress('Fetching course descriptions (' + (i + 1) + '/' + uniqueCodes.length + ')',
10 + 60 * ((i + 1) / uniqueCodes.length));
}
courses.forEach((c) => { c.description = descMap[c.courseCode] || ''; });
let header = {};
if (onProgress) onProgress('Reading profile', 75);
try {
const resp = await fetch('/Manage/Student', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Profile request failed: ' + resp.status);
@@ -141,9 +160,77 @@
? selectedPeriod + ' ' + selectedYear
: (header.semester || '');
// Resolve the Year Level for the PRINTED semester (hybrid, see design
// spec): custom setting wins; else View Grades history matching; else the
// units math; else the profile-scraped value.
header.yearLevel = await resolveYearLevel(header, selectedPeriod, selectedYear, onProgress);
return { header, courses, period: selectedPeriod, year: selectedYear };
}
// Fetch a same-origin page and return its text, or null on failure.
async function fetchText(path) {
try {
const resp = await fetch(path, { credentials: 'same-origin' });
if (!resp.ok) return null;
return await resp.text();
} catch (e) {
return null;
}
}
// Hybrid year-level resolution. Returns null only if every source failed.
// mode: 'auto' (default) runs the full hybrid; 'custom' uses only the custom
// value; 'off' uses only the profile-scraped value.
async function resolveYearLevel(header, period, year, onProgress) {
const settings = await browser.storage.local.get(['yearMode', 'customYearLevel']);
const mode = settings.yearMode || 'auto';
const custom = String(settings.customYearLevel || '').trim();
if (mode === 'off') {
return header.yearLevel ? String(header.yearLevel) : null;
}
if (mode === 'custom') {
return custom ? custom : null;
}
// auto: custom value wins, then the hybrid sources.
if (custom) return custom;
const yearNum = parseInt(year, 10);
// 1. UNITS MATH — primary. Standing from units actually passed:
// total prospectus units minus still-lacking units. This reflects real
// earned units and is correct even for students who repeated or failed
// a year (the naive grade-history count would over-advance them).
if (onProgress) onProgress('Determining year level', 80);
const [prospectusHtml, lackingHtml] = await Promise.all([
fetchText('/prospectus/StudentProspectus'),
fetchText('/Extras/ViewLackingSubjectsStudent1')
]);
if (onProgress) onProgress('Checking remaining units', 88);
if (prospectusHtml) {
const yearUnits = ISMIStools.parseProspectusYearUnits(prospectusHtml);
const lacking = lackingHtml ? ISMIStools.parseLackingSubjectsUnits(lackingHtml) : 0;
const level = ISMIStools.computeYearStandingFromUnits(yearUnits, lacking);
if (level != null) return String(level);
}
// 2. GRADE HISTORY — fallback only when the units path is unavailable.
// It counts distinct academic years, so it is only an approximation for
// the printed semester and is not used when units data is present.
if (onProgress) onProgress('Checking grade history', 93);
const gradesHtml = await fetchText('/ViewGrades');
if (gradesHtml) {
const history = ISMIStools.parseGradeHistory(gradesHtml);
const level = ISMIStools.yearLevelForSemester(history, yearNum);
if (level != null) return String(level);
}
// 3. Profile-scraped value as a last resort.
if (header.yearLevel) return String(header.yearLevel);
return null;
}
// ---- On-page Download PDF button ----
const BTN_ID = 'ismisHelperDownloadBtn';
@@ -166,39 +253,148 @@
async function onDownloadPdf() {
const btn = document.getElementById(BTN_ID);
setExportButtonsDisabled(true);
try {
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...';
const { header, courses, period: selectedPeriod } = await scrapeStudyLoad();
setProgress('Reading study load', 2);
const { header, courses } = await scrapeStudyLoad((l, p) => setProgress(l, p));
const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included);
const settings = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
// Resolve the Year Level: custom value wins; otherwise prefer the scraped
// value, smart-adjusted (minus one) during SUMMER when the year level has
// not yet advanced.
const settings = await browser.storage.local.get(['truncateDescription', 'showYearLevel']);
header.showYearLevel = settings.showYearLevel !== false;
const custom = String(settings.customYearLevel || '').trim();
if (custom) {
header.yearLevel = custom;
} else if (/summer/i.test(selectedPeriod || '')) {
const n = parseInt(header.yearLevel, 10);
if (Number.isFinite(n) && n > 1) header.yearLevel = String(n - 1);
}
setProgress('Building PDF', 95);
const res = await browser.runtime.sendMessage({
type: 'generate-pdf',
data: { header, courses: included, totalUnits, truncateDescription: settings.truncateDescription !== false }
});
if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed');
setProgress('Done ✓', 100);
showToast('PDF downloaded.');
setTimeout(clearProgress, 3000);
} catch (err) {
setProgress('Error: ' + ((err && err.message) || err), 100);
showToast('Error: ' + ((err && err.message) || err), true);
} finally {
setExportButtonsDisabled(false);
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
setTimeout(clearProgress, 4000);
}
}
// Export another format (csv/json/xlsx/nextup). Reuses scrapeStudyLoad so
// every format gets the same header/courses/totalUnits payload and the same
// hybrid year-level resolution.
async function onExport(format) {
setExportButtonsDisabled(true);
try {
setProgress('Reading ' + format.toUpperCase() + ' data', 2);
const { header, courses, period, year } = await scrapeStudyLoad((l, p) => setProgress(l, p));
const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included);
setProgress('Building ' + format.toUpperCase() + ' file', 95);
const res = await browser.runtime.sendMessage({
type: 'export',
format,
data: { header, courses: included, totalUnits, period, year }
});
if (!res || !res.ok) throw new Error((res && res.error) || 'Export failed');
setProgress('Done ✓', 100);
showToast('Exported ' + format.toUpperCase() + '.');
setTimeout(clearProgress, 3000);
} catch (err) {
setProgress('Error: ' + ((err && err.message) || err), 100);
showToast('Error: ' + ((err && err.message) || err), true);
setTimeout(clearProgress, 4000);
} finally {
setExportButtonsDisabled(false);
}
}
function makeExportButton(id, label, icon, format) {
const b = document.createElement('button');
b.id = id;
b.type = 'button';
b.className = 'btn green';
b.style.marginLeft = '6px';
b.innerHTML = '<i class="' + icon + '"></i> ' + label;
b.addEventListener('click', () => onExport(format));
return b;
}
// Disable/enable all export buttons (incl. PDF) to prevent double-triggers.
function setExportButtonsDisabled(disabled) {
const ids = [BTN_ID, 'ismisHelperExportCsvBtn', 'ismisHelperExportXlsxBtn',
'ismisHelperExportJsonBtn', 'ismisHelperExportNextUpBtn'];
for (const id of ids) {
const b = document.getElementById(id);
if (b) b.disabled = disabled;
}
}
// ---- Export progress indicator ----
const PROGRESS_ID = 'ismisHelperProgress';
const PROGRESS_FILL_ID = 'ismisHelperProgressFill';
const PROGRESS_LABEL_ID = 'ismisHelperProgressLabel';
let progressEl = null;
// Set the status bar label + width (0-100). With no percent, shows just text.
function setProgress(label, percent) {
if (!progressEl) progressEl = document.getElementById(PROGRESS_ID);
if (!progressEl) return;
progressEl.style.display = 'flex';
const fill = document.getElementById(PROGRESS_FILL_ID);
if (fill) fill.style.width = Math.max(0, Math.min(100, percent)) + '%';
const lab = document.getElementById(PROGRESS_LABEL_ID);
if (lab) lab.textContent = label;
}
function clearProgress() {
if (!progressEl) progressEl = document.getElementById(PROGRESS_ID);
if (!progressEl) return;
progressEl.style.display = 'none';
const fill = document.getElementById(PROGRESS_FILL_ID);
if (fill) fill.style.width = '0%';
}
function makeProgressEl() {
if (document.getElementById(PROGRESS_ID)) return;
const el = document.createElement('div');
el.id = PROGRESS_ID;
el.style.display = 'none';
el.style.alignItems = 'center';
el.style.gap = '10px';
el.style.margin = '8px 0';
el.style.fontSize = '12px';
el.style.color = '#555';
el.style.width = '100%';
const track = document.createElement('div');
track.style.flex = '1';
track.style.height = '8px';
track.style.background = '#e2e2e2';
track.style.borderRadius = '4px';
track.style.overflow = 'hidden';
const fill = document.createElement('div');
fill.id = PROGRESS_FILL_ID;
fill.style.width = '0%';
fill.style.height = '100%';
fill.style.background = '#28a745';
fill.style.transition = 'width .2s ease';
track.appendChild(fill);
const lab = document.createElement('span');
lab.id = PROGRESS_LABEL_ID;
lab.style.whiteSpace = 'nowrap';
el.appendChild(track);
el.appendChild(lab);
return el;
}
function injectDownloadButton() {
if (document.getElementById(BTN_ID)) return;
const unitsP = findTotalUnitsP();
@@ -209,6 +405,7 @@
wrapper.style.alignItems = 'center';
wrapper.style.justifyContent = 'space-between';
wrapper.style.margin = '10px 0';
wrapper.style.flexWrap = 'wrap';
const btn = document.createElement('button');
btn.id = BTN_ID;
@@ -217,8 +414,22 @@
btn.innerHTML = '<i class="fa fa-download"></i> Download PDF';
btn.addEventListener('click', onDownloadPdf);
const btnCsv = makeExportButton('ismisHelperExportCsvBtn', 'CSV', 'fa fa-file-text-o', 'csv');
const btnXlsx = makeExportButton('ismisHelperExportXlsxBtn', 'Excel', 'fa fa-file-o', 'xlsx');
const btnJson = makeExportButton('ismisHelperExportJsonBtn', 'JSON', 'fa fa-file-code-o', 'json');
const btnNextUp = makeExportButton('ismisHelperExportNextUpBtn', 'Next Up', 'fa fa-calendar', 'nextup');
const btnGroup = document.createElement('div');
btnGroup.style.display = 'flex';
btnGroup.appendChild(btn);
btnGroup.appendChild(btnCsv);
btnGroup.appendChild(btnXlsx);
btnGroup.appendChild(btnJson);
btnGroup.appendChild(btnNextUp);
unitsP.parentNode.insertBefore(wrapper, unitsP);
wrapper.appendChild(btn);
wrapper.appendChild(btnGroup);
wrapper.appendChild(makeProgressEl());
wrapper.appendChild(unitsP);
}