# 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 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.