From eb7087d506fab9b61ea83ce2439cd22965106c8c Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Thu, 20 Aug 2026 09:02:15 +0800 Subject: [PATCH] feat(progress): live status bar + % complete during exports Adds a progress bar below the export buttons showing the current step (reading table, fetching course descriptions i/N, profile, year level, building file) with a 0-100% fill. Threads an onProgress callback through scrapeStudyLoad and resolveYearLevel. Buttons disable during export to prevent double-triggers; bar clears ~3s after success. --- README.md | 9 +++-- content.js | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4ff0880..c83c660 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,12 @@ built `ismis-helper-chrome.zip` directly: - 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 the View Grades history (exact) or, failing that, from remaining - units (Student Prospectus total − Lacking Subjects). Custom value overrides; - the profile-scraped value is the fallback. + 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`), diff --git a/content.js b/content.js index 520130a..c01ee9a 100644 --- a/content.js +++ b/content.js @@ -94,9 +94,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 +118,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); @@ -144,7 +150,7 @@ // 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); + header.yearLevel = await resolveYearLevel(header, selectedPeriod, selectedYear, onProgress); return { header, courses, period: selectedPeriod, year: selectedYear }; } @@ -163,7 +169,7 @@ // 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) { + 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(); @@ -183,10 +189,12 @@ // 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; @@ -197,6 +205,7 @@ // 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); @@ -231,26 +240,34 @@ async function onDownloadPdf() { const btn = document.getElementById(BTN_ID); + setExportButtonsDisabled(true); try { btn.disabled = true; btn.innerHTML = ' Downloading...'; - const { header, courses } = 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']); header.showYearLevel = settings.showYearLevel !== false; + 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 = ' Download PDF'; + setTimeout(clearProgress, 4000); } } @@ -258,20 +275,29 @@ // every format gets the same header/courses/totalUnits payload and the same // hybrid year-level resolution. async function onExport(format) { + setExportButtonsDisabled(true); try { - const { header, courses, period, year } = await scrapeStudyLoad(); + 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); } } @@ -286,6 +312,76 @@ 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(); @@ -320,6 +416,7 @@ unitsP.parentNode.insertBefore(wrapper, unitsP); wrapper.appendChild(btnGroup); + wrapper.appendChild(makeProgressEl()); wrapper.appendChild(unitsP); }