diff --git a/.gitignore b/.gitignore index c2658d7..5c85e1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ node_modules/ + +docs/ +dist/ diff --git a/docs/superpowers/plans/2026-08-18-ismis-helper.md b/docs/superpowers/plans/2026-08-18-ismis-helper.md deleted file mode 100644 index e71fc0f..0000000 --- a/docs/superpowers/plans/2026-08-18-ismis-helper.md +++ /dev/null @@ -1,1285 +0,0 @@ -# ISMIS Helper Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a personal Firefox extension (MV3, no build step) for `ismis.usc.edu.ph` that provides auto-login, auto-accept of the privacy policy, and Study Load PDF generation. - -**Architecture:** A single MV3 extension with a content script (auto-login, privacy accept, scraping), a background service worker (message routing + PDF generation), and a popup (profile manager + study-load editor). Pure parsing/PDF logic lives in UMD modules (`parsers.js`, `pdf.js`) shared between the browser and Node tests. - -**Tech Stack:** Plain JavaScript, Manifest V3, `pdf-lib` (vendored), Node `node:test` for unit tests. - -**Spec:** `docs/superpowers/specs/2026-08-18-ismis-helper-design.md` - ---- - -## File Structure - -``` -ismis-helper/ -├── manifest.json # MV3 manifest -├── background.js # service worker: message routing + PDF generation + download -├── content.js # content script: auto-login, privacy accept, scraping -├── parsers.js # UMD: parseStudyLoadTable, parseProfilePage, parseOfferedCourses -├── pdf.js # UMD: buildStudyLoadPdf -├── popup.html # popup UI -├── popup.js # popup logic -├── popup.css # popup styles -├── libs/pdf-lib.min.js # vendored pdf-lib -├── icons/ # extension icons (16, 32, 48, 128) -├── test/ -│ ├── parsers.test.js # node:test for parsers -│ ├── pdf.test.js # node:test for PDF builder -│ └── fixtures/ # real HTML samples (already extracted) -│ ├── study-load-table.html -│ ├── profile.html -│ └── offered-courses.html -└── package.json # npm test script + pdf-lib devDependency -``` - ---- - -### Task 1: Project scaffold (manifest, package.json, icons) - -**Files:** -- Create: `manifest.json` -- Create: `package.json` -- Create: `icons/icon16.png`, `icons/icon32.png`, `icons/icon48.png`, `icons/icon128.png` - -- [ ] **Step 1: Write `manifest.json`** - -```json -{ - "manifest_version": 3, - "name": "ISMIS Helper", - "version": "1.0.0", - "description": "Auto-login, auto-accept privacy policy, and Study Load PDF for the USC ISMIS portal.", - "permissions": ["storage", "downloads", "activeTab"], - "host_permissions": ["https://ismis.usc.edu.ph/*"], - "background": { - "service_worker": "background.js" - }, - "content_scripts": [ - { - "matches": ["https://ismis.usc.edu.ph/*"], - "js": ["parsers.js", "content.js"], - "run_at": "document_idle" - } - ], - "action": { - "default_popup": "popup.html", - "default_title": "ISMIS Helper", - "default_icon": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png" - } - }, - "icons": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - } -} -``` - -- [ ] **Step 2: Write `package.json`** - -```json -{ - "name": "ismis-helper", - "version": "1.0.0", - "private": true, - "description": "Firefox extension for the USC ISMIS portal", - "scripts": { - "test": "node --test" - }, - "devDependencies": { - "pdf-lib": "^1.17.1" - } -} -``` - -- [ ] **Step 3: Install pdf-lib and vendor the minified build** - -Run: -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -npm install -cp node_modules/pdf-lib/dist/pdf-lib.min.js libs/pdf-lib.min.js -``` -Expected: `libs/pdf-lib.min.js` exists and is non-empty. - -- [ ] **Step 4: Generate icons** - -Run (requires Python PIL; if unavailable, create 4 solid-color PNGs any way you like): -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -python3 -c " -from PIL import Image, ImageDraw -for size in (16,32,48,128): - img = Image.new('RGBA',(size,size),(0,0,0,0)) - d = ImageDraw.Draw(img) - d.rounded_rectangle([0,0,size-1,size-1], radius=size//5, fill=(0,102,153,255)) - # simple 'I' letter - w = max(2, size//6) - d.rectangle([size//2-w//2, size//5, size//2+w//2, size*4//5], fill=(255,255,255,255)) - img.save(f'icons/icon{size}.png') -print('icons written') -" -``` -Expected: four PNG files in `icons/`. - -- [ ] **Step 5: Commit** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add manifest.json package.json package-lock.json libs/ icons/ -git commit -m "chore: scaffold ISMIS Helper extension" -``` - ---- - -### Task 2: `parsers.js` — parseStudyLoadTable - -**Files:** -- Create: `parsers.js` -- Test: `test/parsers.test.js` - -- [ ] **Step 1: Write the failing test** - -Create `test/parsers.test.js`: -```js -const { test } = require('node:test'); -const assert = require('node:assert'); -const fs = require('node:fs'); -const path = require('node:path'); -const { parseStudyLoadTable } = require('../parsers.js'); - -const fixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'study-load-table.html'), 'utf8'); - -test('parseStudyLoadTable extracts all course rows', () => { - const rows = parseStudyLoadTable(fixture); - assert.strictEqual(rows.length, 5); -}); - -test('parseStudyLoadTable maps columns correctly', () => { - const rows = parseStudyLoadTable(fixture); - const first = rows[0]; - assert.strictEqual(first.group, '1'); - assert.strictEqual(first.courseCode, 'CS 5105'); - assert.strictEqual(first.units, '3.00'); - assert.strictEqual(first.status, 'DISSOLVED'); - assert.ok(first.schedule.includes('F 01:30 PM')); -}); - -test('parseStudyLoadTable handles empty faculty', () => { - const rows = parseStudyLoadTable(fixture); - const it4102 = rows.find(r => r.courseCode === 'IT 4102N'); - assert.strictEqual(it4102.faculty, ''); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: FAIL — `Cannot find module '../parsers.js'`. - -- [ ] **Step 3: Write `parsers.js`** - -```js -(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 = /]*>([\s\S]*?)<\/tr>/gi; - let trMatch; - while ((trMatch = trRe.exec(html)) !== null) { - const rowHtml = trMatch[1]; - if (/]*>([\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] - })); - } - - return { parseStudyLoadTable }; -}); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add parsers.js test/parsers.test.js -git commit -m "feat: parse study load table" -``` - ---- - -### Task 3: `parsers.js` — parseProfilePage - -**Files:** -- Modify: `parsers.js` -- Test: `test/parsers.test.js` - -- [ ] **Step 1: Write the failing test** - -Append to `test/parsers.test.js`: -```js -const { parseProfilePage } = require('../parsers.js'); -const profileFixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'profile.html'), 'utf8'); - -test('parseProfilePage extracts header fields', () => { - const p = parseProfilePage(profileFixture); - assert.strictEqual(p.fullName, 'ADRIAN ALFRED CHIONG BONPIN'); - assert.strictEqual(p.studentId, '22103625'); - assert.strictEqual(p.yearLevel, '4'); - assert.strictEqual(p.program, 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY'); - assert.strictEqual(p.semester, '1ST Semester 2026'); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: FAIL — `parseProfilePage is not a function`. - -- [ ] **Step 3: Add `parseProfilePage` to `parsers.js`** - -Add inside the returned object and define the function: -```js - function parseProfilePage(html) { - const result = {}; - const bMatch = /([\s\S]*?)<\/b>/i.exec(html); - if (bMatch) result.program = cleanText(bMatch[1]); - - const smallMatch = /([\s\S]*?)<\/small>/i.exec(html); - if (smallMatch) { - const lines = smallMatch[1].split(//i).map(cleanText).filter(Boolean); - result.semester = lines[lines.length - 1] || ''; - } - - const kvRe = /]*>([\s\S]*?)<\/td>\s*]*class="bold"[^>]*>([\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; - } -``` - -Update the return statement: -```js - return { parseStudyLoadTable, parseProfilePage }; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add parsers.js test/parsers.test.js -git commit -m "feat: parse profile page header" -``` - ---- - -### Task 4: `parsers.js` — parseOfferedCourses - -**Files:** -- Modify: `parsers.js` -- Test: `test/parsers.test.js` - -- [ ] **Step 1: Write the failing test** - -Append to `test/parsers.test.js`: -```js -const { parseOfferedCourses } = require('../parsers.js'); -const offeredFixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'offered-courses.html'), 'utf8'); - -test('parseOfferedCourses extracts course code and description', () => { - const courses = parseOfferedCourses(offeredFixture); - const it3206 = courses.find(c => c.courseCode === 'IT 3206'); - assert.ok(it3206, 'should find IT 3206'); - assert.strictEqual(it3206.description, 'INTEGRATIVE PROGRAMMING AND TECHNOLOGY'); -}); - -test('parseOfferedCourses strips group suffix from code', () => { - const courses = parseOfferedCourses(offeredFixture); - assert.ok(courses.every(c => !/Group/.test(c.courseCode))); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: FAIL — `parseOfferedCourses is not a function`. - -- [ ] **Step 3: Add `parseOfferedCourses` to `parsers.js`** - -```js - function parseOfferedCourses(html) { - return extractRows(html) - .filter((tds) => tds.length >= 2) - .map((tds) => ({ - courseCode: tds[0].split('-')[0].trim(), - description: tds[1] - })); - } -``` - -Update the return statement: -```js - return { parseStudyLoadTable, parseProfilePage, parseOfferedCourses }; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: PASS (6 tests). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add parsers.js test/parsers.test.js -git commit -m "feat: parse offered courses descriptions" -``` - ---- - -### Task 5: `pdf.js` — buildStudyLoadPdf - -**Files:** -- Create: `pdf.js` -- Test: `test/pdf.test.js` - -- [ ] **Step 1: Write the failing test** - -Create `test/pdf.test.js`: -```js -const { test } = require('node:test'); -const assert = require('node:assert'); -global.PDFLib = require('pdf-lib'); -const { buildStudyLoadPdf } = require('../pdf.js'); - -const sampleData = { - header: { - fullName: 'ADRIAN ALFRED CHIONG BONPIN', - studentId: '22103625', - program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY', - yearLevel: '4', - semester: '1ST Semester 2026', - dateEnrolled: '8/5/2024', - dateRange: 'AUGUST - JANUARY' - }, - courses: [ - { group: '7', courseCode: 'CIS 2101', description: 'DATA STRUCTURES AND ALGORITHMS', units: '3.00', teacher: 'CHRISTIAN MADERAZO', schedule: 'M,W 03:00 PM - 05:30 PM LB443TC', status: 'REGULAR' }, - { group: '2', courseCode: 'IT 3103N', description: 'SYSTEMS INTEGRATION AND ARCHITECTURE', units: '3.00', teacher: 'KHENT DELA PAZ', schedule: 'T,Th 01:30 PM - 03:00 PM DCISMVR3TC', status: 'REGULAR' } - ], - totalUnits: '6.00' -}; - -test('buildStudyLoadPdf returns valid PDF bytes', async () => { - const bytes = await buildStudyLoadPdf(sampleData); - assert.ok(bytes instanceof Uint8Array); - const head = Buffer.from(bytes.slice(0, 5)).toString('latin1'); - assert.strictEqual(head, '%PDF-'); - const doc = await PDFLib.PDFDocument.load(bytes); - assert.strictEqual(doc.getPageCount(), 1); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: FAIL — `Cannot find module '../pdf.js'`. - -- [ ] **Step 3: Write `pdf.js`** - -```js -(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 getPDFLib() { - return (typeof self !== 'undefined' && self.PDFLib) || - (typeof globalThis !== 'undefined' && globalThis.PDFLib); - } - - function wrapText(text, font, size, maxWidth) { - const words = String(text).split(/\s+/).filter(Boolean); - const lines = []; - let line = ''; - for (const word of words) { - const candidate = line ? line + ' ' + word : word; - if (font.widthOfTextAtSize(candidate, size) <= maxWidth) { - line = candidate; - } else { - if (line) lines.push(line); - line = word; - } - } - if (line) lines.push(line); - return lines.length ? lines : ['']; - } - - function buildStudyLoadPdf(data) { - const PDFLib = getPDFLib(); - const { PDFDocument, StandardFonts, rgb } = PDFLib; - const header = data.header || {}; - const courses = data.courses || []; - - const doc = PDFDocument.create(); - const page = doc.addPage([612, 792]); // Letter - const font = doc.getFont(StandardFonts.Helvetica); - const bold = doc.getFont(StandardFonts.HelveticaBold); - const margin = 50; - const contentWidth = 612 - margin * 2; - let y = 792 - 50; - - const black = rgb(0, 0, 0); - const gray = rgb(0.3, 0.3, 0.3); - - function center(text, size, fontToUse, dy) { - const w = fontToUse.widthOfTextAtSize(text, size); - page.drawText(text, { x: (612 - w) / 2, y: y, size, font: fontToUse, color: black }); - y -= dy; - } - function left(text, size, fontToUse, x, dy) { - page.drawText(text, { x, y, size, font: fontToUse, color: black }); - y -= dy; - } - - // Header - center(header.semester || '', 14, bold, 20); - center("STUDENT'S STUDY LOAD", 16, bold, 24); - center(header.dateRange || '', 11, font, 18); - if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 10, font, margin, 16); - y -= 6; - if (header.studentId) left('Student ID: ' + header.studentId, 10, font, margin, 14); - if (header.fullName) left('Name: ' + header.fullName, 10, font, margin, 14); - if (header.program) left('Program: ' + header.program, 10, font, margin, 14); - if (header.yearLevel) left('Year Level: ' + header.yearLevel, 10, font, margin, 14); - y -= 10; - - // Table columns: NOTE, UNITS, TEACHER, COURSE DESCRIPTION, COURSE, GROUP - const cols = [ - { key: 'status', label: 'NOTE', x: margin, w: 40 }, - { key: 'units', label: 'UNITS', x: margin + 40, w: 45 }, - { key: 'teacher', label: 'TEACHER', x: margin + 85, w: 110 }, - { key: 'description', label: 'COURSE DESCRIPTION', x: margin + 195, w: 150 }, - { key: 'courseCode', label: 'COURSE', x: margin + 345, w: 70 }, - { key: 'groupSchedule', label: 'GROUP', x: margin + 415, w: 97 } - ]; - const cellSize = 9; - const headerSize = 9; - - function drawRow(cells, isHeader) { - const f = isHeader ? bold : font; - const size = isHeader ? headerSize : cellSize; - const linesPerCol = cols.map((c) => wrapText(cells[c.key] || '', f, size, c.w - 6)); - const rowHeight = Math.max.apply(null, linesPerCol.map((l) => l.length)) * 11 + 8; - if (y - rowHeight < 50) { - // new page - page.drawText('', { x: margin, y: y, size: 1, font, color: black }); - // (simple: just stop drawing further rows on overflow) - return false; - } - // background for header - if (isHeader) { - page.drawRectangle({ x: margin, y: y - rowHeight, width: contentWidth, height: rowHeight, color: rgb(0.85, 0.9, 0.95) }); - } - // cell borders - page.drawRectangle({ x: margin, y: y - rowHeight, width: contentWidth, height: rowHeight, borderColor: black, borderWidth: 0.5 }); - for (const c of cols) { - page.drawRectangle({ x: c.x, y: y - rowHeight, width: c.w, height: rowHeight, borderColor: black, borderWidth: 0.5 }); - } - // text - for (let i = 0; i < cols.length; i++) { - const c = cols[i]; - const lines = linesPerCol[i]; - let ty = y - 10; - for (const line of lines) { - page.drawText(line, { x: c.x + 3, y: ty, size, font: f, color: black }); - ty -= 11; - } - } - y -= rowHeight; - return true; - } - - // header row - const headerCells = {}; - cols.forEach((c) => { headerCells[c.key] = c.label; }); - drawRow(headerCells, true); - - // data rows - for (const course of courses) { - const cells = { - status: course.status, - units: course.units, - teacher: course.teacher, - description: course.description, - courseCode: course.courseCode, - groupSchedule: (course.group ? course.group + ' ' : '') + (course.schedule || '') - }; - if (!drawRow(cells, false)) break; - } - - // Footer - y -= 12; - if (data.totalUnits) { - page.drawText('Total Units: ' + data.totalUnits, { x: margin, y: y, size: 11, font: bold, color: black }); - y -= 16; - } - const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString(); - page.drawText(printedBy, { x: margin, y: y, size: 9, font, color: gray }); - - return doc.save(); // returns Promise - } - - return { buildStudyLoadPdf }; -}); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: PASS (7 tests). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add pdf.js test/pdf.test.js -git commit -m "feat: build study load PDF" -``` - ---- - -### Task 6: `content.js` — auto-login, privacy accept, scraping - -**Files:** -- Create: `content.js` - -- [ ] **Step 1: Write `content.js`** - -```js -(function () { - 'use strict'; - - const MASTER_KEY = 'enabled'; - - async function isEnabled() { - const { enabled } = await browser.storage.local.get(MASTER_KEY); - return enabled !== false; - } - - function autoAcceptPrivacy() { - const modal = document.getElementById('myModal'); - if (!modal) return; - const checkbox = document.getElementById('Confirmation'); - const submit = document.getElementById('Submit'); - if (checkbox && !checkbox.checked) checkbox.click(); - if (submit) submit.click(); - } - - function watchPrivacyModal() { - autoAcceptPrivacy(); - const observer = new MutationObserver(() => autoAcceptPrivacy()); - observer.observe(document.body, { childList: true, subtree: true }); - setTimeout(() => observer.disconnect(), 30000); - } - - async function autoLogin() { - const { profiles } = await browser.storage.local.get('profiles'); - const active = (profiles || []).find((p) => p.enabled); - if (!active) return; - const userField = document.querySelector( - 'input[name="UserName"], input[name="username"], input[id="UserName"], input[id="username"]' - ); - const passField = document.querySelector('input[type="password"]'); - if (!userField || !passField) return; - userField.value = active.username; - passField.value = active.password; - userField.dispatchEvent(new Event('input', { bubbles: true })); - passField.dispatchEvent(new Event('input', { bubbles: true })); - const form = userField.closest('form') || passField.closest('form'); - if (!form) return; - setTimeout(() => { - const submitBtn = form.querySelector('input[type="submit"], button[type="submit"]'); - if (submitBtn) submitBtn.click(); - else form.submit(); - }, 300); - } - - async function fetchCourseDescription(courseCode, academicPeriod, academicYear, token) { - const body = new URLSearchParams(); - body.set('Courses', courseCode); - body.set('AcademicPeriod', academicPeriod); - body.set('AcademicYear', academicYear); - body.set('AjaxFormVM.Controller', 'CourseSchedule'); - body.set('AjaxFormVM.Action', 'CourseScheduleOffered'); - body.set('AjaxFormVM.ResultTargetId', 'CourseScheduleOfferedList'); - body.set('AjaxFormVM.LinkToReturn', ''); - body.set('AjaxFormVM.LinkToReturnTargetId', ''); - body.set('AjaxFormVM.FormHeader', 'Choose term to view courses offered'); - body.set('AjaxFormVM.FormHeaderIcon', ''); - body.set('AjaxFormVM.FormId', 'CourseScheduleCourseScheduleOffered'); - body.set('AjaxFormVM.ShowCloseButton', 'True'); - body.set('AjaxFormVM.PreventAutoUpdateTarget', 'True'); - body.set('AjaxFormVM.CloseModalAfterSubmit', 'False'); - body.set('AjaxFormVM.AjaxFormType', 'Search'); - body.set('AjaxFormVM.FormNoteLocation', ''); - body.set('AjaxFormVM.SubmitButton.FaIcon', ''); - body.set('AjaxFormVM.SubmitButton.ButtonText', ''); - body.set('AjaxFormVM.SubmitButton.ButtonSize', ''); - body.set('__RequestVerificationToken', token); - const resp = await fetch('/CourseSchedule/CourseScheduleOffered?Length=14', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'X-Requested-With': 'XMLHttpRequest' - }, - body: body.toString(), - credentials: 'same-origin' - }); - const html = await resp.text(); - const courses = ISMIStools.parseOfferedCourses(html); - const match = courses.find((c) => c.courseCode === courseCode); - return match ? match.description : ''; - } - - function extractTotalUnits(text) { - const m = /Total Units:\s*([\d.]+)/i.exec(text); - return m ? m[1] : ''; - } - - async function scrapeStudyLoad() { - const table = document.querySelector('table.table-forum'); - if (!table) throw new Error('No study load table found on this page.'); - const courses = ISMIStools.parseStudyLoadTable(table.outerHTML); - if (!courses.length) throw new Error('No courses found in study load table.'); - - const periodEl = document.getElementById('AcademicPeriod'); - const yearEl = document.getElementById('AcademicYear'); - const tokenEl = document.querySelector('input[name="__RequestVerificationToken"]'); - const academicPeriod = periodEl ? periodEl.value : ''; - const academicYear = yearEl ? yearEl.value : ''; - const token = tokenEl ? tokenEl.value : ''; - - const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))]; - const descMap = {}; - for (const code of uniqueCodes) { - try { - descMap[code] = await fetchCourseDescription(code, academicPeriod, academicYear, token); - } catch (e) { - descMap[code] = ''; - } - } - courses.forEach((c) => { c.description = descMap[c.courseCode] || ''; }); - - let header = {}; - try { - const resp = await fetch('/Manage/Student', { credentials: 'same-origin' }); - const html = await resp.text(); - header = ISMIStools.parseProfilePage(html); - } catch (e) { - header = {}; - } - - const totalUnits = extractTotalUnits(document.body.innerText); - return { header, courses, totalUnits }; - } - - browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg && msg.type === 'scrape-study-load') { - scrapeStudyLoad() - .then((data) => sendResponse({ ok: true, data })) - .catch((err) => sendResponse({ ok: false, error: String((err && err.message) || err) })); - return true; // async response - } - }); - - (async function init() { - if (!(await isEnabled())) return; - watchPrivacyModal(); - if (/\/Account\/Login/i.test(location.href)) autoLogin(); - })(); -})(); -``` - -- [ ] **Step 2: Sanity-check syntax** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && node --check content.js` -Expected: no output (syntax OK). - -- [ ] **Step 3: Commit** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add content.js -git commit -m "feat: content script for auto-login, privacy, scraping" -``` - ---- - -### Task 7: `background.js` — message routing + PDF generation - -**Files:** -- Create: `background.js` - -- [ ] **Step 1: Write `background.js`** - -```js -'use strict'; - -importScripts('libs/pdf-lib.min.js', 'parsers.js', 'pdf.js'); - -function errMsg(err) { - return String((err && err.message) || err); -} - -browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg && msg.type === 'scrape-study-load') { - browser.tabs.query({ active: true, currentWindow: true }) - .then((tabs) => { - const tab = tabs[0]; - if (!tab || tab.id == null) throw new Error('No active tab.'); - return browser.tabs.sendMessage(tab.id, { type: 'scrape-study-load' }); - }) - .then((data) => sendResponse(data)) - .catch((err) => sendResponse({ ok: false, error: errMsg(err) })); - return true; - } - - if (msg && msg.type === 'generate-pdf') { - generatePdf(msg.data) - .then(() => sendResponse({ ok: true })) - .catch((err) => sendResponse({ ok: false, error: errMsg(err) })); - return true; - } -}); - -async function generatePdf(data) { - const bytes = await ISMIStools.buildStudyLoadPdf(data); - const blob = new Blob([bytes], { type: 'application/pdf' }); - const url = URL.createObjectURL(blob); - const name = (data.header && data.header.fullName) || 'Student'; - const filename = 'Study Load - ' + name + '.pdf'; - await browser.downloads.download({ url, filename, saveAs: true }); - setTimeout(() => URL.revokeObjectURL(url), 10000); -} -``` - -- [ ] **Step 2: Sanity-check syntax** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && node --check background.js` -Expected: no output (syntax OK). - -- [ ] **Step 3: Commit** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add background.js -git commit -m "feat: background message routing and PDF download" -``` - ---- - -### Task 8: Popup UI — profiles, master toggle, study-load editor - -**Files:** -- Create: `popup.html` -- Create: `popup.css` -- Create: `popup.js` - -- [ ] **Step 1: Write `popup.html`** - -```html - - - - - - - -
-

