From a778840845eac789eb02f422c5560342874f095e Mon Sep 17 00:00:00 2001 From: adrianbonpin Date: Tue, 18 Aug 2026 15:16:07 +0800 Subject: [PATCH] Add ISMIS Helper implementation plan --- .../plans/2026-08-18-ismis-helper.md | 1285 +++++++++++++++++ test/fixtures/offered-courses.html | 186 +++ test/fixtures/profile.html | 70 + test/fixtures/study-load-table.html | 104 ++ 4 files changed, 1645 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-ismis-helper.md create mode 100644 test/fixtures/offered-courses.html create mode 100644 test/fixtures/profile.html create mode 100644 test/fixtures/study-load-table.html diff --git a/docs/superpowers/plans/2026-08-18-ismis-helper.md b/docs/superpowers/plans/2026-08-18-ismis-helper.md new file mode 100644 index 0000000..8816629 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-ismis-helper.md @@ -0,0 +1,1285 @@ +# 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. diff --git a/test/fixtures/offered-courses.html b/test/fixtures/offered-courses.html new file mode 100644 index 0000000..0b59242 --- /dev/null +++ b/test/fixtures/offered-courses.html @@ -0,0 +1,186 @@ + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    CourseCodeCourse DescriptionCourse StatusTeacher/sScheduleDepartment ReservedEnrolled Students
    + +
    + IT 3206 - Group 1 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYPARALLELDELA PAZ, KHENT L.
    + TTh 03:00 PM - 04:30 PM LB468TCp
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (5)
    + +
    10/10
    + IT 3206 - Group 2 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYPARALLEL + TTh 06:00 PM - 07:30 PM LB468TCp
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (5)
    + +
    5/5
    + IT 3206 - Group 3 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYPARALLEL + TTh 12:30 PM - 02:00 PM LB447TCp
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (4)
    + +
    4/4
    + IT 3206N - Group 1 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYBLOCKSECTIONDELA PAZ, KHENT L.
    + MW 06:00 PM - 08:30 PM LB442TC
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (20)
    + +
    19/29
    + IT 3206N - Group 2 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYBLOCKSECTIONDELA PAZ, KHENT L.
    + TTh 06:00 PM - 08:30 PM LB468TC
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (20)
    + +
    28/29
    + IT 3206N - Group 3 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYBLOCKSECTIONDELA PAZ, KHENT L.
    + MW 12:30 PM - 03:00 PM LB448TC
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (40)
    + +
    29/29
    + IT 3206N - Group 4 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYBLOCKSECTIONDELA PAZ, KHENT L.
    + TTh 03:00 PM - 05:30 PM LB445TC
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (20)
    + +
    28/29
    + IT 3206N - Group 5 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYREGULARDIONSON, PRIMO JR. L.
    + TTh 12:30 PM - 03:00 PM LB446TC
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (20)
    + +
    28/29
    + IT 3206N - Group 6 + INTEGRATIVE PROGRAMMING AND TECHNOLOGYREGULAR + Sat 07:30 AM - 12:30 PM LB446TC
    +
    +DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS   (40)
    + +
    9/25
    + +
    + Page 1 of 1 + + +
    +
    +
    +
    + + + + + diff --git a/test/fixtures/profile.html b/test/fixtures/profile.html new file mode 100644 index 0000000..8a81225 --- /dev/null +++ b/test/fixtures/profile.html @@ -0,0 +1,70 @@ +
    +
    +
    +
    + Student Profile +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +

    + BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY
    + + DEPARTMENT OF COMPUTER, INFORMATION SCIENCES AND MATHEMATICS
    + 1ST Semester 2026 +
    +

    +
    +

    +
    +
    +

    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Full Name     ADRIAN ALFRED CHIONG BONPIN
    Student ID     22103625
    Date of birth     Wednesday, January 22, 2003
    Year Level     4
    Student Type     CONTINUING
    Foreign     NO
    Faculty Evaluation     COMPLETED
    1ST Semester 2026
    +
    + +
    +
    +
    +
    +
    +
    + diff --git a/test/fixtures/study-load-table.html b/test/fixtures/study-load-table.html new file mode 100644 index 0000000..494f491 --- /dev/null +++ b/test/fixtures/study-load-table.html @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Group #Course CodeScheduleFacultyUnitsStatus
    +1 + + + +CS 5105 + F 01:30 PM - 04:30 PM LB486TC TC
    +
    + +3.00 +DISSOLVED
    +1 + + + +IT 3206 + T Th 03:00 PM - 04:30 PM LB468TCp TC
    +
    + DELA PAZ, KHENT LOZANO + +3.00 +PARALLEL
    +4 + + + +IT 4102N + Sun 07:30 AM - 10:30 AM LB486TCp TC
    +
    + +3.00 +REGULAR
    +1 + + + +IT 4103 + F 06:00 PM - 09:00 PM LB484TCp TC
    +
    + +3.00 +PARALLEL
    +1 + + + +IT 5101 + F 07:30 AM - 10:30 AM LB442TCp TC
    +
    + +3.00 +PARALLEL
    \ No newline at end of file