ISMIS Helper

-
- - Enabled -
-
- - - -
-

Profiles

-
    - - -
    - - - - - - -``` - -- [ ] **Step 2: Write `popup.css`** - -```css -* { box-sizing: border-box; } -body { - width: 360px; - margin: 0; - font-family: system-ui, sans-serif; - font-size: 13px; - color: #222; -} -.top { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - background: #006699; - color: #fff; -} -.top h1 { margin: 0; font-size: 16px; } -.master { display: flex; align-items: center; gap: 6px; } -.switch { position: relative; display: inline-block; width: 40px; height: 22px; } -.switch input { display: none; } -.slider { - position: absolute; cursor: pointer; inset: 0; - background: #ccc; border-radius: 22px; transition: 0.2s; -} -.slider::before { - content: ""; position: absolute; height: 18px; width: 18px; - left: 2px; top: 2px; background: #fff; border-radius: 50%; transition: 0.2s; -} -.switch input:checked + .slider { background: #4caf50; } -.switch input:checked + .slider::before { transform: translateX(18px); } -.tabs { display: flex; border-bottom: 1px solid #ddd; } -.tabs button { - flex: 1; padding: 8px; border: none; background: #f5f5f5; cursor: pointer; -} -.tabs button.active { background: #fff; font-weight: bold; border-bottom: 2px solid #006699; } -.tab { padding: 12px; } -.tab h2 { margin: 0 0 8px; font-size: 14px; } -.tab h3 { margin: 12px 0 6px; font-size: 12px; color: #555; } -.profile-list { list-style: none; margin: 0 0 8px; padding: 0; } -.profile-list li { - display: flex; align-items: center; gap: 6px; - padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 6px; -} -.profile-list .name { flex: 1; } -.profile-list .user { color: #777; font-size: 11px; } -.btn { - padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; - background: #fff; cursor: pointer; -} -.btn.primary { background: #006699; color: #fff; border-color: #006699; } -.btn-row { display: flex; gap: 6px; margin-top: 6px; } -.form input { - width: 100%; padding: 6px; margin-bottom: 6px; - border: 1px solid #ccc; border-radius: 4px; -} -.form .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } -.course-row { - border: 1px solid #ddd; border-radius: 4px; padding: 6px; margin-bottom: 6px; -} -.course-row .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } -.course-row input { margin-bottom: 4px; } -.status { margin-top: 8px; font-size: 12px; } -.status.error { color: #c00; } -.status.ok { color: #080; } -``` - -- [ ] **Step 3: Write `popup.js`** - -```js -'use strict'; - -const $ = (sel) => document.querySelector(sel); - -const MASTER_KEY = 'enabled'; -const PROFILES_KEY = 'profiles'; - -let editingProfileId = null; - -// ---- Master toggle ---- -async function loadMaster() { - const { enabled } = await browser.storage.local.get(MASTER_KEY); - $('#masterToggle').checked = enabled !== false; - $('#masterLabel').textContent = enabled === false ? 'Disabled' : 'Enabled'; -} -$('#masterToggle').addEventListener('change', async (e) => { - await browser.storage.local.set({ [MASTER_KEY]: e.target.checked }); - $('#masterLabel').textContent = e.target.checked ? 'Enabled' : 'Disabled'; -}); - -// ---- Profiles ---- -async function getProfiles() { - const { profiles } = await browser.storage.local.get(PROFILES_KEY); - return profiles || []; -} -async function saveProfiles(profiles) { - await browser.storage.local.set({ [PROFILES_KEY]: profiles }); -} - -function renderProfiles(profiles) { - const list = $('#profileList'); - list.innerHTML = ''; - for (const p of profiles) { - const li = document.createElement('li'); - const name = document.createElement('div'); - name.className = 'name'; - name.innerHTML = '' + escapeHtml(p.name) + '
    ' + escapeHtml(p.username) + ''; - const toggle = document.createElement('input'); - toggle.type = 'checkbox'; - toggle.checked = !!p.enabled; - toggle.title = 'Enable/disable'; - toggle.addEventListener('change', async () => { - p.enabled = toggle.checked; - await saveProfiles(profiles); - }); - const edit = document.createElement('button'); - edit.className = 'btn'; - edit.textContent = 'Edit'; - edit.addEventListener('click', () => openProfileForm(p)); - const del = document.createElement('button'); - del.className = 'btn'; - del.textContent = 'Delete'; - del.addEventListener('click', async () => { - await saveProfiles(profiles.filter((x) => x.id !== p.id)); - renderProfiles(await getProfiles()); - }); - li.append(toggle, name, edit, del); - list.appendChild(li); - } -} - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - }[c])); -} - -function openProfileForm(p) { - editingProfileId = p ? p.id : null; - $('#pName').value = p ? p.name : ''; - $('#pUsername').value = p ? p.username : ''; - $('#pPassword').value = p ? p.password : ''; - $('#pEnabled').checked = p ? !!p.enabled : true; - $('#profileForm').hidden = false; -} -$('#addProfile').addEventListener('click', () => openProfileForm(null)); -$('#cancelProfile').addEventListener('click', () => { $('#profileForm').hidden = true; }); -$('#saveProfile').addEventListener('click', async () => { - const profiles = await getProfiles(); - const data = { - id: editingProfileId || Date.now().toString(), - name: $('#pName').value.trim(), - username: $('#pUsername').value.trim(), - password: $('#pPassword').value, - enabled: $('#pEnabled').checked - }; - if (!data.name || !data.username) return; - if (editingProfileId) { - const i = profiles.findIndex((x) => x.id === editingProfileId); - if (i >= 0) profiles[i] = data; - } else { - profiles.push(data); - } - await saveProfiles(profiles); - $('#profileForm').hidden = true; - renderProfiles(profiles); -}); - -// ---- Tabs ---- -document.querySelectorAll('.tabs button').forEach((btn) => { - btn.addEventListener('click', () => { - document.querySelectorAll('.tabs button').forEach((b) => b.classList.remove('active')); - btn.classList.add('active'); - $('#tab-login').hidden = btn.dataset.tab !== 'login'; - $('#tab-studyload').hidden = btn.dataset.tab !== 'studyload'; - }); -}); - -// ---- Study Load ---- -function setStatus(msg, cls) { - const el = $('#status'); - el.textContent = msg; - el.className = 'status ' + (cls || ''); -} - -function renderCourses(courses) { - const box = $('#coursesEditor'); - box.innerHTML = ''; - courses.forEach((c, i) => { - const row = document.createElement('div'); - row.className = 'course-row'; - row.innerHTML = - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '' + - ''; - box.appendChild(row); - }); - box.querySelectorAll('input').forEach((inp) => { - inp.addEventListener('input', () => { - const i = +inp.dataset.i; - const f = inp.dataset.f; - studyLoadCourses[i][f] = inp.value; - }); - }); - box.querySelectorAll('.remove-course').forEach((btn) => { - btn.addEventListener('click', () => { - studyLoadCourses.splice(+btn.dataset.i, 1); - renderCourses(studyLoadCourses); - }); - }); -} - -let studyLoadCourses = []; - -$('#loadFromPortal').addEventListener('click', async () => { - setStatus('Loading from portal...'); - try { - const res = await browser.runtime.sendMessage({ type: 'scrape-study-load' }); - if (!res || !res.ok) throw new Error((res && res.error) || 'Scrape failed'); - const d = res.data; - $('#hFullName').value = d.header.fullName || ''; - $('#hStudentId').value = d.header.studentId || ''; - $('#hProgram').value = d.header.program || ''; - $('#hYearLevel').value = d.header.yearLevel || ''; - $('#hSemester').value = d.header.semester || ''; - $('#hDateEnrolled').value = d.header.dateEnrolled || ''; - $('#hDateRange').value = d.header.dateRange || ''; - $('#totalUnits').value = d.totalUnits || ''; - studyLoadCourses = d.courses || []; - renderCourses(studyLoadCourses); - $('#studyLoadForm').hidden = false; - setStatus('Loaded. Review and generate.', 'ok'); - } catch (err) { - setStatus('Could not load from portal: ' + err.message + ' — enter manually below.', 'error'); - studyLoadCourses = []; - renderCourses(studyLoadCourses); - $('#studyLoadForm').hidden = false; - } -}); - -$('#addCourse').addEventListener('click', () => { - studyLoadCourses.push({ group: '', courseCode: '', description: '', units: '', teacher: '', schedule: '', status: '' }); - renderCourses(studyLoadCourses); -}); - -$('#generatePdf').addEventListener('click', async () => { - const data = { - header: { - fullName: $('#hFullName').value.trim(), - studentId: $('#hStudentId').value.trim(), - program: $('#hProgram').value.trim(), - yearLevel: $('#hYearLevel').value.trim(), - semester: $('#hSemester').value.trim(), - dateEnrolled: $('#hDateEnrolled').value.trim(), - dateRange: $('#hDateRange').value.trim() - }, - courses: studyLoadCourses, - totalUnits: $('#totalUnits').value.trim() - }; - if (!data.courses.length) { setStatus('Add at least one course.', 'error'); return; } - setStatus('Generating PDF...'); - try { - const res = await browser.runtime.sendMessage({ type: 'generate-pdf', data }); - if (!res || !res.ok) throw new Error((res && res.error) || 'Generation failed'); - setStatus('PDF downloaded.', 'ok'); - } catch (err) { - setStatus('Error: ' + err.message, 'error'); - } -}); - -// ---- Init ---- -(async function init() { - await loadMaster(); - renderProfiles(await getProfiles()); -})(); -``` - -- [ ] **Step 4: Sanity-check syntax** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && node --check popup.js` -Expected: no output (syntax OK). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add popup.html popup.css popup.js -git commit -m "feat: popup UI for profiles and study load" -``` - ---- - -### Task 9: End-to-end manual verification in Firefox - -**Files:** none (verification only) - -- [ ] **Step 1: Run the full test suite** - -Run: `cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper && npm test` -Expected: all tests PASS. - -- [ ] **Step 2: Load the extension in Firefox** - -1. Open Firefox → `about:debugging` → "This Firefox" → "Load Temporary Add-on". -2. Select `manifest.json` in `ismis-helper/`. -3. Confirm the extension appears with no load errors. - -- [ ] **Step 3: Verify auto-login** -1. Open `https://ismis.usc.edu.ph/Account/Login`. -2. Add a profile in the popup (Auto-Login tab) and enable it. -3. Reload the login page. Expected: username/password filled and form submitted. - -- [ ] **Step 4: Verify privacy auto-accept** -1. With the master toggle on, open any portal page that shows the privacy modal. -2. Expected: the `#myModal` modal is dismissed automatically. - -- [ ] **Step 5: Verify master toggle** -1. Turn the master toggle OFF in the popup. -2. Reload the login page. Expected: no auto-fill/submit occurs. - -- [ ] **Step 6: Verify Study Load PDF** -1. Open the Study Load page in the portal. -2. In the popup Study Load tab, click "Load from portal". -3. Expected: header and courses populate; descriptions filled in. -4. Click "Generate PDF". Expected: a PDF downloads matching the official layout. - -- [ ] **Step 7: Verify manual fallback** -1. Open a non-portal page (or a portal page without the table). -2. Click "Load from portal". Expected: manual entry form appears. -3. Enter a course, click "Generate PDF". Expected: PDF downloads. - -- [ ] **Step 8: Commit any fixes** - -```bash -cd /Users/adrianbonpin/Documents/Code/personal/ismis-helper -git add -A -git commit -m "fix: verification adjustments" -``` - ---- - -## Self-Review - -**Spec coverage:** -- Auto-login (fill + submit) → Task 6, Task 9. -- Master toggle → Task 8 (popup), Task 6 (content gate), Task 9. -- Auto-accept privacy policy → Task 6, Task 9. -- Profiles (add/edit/delete/toggle) → Task 8. -- Study Load scrape → Task 6. -- Description lookup via Offered Courses → Task 6 + Task 4 parser. -- Header from My Profile → Task 6 + Task 3 parser. -- Manual fallback → Task 8 (popup editor), Task 9. -- PDF generation replicating official look → Task 5. -- Download via downloads API → Task 7. - -**Placeholder scan:** No TBD/TODO; all steps contain concrete code and commands. - -**Type consistency:** `ISMIStools` namespace used consistently across `parsers.js`, `pdf.js`, `content.js`, `background.js`. Message types `scrape-study-load` and `generate-pdf` match between popup, background, and content. Data shape `{ header, courses, totalUnits }` consistent across scrape, popup, and PDF builder. diff --git a/docs/superpowers/specs/2026-08-18-ismis-helper-design.md b/docs/superpowers/specs/2026-08-18-ismis-helper-design.md deleted file mode 100644 index e7e551f..0000000 --- a/docs/superpowers/specs/2026-08-18-ismis-helper-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# ISMIS Helper — Firefox Extension Design - -**Date:** 2026-08-18 -**Status:** Approved -**Target site:** `https://ismis.usc.edu.ph` (USC ISMIS student portal) - -## Overview - -A personal Firefox extension (Manifest V3, no build step) that improves the USC ISMIS -student portal experience. It provides three features: - -1. **Auto-login** — auto-fill and auto-submit the login form from a saved profile. -2. **Auto-accept Privacy Policy** — dismiss the recurring privacy-policy modal. -3. **Study Load PDF** — an on-page "Download PDF" button on the Study Load page that - generates a PDF replicating the official ISMIS Study Load document (Letter size, - with course descriptions looked up from the Offered Courses endpoint) and - downloads it directly. - -A single **master toggle** disables all extension behavior on the portal. - -## Architecture - -Single MV3 extension, plain JavaScript, no bundler. No background script — all logic -runs in the content script (scraping and print-document building happen in-page). - -``` -ismis-helper/ -├── manifest.json # MV3 manifest, permissions, content script, popup, background -├── background.js # message handler: build PDF (pdf-lib) + download via downloads API -├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, button, scrape -├── parsers.js # UMD: table/profile/offered-courses parsing + filtering -├── pdf.js # UMD: buildStudyLoadPdf (Letter PDF, official layout, pagination) -├── popup.html # popup UI (master toggle + profiles) -├── popup.js # popup logic -├── popup.css # popup styles -├── libs/pdf-lib.min.js # vendored pdf-lib library (used by background) -└── icons/ # extension icons (16, 32, 48, 128) -``` - -### Permissions (manifest.json) -- `storage` — persist profiles and the master toggle. -- `downloads` — save the generated Study Load PDF. -- `host_permissions` — `https://ismis.usc.edu.ph/*` for the content script. -- Background loads `libs/pdf-lib.min.js` + `pdf.js` via `background.scripts`. - -## Background script -- `background.js` handles the `generate-pdf` message from the content script: - builds the PDF via `ISMIStools.buildStudyLoadPdf`, wraps the bytes in a Blob, - downloads via `browser.downloads.download({ saveAs: true })`, and revokes the - object URL when the download reaches `complete`/`interrupted` (and on failure). - -## Component 1 — Auto-login - -- Content script runs on `https://ismis.usc.edu.ph/Account/Login`. -- Reads the active (enabled) profile from `browser.storage.local`. -- Locates the username and password input fields (`name`/`id` = `Username` / - `Password`, with fallback variants). -- Fills both fields, then submits the form (auto-submit). -- If no profile is enabled, or the master toggle is off, does nothing. - -## Component 2 — Auto-accept Privacy Policy - -- On any portal page, if the privacy modal `#myModal` is present and visible: - - Check the `#Confirmation` checkbox ("I have read and understood the Privacy - Policy Statement"). - - Click the `#Submit` button (Continue), which is disabled until the checkbox is - checked. -- Gated by the master toggle. - -## Component 3 — Profiles - -- Stored in `browser.storage.local` under key `profiles` as an array of: - `{ id, name, username, password, enabled }`. -- Popup provides add, edit, delete, and enable/disable per profile. -- Only one profile can be enabled at a time (enabling one disables the others); - that profile is used for auto-login. -- Master toggle stored under key `enabled` (boolean). - -## Component 4 — Study Load PDF - -### On-page button -- The content script watches the Study Load page (`#StudyLoad`). When the course - table appears (after the user selects academic period/year and searches), it - injects a **"Download PDF"** button inline with the - `12.00 Units / Total Units: 18.00` footer (button on the left, units text on the - right, via a flex row), styled like the page's buttons (`btn green`). - -### Scrape -- Parse the course table (columns: Group #, Course Code, Schedule, Faculty, Units, - Status) via `parseStudyLoadTable`. -- **Header info** (full name, student ID, program, year level, semester) is scraped - from the **My Profile** page (`/Manage/Student`). - -### Filtering -- **DISSOLVED courses are excluded** (`filterStudyLoadCourses`). -- Total units = sum of the included courses' units (`sumUnits`). - -### Description lookup -- For each unique course code, POST to - `https://ismis.usc.edu.ph/CourseSchedule/CourseScheduleOffered` with the - `__RequestVerificationToken` and selected `AcademicPeriod`/`AcademicYear`, then - parse the returned table for the **Course Description**. -- If lookup fails for a course, the description is left blank and generation - continues. - -### PDF generation -- `pdf.js` (`buildStudyLoadPdf`, using **pdf-lib**) builds a Letter-size (612×792pt) - PDF replicating the official layout: title block (STUDENT'S STUDY LOAD, semester, - date range, date enrolled), student info row (ID, name, program, year level), the - course table (GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE — NOTE is - the status), and a footer (total units, "Printed by: …"). Multi-page - pagination re-draws the table header. NOTE column is wide enough for `REGULAR`. -- The content script sends the data to the background (`generate-pdf`), which - downloads the PDF via the `downloads` API (`saveAs: true`). - -## Data flow - -``` -Study Load page ──▶ content script (scrape + filter DISSOLVED + description lookup) - │ - ▼ browser.runtime.sendMessage({ type: 'generate-pdf', data }) - background.js (pdf-lib buildStudyLoadPdf) - │ - ▼ - browser.downloads.download → Study Load PDF file -``` - -## Error handling - -- Master toggle off → no extension behavior on the portal. -- No enabled profile → no auto-login. -- Description lookup fails for a course → blank description, PDF still generated. -- No enrolled (non-DISSOLVED) courses → error toast, no download. -- Download fails → error toast; object URL is revoked. - -## Testing - -- Node `node:test` suites: parsers (table/profile/offered-courses/filter/sum) and - pdf (valid Letter PDF output). -- Load via `about:debugging` → "Load Temporary Add-on". -- Verify auto-login on the login page. -- Verify privacy modal auto-accept. -- Verify profile add/edit/delete/toggle and master toggle. -- Verify the on-page Download PDF button: appears inline with the total-units - footer, excludes DISSOLVED courses, and downloads a Letter-size PDF.