From 13ccc6ca63c451bc00485b89fdc88e3757a0d64f Mon Sep 17 00:00:00 2001 From: haopengzhan Date: Sun, 23 Aug 2026 11:25:19 -0700 Subject: [PATCH] feat: initial release of Brightwheel Memories & Reports Exporter Chrome extension --- CHROMEWEBSTORE.md | 63 +++ README.md | 71 +++ dashboard/dashboard.css | 616 +++++++++++++++++++++++++ dashboard/dashboard.html | 294 ++++++++++++ dashboard/dashboard.js | 804 +++++++++++++++++++++++++++++++++ dashboard/viewer_template.html | 427 +++++++++++++++++ icons/icon128.png | Bin 0 -> 1263 bytes icons/icon16.png | Bin 0 -> 232 bytes icons/icon48.png | Bin 0 -> 580 bytes lib/jszip.min.js | 14 + manifest.json | 41 ++ popup/popup.css | 186 ++++++++ popup/popup.html | 66 +++ popup/popup.js | 36 ++ scripts/background.js | 51 +++ scripts/content_script.js | 126 ++++++ 16 files changed, 2795 insertions(+) create mode 100644 CHROMEWEBSTORE.md create mode 100644 README.md create mode 100644 dashboard/dashboard.css create mode 100644 dashboard/dashboard.html create mode 100644 dashboard/dashboard.js create mode 100644 dashboard/viewer_template.html create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon48.png create mode 100644 lib/jszip.min.js create mode 100644 manifest.json create mode 100644 popup/popup.css create mode 100644 popup/popup.html create mode 100644 popup/popup.js create mode 100644 scripts/background.js create mode 100644 scripts/content_script.js diff --git a/CHROMEWEBSTORE.md b/CHROMEWEBSTORE.md new file mode 100644 index 0000000..a493060 --- /dev/null +++ b/CHROMEWEBSTORE.md @@ -0,0 +1,63 @@ +# Chrome Web Store Listing: Brightwheel Memories & Reports Exporter + +**Last Updated:** 2026-08-23 +**Version:** 1.0.0 +**Status:** Ready for Packing & Review + +--- + +## 1. Store Listing Metadata + +- **Name:** Brightwheel Memories & Reports Exporter +- **Short Description (max 132 chars):** Export all your child's high-res photos, videos, daily care logs, and activity reports from Brightwheel into a ZIP archive. +- **Category:** Productivity / Photos +- **Language:** English + +### Detailed Description + +```markdown +Brightwheel Memories & Reports Exporter lets parents and guardians create a permanent local backup of their children's preschool and daycare memories. + +Unlike manual downloads or limited screenshot tools, this extension exports your complete Brightwheel timeline into an organized, offline ZIP archive. + +✨ KEY FEATURES: +• High-Resolution Media: Download original quality photos and videos organized neatly by year and month (/YYYY/MM/photos/). +• Daily Care Logs: Preserve meal records, nap times and durations, diaper/potty logs, and medication entries. +• Milestones & Observations: Back up developmental milestones, curriculum tags, and teacher notes. +• Attendance & Incidents: Export check-in/out timestamps and health screening logs. +• Offline Interactive Viewer: Includes a standalone "index.html" inside the ZIP to browse, search, and relive memories locally with no internet connection required. +• Spreadsheet & JSON Exports: Generates activities.csv for Excel/Google Sheets and manifest.json for data portability. + +🔒 PRIVACY FIRST: +• Runs 100% locally in your browser. +• No logins, passwords, or personal data are ever sent to any third-party servers. +• Direct connection between your browser and Brightwheel/S3. +``` + +--- + +## 2. Permissions Justification + +| Permission / Host | Justification | +| :--- | :--- | +| `storage` | Used to remember the selected student ID and export preferences locally on the user's computer. | +| `downloads` | Used to save the generated ZIP archive, media files, and CSV/JSON reports to the user's Downloads folder. | +| `tabs` | Used to detect if an active Brightwheel tab is open and to launch the exporter dashboard in a new tab. | +| `https://schools.mybrightwheel.com/*` | Required to communicate with the Brightwheel web portal and fetch the student's activity feed using the user's active session. | +| `https://*.brightwheel.com/*` | Required to access Brightwheel API subdomains and services. | +| `https://*.cloudfront.net/*` | Required to download high-resolution photos and video assets hosted on Brightwheel's CDN. | +| `https://*.s3.amazonaws.com/*` | Required to retrieve multimedia attachments and avatars stored in AWS S3 storage buckets. | + +--- + +## 3. Privacy Policy & Data Handling + +- **Personal Information Collected:** None. The extension does not collect, track, or transmit any user information. +- **Remote Communications:** Requests are made exclusively to Brightwheel domain endpoints and official CDN/S3 media storage URLs to fetch the user's authorized family records. +- **Data Retention:** All exported files remain strictly on the user's local filesystem. + +--- + +## 4. Version History + +- **1.0.0 (2026-08-23)**: Initial release with full-feed harvester, multi-format export (ZIP, CSV, JSON, offline HTML viewer), rate-limit backoff, and live progress dashboard. diff --git a/README.md b/README.md new file mode 100644 index 0000000..99436da --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Brightwheel Memories & Reports Exporter Chrome Extension + +A Chrome Extension (Manifest V3) that allows parents and guardians to bulk export all of their children's memories, high-resolution photos, videos, daily care reports (meals, naps, potty), learning milestones, and activity manifests from Brightwheel into an organized offline ZIP archive. + +## ✨ Features + +- **Full Data Manifest**: Exports complete student records, activities, and teacher notes in structured `manifest.json` format. +- **High-Resolution Media**: Downloads original full-size photos and videos organized neatly by year and month (`/YYYY/MM/photos/` & `.../videos/`). +- **Daily Care Logs**: Captures meal entries, nap start/end times and durations, diaper/potty logs, and medication logs. +- **Learning Milestones**: Backs up developmental milestones, curriculum tags, and teacher observations. +- **Attendance & Communication**: Includes check-in/out records, health screenings, and message history. +- **Spreadsheet Ready**: Generates an `activities.csv` spreadsheet compatible with Microsoft Excel, Numbers, and Google Sheets. +- **Offline Interactive Timeline Viewer**: Bundles a standalone `index.html` inside the exported ZIP so you can browse your child's photos, search captions, and filter events locally on your computer with no internet required. +- **Safe & Private**: Runs 100% locally in your browser. Seamlessly uses your active Brightwheel logged-in session cookies without asking for passwords or transmitting your family's data to third parties. +- **Smart Rate-Limiting**: Includes automatic exponential backoff (handling HTTP 429 throttling) and configurable pacing delays. + +--- + +## 🚀 Installation (Load Unpacked in Chrome) + +1. Open Google Chrome and navigate to `chrome://extensions/`. +2. Turn **ON** the **Developer mode** toggle in the top-right corner. +3. Click the **Load unpacked** button in the top-left corner. +4. Select this directory: + ``` + /Users/haopengzhan/Documents/MyGit/brightwheel-exporter-chrome-extension + ``` +5. The **Brightwheel Memories & Reports Exporter** icon will now appear in your Chrome toolbar! + +--- + +## 📖 How to Use + +1. Log into your Brightwheel account in Chrome at [https://schools.mybrightwheel.com](https://schools.mybrightwheel.com). +2. Navigate to your child's feed page (e.g. `https://schools.mybrightwheel.com/students/edda433b-2a24-4da7-bbd5-9f131b4b2565/feed`). +3. Click the **Brightwheel Exporter** icon in your browser toolbar (or click the floating **"Export Brightwheel Data"** button on the bottom right of the page). +4. Click **Open Exporter Dashboard**. +5. In the Dashboard: + - Select your child (or enter a custom student UUID). + - Choose your date range (**All Time**, **This Year**, or **Custom**). + - Select the content categories and export formats you want. + - Click **Start Complete Export**. +6. The extension will automatically harvest activities, download media with live progress tracking, and generate your downloadable ZIP archive! + +--- + +## 📁 Exported Archive Structure + +Inside the downloaded `.zip` file, you will find: + +``` +[ChildName]_[YYYY-MM-DD]/ +├── index.html # Offline interactive timeline viewer +├── manifest.json # Complete raw structured data +├── activities.csv # Spreadsheet for Excel / Google Sheets +├── photos/ +│ ├── 2026-08/ +│ │ ├── 2026-08-20_abc123.jpg +│ │ └── ... +│ └── 2026-07/ +└── videos/ + └── 2026-08/ + └── 2026-08-15_xyz789.mp4 +``` + +--- + +## 🔒 Privacy & Security + +- **Zero Data Collection**: This extension does not send any personal data, photos, credentials, or cookies to external servers. All requests happen strictly between your browser and Brightwheel/AWS S3. +- **Local Packaging**: The ZIP compression and HTML viewer creation happen entirely in your local browser memory using client-side JSZip. diff --git a/dashboard/dashboard.css b/dashboard/dashboard.css new file mode 100644 index 0000000..8adbbe4 --- /dev/null +++ b/dashboard/dashboard.css @@ -0,0 +1,616 @@ +:root { + --primary: #FF6B35; + --primary-hover: #E85820; + --primary-light: #FFF0EB; + --primary-gradient: linear-gradient(135deg, #FF6B35 0%, #FF8E53 100%); + --bg: #F8FAFC; + --card-bg: #FFFFFF; + --text-main: #0F172A; + --text-muted: #64748B; + --border: #E2E8F0; + --radius: 12px; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 16px -2px rgba(0, 0, 0, 0.08); + --success: #10B981; + --warning: #F59E0B; + --danger: #EF4444; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + background-color: var(--bg); + color: var(--text-main); + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* App Layout */ +.app-layout { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Navigation Bar */ +.top-nav { + background: var(--card-bg); + border-bottom: 1px solid var(--border); + padding: 14px 28px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-brand { + display: flex; + align-items: center; + gap: 14px; +} + +.brand-icon { + width: 42px; + height: 42px; + background: var(--primary-gradient); + color: #FFFFFF; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3); +} + +.brand-text h1 { + font-size: 18px; + font-weight: 800; + color: var(--text-main); + line-height: 1.2; +} + +.brand-text span { + font-size: 12px; + color: var(--text-muted); +} + +.session-badge { + display: flex; + align-items: center; + gap: 8px; + background: #F1F5F9; + padding: 8px 14px; + border-radius: 20px; + font-size: 12.5px; + font-weight: 500; + color: #334155; + border: 1px solid var(--border); +} + +.session-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--warning); +} + +.session-dot.online { + background-color: var(--success); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); +} + +.icon-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + padding: 2px; +} + +.icon-btn:hover { + color: var(--primary); +} + +/* Main Container */ +.main-container { + max-width: 1280px; + width: 100%; + margin: 28px auto; + padding: 0 24px; + flex: 1; +} + +.dashboard-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 28px; + align-items: start; +} + +@media (max-width: 900px) { + .dashboard-grid { + grid-template-columns: 1fr; + } +} + +/* Cards */ +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + overflow: hidden; + margin-bottom: 20px; +} + +.card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-title-group { + display: flex; + align-items: center; + gap: 10px; +} + +.step-num { + width: 22px; + height: 22px; + background: var(--primary-light); + color: var(--primary); + font-weight: 700; + font-size: 12px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.card-header h2 { + font-size: 15px; + font-weight: 700; + color: var(--text-main); +} + +.card-content { + padding: 20px; +} + +/* Students Selector */ +.students-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.student-card { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 14px; + border: 2px solid var(--border); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; + background: #FAFAFA; +} + +.student-card:hover { + border-color: #CBD5E1; +} + +.student-card.selected { + border-color: var(--primary); + background: var(--primary-light); +} + +.student-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--primary-gradient); + color: #FFFFFF; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + overflow: hidden; + flex-shrink: 0; +} + +.student-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.student-info h4 { + font-size: 14px; + font-weight: 700; + color: var(--text-main); +} + +.student-info p { + font-size: 12px; + color: var(--text-muted); +} + +.loading-placeholder { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-muted); + font-size: 13px; + padding: 12px 0; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid #E2E8F0; + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.manual-id-details summary { + font-size: 12.5px; + color: var(--primary); + cursor: pointer; + margin-top: 4px; + user-select: none; +} + +.manual-input-row { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.manual-input-row input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12.5px; + outline: none; +} + +.manual-input-row input:focus { + border-color: var(--primary); +} + +/* Date Filters */ +.quick-dates-pills { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.pill-btn { + padding: 6px 12px; + border-radius: 20px; + border: 1px solid var(--border); + background: #FFFFFF; + font-size: 12.5px; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; +} + +.pill-btn:hover { + border-color: #94A3B8; +} + +.pill-btn.active { + background: var(--primary); + border-color: var(--primary); + color: #FFFFFF; +} + +.date-inputs-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.date-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.date-field label { + font-size: 11.5px; + font-weight: 600; + color: var(--text-muted); +} + +.date-field input { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + outline: none; +} + +.date-field input:focus { + border-color: var(--primary); +} + +/* Checkboxes */ +.checkbox-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.custom-checkbox { + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + position: relative; + user-select: none; +} + +.custom-checkbox input { + position: absolute; + opacity: 0; + cursor: pointer; +} + +.checkmark { + width: 18px; + height: 18px; + border: 2px solid #CBD5E1; + border-radius: 5px; + margin-top: 2px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.custom-checkbox input:checked ~ .checkmark { + background-color: var(--primary); + border-color: var(--primary); +} + +.custom-checkbox input:checked ~ .checkmark::after { + content: ""; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-bottom: 2px; +} + +.checkbox-label { + display: flex; + flex-direction: column; +} + +.checkbox-label strong { + font-size: 13.5px; + font-weight: 600; + color: var(--text-main); +} + +.checkbox-label small { + font-size: 12px; + color: var(--text-muted); +} + +/* Action Box */ +.hero-action-card { + padding: 24px; +} + +.action-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 18px; +} + +.action-header h3 { + font-size: 16px; + font-weight: 800; +} + +.speed-setting { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); +} + +.speed-setting select { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--border); + font-size: 12px; + outline: none; +} + +.primary-controls { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 22px; +} + +.primary-action-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 14px 20px; + background: var(--primary-gradient); + color: #FFFFFF; + border: none; + border-radius: 10px; + font-size: 15px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 6px 18px rgba(255, 107, 53, 0.35); + transition: all 0.2s ease; +} + +.primary-action-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(255, 107, 53, 0.45); +} + +.primary-action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.secondary-controls-row { + display: flex; + gap: 10px; +} + +.secondary-btn, .danger-btn { + flex: 1; + padding: 8px 14px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border); + background: #FFFFFF; + transition: all 0.2s ease; +} + +.secondary-btn:hover { + background: #F1F5F9; +} + +.danger-btn { + color: var(--danger); + border-color: #FECACA; +} + +.danger-btn:hover { + background: #FEF2F2; +} + +/* Progress Bar */ +.progress-section { + margin-bottom: 20px; +} + +.progress-labels { + display: flex; + justify-content: space-between; + font-size: 12.5px; + font-weight: 600; + margin-bottom: 6px; +} + +.progress-bar-track { + width: 100%; + height: 10px; + background: #E2E8F0; + border-radius: 10px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: var(--primary-gradient); + border-radius: 10px; + transition: width 0.3s ease; +} + +/* Metrics Grid */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.metric-card { + background: #F8FAFC; + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-direction: column; +} + +.metric-value { + font-size: 17px; + font-weight: 800; + color: var(--text-main); +} + +.metric-label { + font-size: 11.5px; + color: var(--text-muted); +} + +/* Terminal Log */ +.log-card { + display: flex; + flex-direction: column; + height: 320px; +} + +.log-header h3 { + font-size: 14px; + font-weight: 700; +} + +.link-btn { + background: none; + border: none; + color: var(--primary); + font-size: 12px; + cursor: pointer; +} + +.terminal-log-body { + flex: 1; + background: #0F172A; + color: #E2E8F0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + padding: 14px; + overflow-y: auto; + line-height: 1.6; +} + +.log-line.info { color: #94A3B8; } +.log-line.success { color: #34D399; } +.log-line.warn { color: #FBBF24; } +.log-line.error { color: #F87171; } diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html new file mode 100644 index 0000000..c18a919 --- /dev/null +++ b/dashboard/dashboard.html @@ -0,0 +1,294 @@ + + + + + + Brightwheel Exporter Dashboard + + + + +
+ +
+ + +
+ + Detecting Brightwheel Session... + +
+
+ + +
+
+ +
+ +
+
+
+ 1 +

Select Child / Student

+
+
+
+
+
+
+ Scanning Brightwheel profile... +
+
+ + +
+ Enter Student UUID Manually +
+ + +
+
+
+
+ + +
+
+
+ 2 +

Date Range Filter

+
+
+
+
+ + + + + +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ 3 +

Export Categories

+
+
+
+
+ + + + + + + + + +
+
+
+ + +
+
+
+ 4 +

Archive Formats

+
+
+
+
+ + + + + + + +
+
+
+
+ + +
+ +
+
+

Export Controller

+
+ + +
+
+ +
+ + + +
+ + +
+
+ Ready to start + 0% +
+
+
+
+
+ + +
+
+ 0 + Activities Found +
+
+ 0 / 0 + Media Files +
+
+ 0.0 MB + Downloaded Data +
+
+ 0 + Retries / Backoffs +
+
+
+ + +
+
+

Live Activity Log

+
+ +
+
+
+
[System] Exporter dashboard initialized. Waiting to begin...
+
+
+
+
+
+
+ + + + diff --git a/dashboard/dashboard.js b/dashboard/dashboard.js new file mode 100644 index 0000000..35a1b53 --- /dev/null +++ b/dashboard/dashboard.js @@ -0,0 +1,804 @@ +/** + * Brightwheel Exporter - Dashboard Controller + */ + +// Application State +const state = { + students: [], + selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565', + selectedStudentData: null, + isExporting: false, + isPaused: false, + isCancelled: false, + activities: [], + mediaQueue: [], + stats: { + activitiesCount: 0, + mediaTotal: 0, + mediaDownloaded: 0, + mediaFailed: 0, + bytesDownloaded: 0, + retryCount: 0 + } +}; + +// DOM Elements +let el = {}; + +document.addEventListener('DOMContentLoaded', async () => { + initDOMElements(); + setupEventListeners(); + initDatePresets(); + await checkSessionAndDiscoverStudents(); +}); + +function initDOMElements() { + el = { + sessionDot: document.getElementById('sessionDot'), + sessionStatusText: document.getElementById('sessionStatusText'), + refreshSessionBtn: document.getElementById('refreshSessionBtn'), + studentsList: document.getElementById('studentsList'), + manualDetails: document.getElementById('manualDetails'), + manualStudentId: document.getElementById('manualStudentId'), + setManualIdBtn: document.getElementById('setManualIdBtn'), + quickDatePills: document.getElementById('quickDatePills'), + customDateRow: document.getElementById('customDateRow'), + startDate: document.getElementById('startDate'), + endDate: document.getElementById('endDate'), + catPhotos: document.getElementById('catPhotos'), + catCare: document.getElementById('catCare'), + catMilestones: document.getElementById('catMilestones'), + catAttendance: document.getElementById('catAttendance'), + catMessages: document.getElementById('catMessages'), + fmtZip: document.getElementById('fmtZip'), + fmtViewer: document.getElementById('fmtViewer'), + fmtJson: document.getElementById('fmtJson'), + fmtCsv: document.getElementById('fmtCsv'), + concurrencySelect: document.getElementById('concurrencySelect'), + startExportBtn: document.getElementById('startExportBtn'), + inProgressControls: document.getElementById('inProgressControls'), + pauseExportBtn: document.getElementById('pauseExportBtn'), + cancelExportBtn: document.getElementById('cancelExportBtn'), + progressStatusText: document.getElementById('progressStatusText'), + progressPercent: document.getElementById('progressPercent'), + progressBarFill: document.getElementById('progressBarFill'), + metricActivitiesCount: document.getElementById('metricActivitiesCount'), + metricMediaCount: document.getElementById('metricMediaCount'), + metricSize: document.getElementById('metricSize'), + metricRateLimit: document.getElementById('metricRateLimit'), + terminalLog: document.getElementById('terminalLog'), + clearLogBtn: document.getElementById('clearLogBtn') + }; +} + +function log(message, type = 'info') { + const line = document.createElement('div'); + line.className = `log-line ${type}`; + const timestamp = new Date().toLocaleTimeString(); + line.textContent = `[${timestamp}] ${message}`; + el.terminalLog.appendChild(line); + el.terminalLog.scrollTop = el.terminalLog.scrollHeight; +} + +// ------------------------------------------------------------- +// Session & Student Discovery +// ------------------------------------------------------------- + +async function checkSessionAndDiscoverStudents() { + log('Checking active Brightwheel browser session...', 'info'); + + // Check stored active student ID first + const stored = await chrome.storage.local.get(['lastActiveStudentId']); + if (stored.lastActiveStudentId) { + state.selectedStudentId = stored.lastActiveStudentId; + el.manualStudentId.value = stored.lastActiveStudentId; + } + + try { + // 1. Try to fetch user/guardian profile to list students + const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me'); + + if (userRes && userRes.success && userRes.data) { + el.sessionDot.classList.add('online'); + el.sessionStatusText.textContent = 'Brightwheel Session Active'; + log('Successfully connected to Brightwheel user profile.', 'success'); + + const userData = userRes.data; + const studentsFound = userData.students || userData.children || (userData.guardians && userData.guardians[0]?.students) || []; + + if (studentsFound.length > 0) { + state.students = studentsFound; + renderStudentsList(); + return; + } + } + + // 2. Try fetching specific student profile if selected + if (state.selectedStudentId) { + const studentRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${state.selectedStudentId}`); + if (studentRes && studentRes.success && studentRes.data) { + el.sessionDot.classList.add('online'); + el.sessionStatusText.textContent = 'Brightwheel Session Active'; + + const s = studentRes.data.student || studentRes.data; + state.students = [s]; + state.selectedStudentData = s; + renderStudentsList(); + log(`Loaded student profile: ${s.first_name || 'Student'} (${state.selectedStudentId})`, 'success'); + return; + } + } + + // Fallback: Default student card with manual ID + el.sessionDot.classList.add('online'); + el.sessionStatusText.textContent = 'Session Connected'; + state.students = [{ + id: state.selectedStudentId, + first_name: 'Student', + last_name: `(${state.selectedStudentId.substring(0, 8)}...)`, + school_name: 'Brightwheel School' + }]; + renderStudentsList(); + log(`Using student UUID: ${state.selectedStudentId}`, 'info'); + + } catch (err) { + log(`Session check note: ${err.message}. Ready with manual ID.`, 'warn'); + renderStudentsList(); + } +} + +async function fetchBrightwheelAPI(url) { + try { + const res = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + credentials: 'include' + }); + + if (!res.ok) { + return { success: false, status: res.status, statusText: res.statusText }; + } + const json = await res.json(); + return { success: true, status: res.status, data: json }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +function renderStudentsList() { + el.studentsList.innerHTML = ''; + + if (state.students.length === 0) { + el.studentsList.innerHTML = '
No students auto-detected. Use manual UUID below.
'; + return; + } + + state.students.forEach((student) => { + const sId = student.id || student.object_id || state.selectedStudentId; + const isSelected = sId === state.selectedStudentId; + const firstName = student.first_name || 'My'; + const lastName = student.last_name || 'Child'; + const initials = (firstName[0] || 'B') + (lastName[0] || 'W'); + const school = student.school_name || student.room_name || 'Brightwheel Account'; + + const card = document.createElement('div'); + card.className = `student-card ${isSelected ? 'selected' : ''}`; + card.dataset.id = sId; + + let avatarContent = initials; + if (student.picture_url || student.avatar_url) { + avatarContent = `${firstName}`; + } + + card.innerHTML = ` +
${avatarContent}
+
+

${firstName} ${lastName}

+

${school} • ${sId.substring(0, 8)}...

+
+ `; + + card.addEventListener('click', () => { + document.querySelectorAll('.student-card').forEach(c => c.classList.remove('selected')); + card.classList.add('selected'); + state.selectedStudentId = sId; + state.selectedStudentData = student; + el.manualStudentId.value = sId; + log(`Selected child: ${firstName} ${lastName} (${sId})`, 'info'); + }); + + el.studentsList.appendChild(card); + }); +} + +// ------------------------------------------------------------- +// Date Filters & Presets +// ------------------------------------------------------------- + +function initDatePresets() { + const today = new Date(); + const currentYear = today.getFullYear(); + + el.startDate.value = ''; + el.endDate.value = today.toISOString().split('T')[0]; +} + +function setDateRange(rangeType) { + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; + + if (rangeType === 'all') { + el.startDate.value = ''; + el.endDate.value = todayStr; + } else if (rangeType === 'this-year') { + el.startDate.value = `${today.getFullYear()}-01-01`; + el.endDate.value = todayStr; + } else if (rangeType === 'last-year') { + el.startDate.value = `${today.getFullYear() - 1}-01-01`; + el.endDate.value = `${today.getFullYear() - 1}-12-31`; + } else if (rangeType === 'last-90') { + const d = new Date(); + d.setDate(d.getDate() - 90); + el.startDate.value = d.toISOString().split('T')[0]; + el.endDate.value = todayStr; + } +} + +// ------------------------------------------------------------- +// Event Listeners +// ------------------------------------------------------------- + +function setupEventListeners() { + el.refreshSessionBtn.addEventListener('click', () => { + checkSessionAndDiscoverStudents(); + }); + + el.setManualIdBtn.addEventListener('click', () => { + const customId = el.manualStudentId.value.trim(); + if (!customId) return; + state.selectedStudentId = customId; + state.students = [{ + id: customId, + first_name: 'Custom', + last_name: `(${customId.substring(0, 8)}...)`, + school_name: 'Brightwheel Profile' + }]; + renderStudentsList(); + chrome.storage.local.set({ lastActiveStudentId: customId }); + log(`Updated active student ID: ${customId}`, 'info'); + }); + + el.quickDatePills.addEventListener('click', (e) => { + const btn = e.target.closest('.pill-btn'); + if (!btn) return; + document.querySelectorAll('.pill-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + setDateRange(btn.dataset.range); + }); + + el.clearLogBtn.addEventListener('click', () => { + el.terminalLog.innerHTML = '
[System] Log cleared.
'; + }); + + el.startExportBtn.addEventListener('click', () => { + if (!state.isExporting) { + startExportProcess(); + } + }); + + el.pauseExportBtn.addEventListener('click', () => { + state.isPaused = !state.isPaused; + el.pauseExportBtn.textContent = state.isPaused ? 'Resume' : 'Pause'; + log(state.isPaused ? 'Export process PAUSED.' : 'Export process RESUMED.', 'warn'); + }); + + el.cancelExportBtn.addEventListener('click', () => { + state.isCancelled = true; + log('Cancelling export process...', 'error'); + }); +} + +// ------------------------------------------------------------- +// Main Export Process Engine +// ------------------------------------------------------------- + +async function startExportProcess() { + const studentId = state.selectedStudentId; + if (!studentId) { + alert('Please select or enter a Student UUID first.'); + return; + } + + // Reset State + state.isExporting = true; + state.isPaused = false; + state.isCancelled = false; + state.activities = []; + state.mediaQueue = []; + state.stats = { + activitiesCount: 0, + mediaTotal: 0, + mediaDownloaded: 0, + mediaFailed: 0, + bytesDownloaded: 0, + retryCount: 0 + }; + + updateUIForExportStart(); + log(`Starting export for Student ID: ${studentId}`, 'info'); + + try { + // 1. Fetch Student Profile details + let studentProfile = state.selectedStudentData; + if (!studentProfile) { + log('Fetching student profile details...', 'info'); + const pRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${studentId}`); + if (pRes && pRes.success && pRes.data) { + studentProfile = pRes.data.student || pRes.data; + } else { + studentProfile = { id: studentId, first_name: 'Student', last_name: 'Brightwheel' }; + } + } + + // 2. Fetch Activities Feed with pagination + await fetchAllActivities(studentId); + + if (state.isCancelled) { + log('Export cancelled by user.', 'error'); + finalizeExportUI(false); + return; + } + + log(`Total matching activities retrieved: ${state.activities.length}`, 'success'); + + // 3. Extract media downloads queue if selected + if (el.catPhotos.checked) { + extractMediaQueue(); + log(`Found ${state.mediaQueue.length} media files to download.`, 'info'); + } + + // 4. Download media files with concurrency and pacing + if (state.mediaQueue.length > 0) { + await downloadAllMediaFiles(); + } + + if (state.isCancelled) { + log('Export cancelled during media download.', 'error'); + finalizeExportUI(false); + return; + } + + // 5. Build ZIP & Bundled Archives + await buildAndDownloadZip(studentProfile); + + log('🎉 Export completed successfully! Enjoy your memories.', 'success'); + updateProgress(100, 'Export Complete!'); + finalizeExportUI(true); + + } catch (err) { + log(`Fatal Export Error: ${err.message}`, 'error'); + console.error(err); + finalizeExportUI(false); + } +} + +function updateUIForExportStart() { + el.startExportBtn.disabled = true; + el.startExportBtn.style.display = 'none'; + el.inProgressControls.style.display = 'flex'; + el.pauseExportBtn.textContent = 'Pause'; + updateProgress(0, 'Initializing export...'); + updateMetrics(); +} + +function finalizeExportUI(success) { + state.isExporting = false; + el.startExportBtn.disabled = false; + el.startExportBtn.style.display = 'flex'; + el.inProgressControls.style.display = 'none'; + if (!success) { + el.progressStatusText.textContent = 'Export Stopped'; + } +} + +function updateProgress(percent, statusText) { + el.progressPercent.textContent = `${Math.round(percent)}%`; + el.progressBarFill.style.width = `${percent}%`; + if (statusText) el.progressStatusText.textContent = statusText; +} + +function updateMetrics() { + el.metricActivitiesCount.textContent = state.stats.activitiesCount; + el.metricMediaCount.textContent = `${state.stats.mediaDownloaded} / ${state.stats.mediaTotal}`; + const mb = (state.stats.bytesDownloaded / (1024 * 1024)).toFixed(1); + el.metricSize.textContent = `${mb} MB`; + el.metricRateLimit.textContent = state.stats.retryCount; +} + +// ------------------------------------------------------------- +// Activity Feed Harvester with Pagination & Backoff +// ------------------------------------------------------------- + +async function fetchAllActivities(studentId) { + const pageSize = 50; + let page = 1; + let hasMore = true; + const startDate = el.startDate.value; + const endDate = el.endDate.value; + const pacingDelay = parseInt(el.concurrencySelect.value) || 200; + + updateProgress(5, 'Harvesting activity logs...'); + + while (hasMore && !state.isCancelled) { + while (state.isPaused && !state.isCancelled) { + await sleep(500); + } + if (state.isCancelled) break; + + // Build URL query + let url = `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=${pageSize}&page=${page}`; + if (startDate) url += `&start_date=${startDate}`; + if (endDate) url += `&end_date=${endDate}`; + + log(`Fetching activities page ${page}...`, 'info'); + + let response = null; + let retries = 0; + const maxRetries = 5; + + while (retries < maxRetries) { + response = await fetchBrightwheelAPI(url); + + if (response && response.status === 429) { + state.stats.retryCount++; + updateMetrics(); + const backoffTime = Math.pow(2, retries) * 1000; + log(`Rate limited (429). Backing off for ${backoffTime / 1000}s...`, 'warn'); + await sleep(backoffTime); + retries++; + } else { + break; + } + } + + if (!response || !response.success || !response.data) { + // If page 1 failed or reached end + if (page === 1) { + // Try fallback endpoint /api/v1/activities?student_ids[] + log('Trying alternate activity feed endpoint...', 'info'); + const altUrl = `https://schools.mybrightwheel.com/api/v1/activities?student_ids[]=${studentId}&page_size=${pageSize}&page=${page}`; + const altRes = await fetchBrightwheelAPI(altUrl); + if (altRes && altRes.success && altRes.data) { + response = altRes; + } + } + } + + const rawActivities = response?.data?.activities || response?.data?.items || response?.data || []; + + if (!Array.isArray(rawActivities) || rawActivities.length === 0) { + log(`No more activities found after page ${page - 1}. Done harvesting.`, 'info'); + hasMore = false; + break; + } + + // Filter and normalize activities + for (const act of rawActivities) { + if (matchesSelectedCategories(act)) { + state.activities.push(normalizeActivity(act)); + } + } + + state.stats.activitiesCount = state.activities.length; + updateMetrics(); + updateProgress(Math.min(30, 5 + page * 2), `Harvested ${state.activities.length} activities (Page ${page})`); + + if (rawActivities.length < pageSize) { + hasMore = false; + break; + } + + page++; + await sleep(pacingDelay); + } +} + +function matchesSelectedCategories(act) { + const type = (act.activity_type || act.action_type || act.type || '').toLowerCase(); + + if (act.media || act.image_url || act.video_url || type.includes('photo') || type.includes('video')) { + if (el.catPhotos.checked) return true; + } + if (type.includes('food') || type.includes('meal') || type.includes('nap') || type.includes('potty') || type.includes('diaper') || type.includes('med')) { + if (el.catCare.checked) return true; + } + if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos')) { + if (el.catMilestones.checked) return true; + } + if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident')) { + if (el.catAttendance.checked) return true; + } + if (type.includes('note') || type.includes('message') || type.includes('announcement')) { + if (el.catMessages.checked) return true; + } + return true; +} + +function normalizeActivity(act) { + const mediaObj = act.media || {}; + const highResImage = mediaObj.image_url || act.image_url || mediaObj.url || act.url; + const highResVideo = mediaObj.video_url || act.video_url; + + return { + id: act.id || act.object_id || Math.random().toString(36).substring(2), + created_at: act.created_at || act.event_date || new Date().toISOString(), + activity_type: act.activity_type || act.action_type || act.type || 'activity', + title: act.title || '', + note: act.note || act.description || act.body || '', + teacher_name: act.actor_name || act.created_by?.name || act.teacher_name || '', + image_url: highResImage || null, + video_url: highResVideo || null, + thumb_url: mediaObj.thumb_url || act.thumb_url || null, + food_type: act.food_type || act.meal_type || null, + amount_eaten: act.amount_eaten || act.quantity || null, + nap_start: act.start_time || act.nap_start || null, + nap_end: act.end_time || act.nap_end || null, + duration_minutes: act.duration_minutes || null, + potty_type: act.potty_type || act.diaper_type || null, + milestone_name: act.milestone_name || act.tag_name || null, + media_local_path: null // Populated during ZIP bundling + }; +} + +// ------------------------------------------------------------- +// Media Downloader with Concurrency & Progress +// ------------------------------------------------------------- + +function extractMediaQueue() { + state.mediaQueue = []; + + state.activities.forEach((act) => { + const url = act.video_url || act.image_url; + if (url) { + const isVideo = !!act.video_url || url.includes('.mp4') || act.activity_type === 'video'; + const ext = isVideo ? 'mp4' : 'jpg'; + const datePrefix = (act.created_at || '').substring(0, 10) || 'unknown_date'; + const filename = `${datePrefix}_${act.id.substring(0, 12)}.${ext}`; + const folder = isVideo ? 'videos' : 'photos'; + const yearMonth = datePrefix.substring(0, 7) || 'all'; + const localZipPath = `${folder}/${yearMonth}/${filename}`; + + act.media_local_path = localZipPath; + + state.mediaQueue.push({ + activityId: act.id, + url: url, + filename: filename, + localZipPath: localZipPath, + blob: null, + status: 'pending' + }); + } + }); + + state.stats.mediaTotal = state.mediaQueue.length; + updateMetrics(); +} + +async function downloadAllMediaFiles() { + const queue = state.mediaQueue; + const concurrency = 3; + let activeIndex = 0; + + updateProgress(35, `Downloading 0 / ${queue.length} media files...`); + + async function worker() { + while (activeIndex < queue.length && !state.isCancelled) { + while (state.isPaused && !state.isCancelled) { + await sleep(500); + } + if (state.isCancelled) break; + + const itemIndex = activeIndex++; + const item = queue[itemIndex]; + if (!item) break; + + try { + const blob = await fetchMediaBlob(item.url); + item.blob = blob; + item.status = 'done'; + state.stats.mediaDownloaded++; + state.stats.bytesDownloaded += blob.size; + } catch (err) { + item.status = 'failed'; + state.stats.mediaFailed++; + log(`Failed to download media ${item.filename}: ${err.message}`, 'warn'); + } + + updateMetrics(); + const progressPercent = 35 + (state.stats.mediaDownloaded / queue.length) * 50; + updateProgress(progressPercent, `Downloading media: ${state.stats.mediaDownloaded} / ${queue.length}`); + } + } + + const workers = []; + for (let i = 0; i < Math.min(concurrency, queue.length); i++) { + workers.push(worker()); + } + + await Promise.all(workers); +} + +async function fetchMediaBlob(url) { + let retries = 0; + while (retries < 3) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.blob(); + } catch (err) { + retries++; + if (retries >= 3) throw err; + await sleep(1000 * retries); + } + } +} + +// ------------------------------------------------------------- +// ZIP Archiving & Packaging +// ------------------------------------------------------------- + +async function buildAndDownloadZip(studentProfile) { + if (typeof JSZip === 'undefined') { + throw new Error('JSZip library is not loaded.'); + } + + updateProgress(88, 'Generating ZIP package...'); + log('Packaging media and manifests into ZIP archive...', 'info'); + + const zip = new JSZip(); + const childName = sanitizeFilename(`${studentProfile.first_name || 'Child'}_${studentProfile.last_name || ''}`).trim() || 'Brightwheel_Archive'; + const rootFolder = zip.folder(childName); + + // 1. Add Media Files + for (const item of state.mediaQueue) { + if (item.blob && item.status === 'done') { + rootFolder.file(item.localZipPath, item.blob); + } + } + + // 2. Add JSON Manifest + if (el.fmtJson.checked) { + const manifestData = { + export_date: new Date().toISOString(), + student: studentProfile, + total_activities: state.activities.length, + activities: state.activities + }; + rootFolder.file('manifest.json', JSON.stringify(manifestData, null, 2)); + } + + // 3. Add CSV Spreadsheet + if (el.fmtCsv.checked) { + const csvContent = generateCSV(state.activities); + rootFolder.file('activities.csv', csvContent); + } + + // 4. Add Standalone Interactive Offline Viewer + if (el.fmtViewer.checked) { + try { + const templateRes = await fetch(chrome.runtime.getURL('dashboard/viewer_template.html')); + if (templateRes.ok) { + let viewerHtml = await templateRes.text(); + // Embed manifest data into index.html for instant zero-server loading + const embeddedManifest = JSON.stringify({ + export_date: new Date().toISOString(), + student: studentProfile, + activities: state.activities + }); + viewerHtml = viewerHtml.replace( + 'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;', + `let ARCHIVE_DATA = ${embeddedManifest};` + ); + rootFolder.file('index.html', viewerHtml); + } + } catch (e) { + log('Note: could not embed standalone index.html viewer: ' + e.message, 'warn'); + } + } + + // 5. Generate ZIP Blob + updateProgress(94, 'Compressing archive...'); + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }, (metadata) => { + updateProgress(88 + (metadata.percent * 0.08), `Compressing ZIP: ${Math.round(metadata.percent)}%`); + }); + + const zipFilename = `${childName}_Brightwheel_Archive_${new Date().toISOString().split('T')[0]}.zip`; + + // 6. Trigger Download + const blobUrl = URL.createObjectURL(zipBlob); + + if (chrome.downloads) { + chrome.runtime.sendMessage({ + action: 'DOWNLOAD_BLOB_URL', + url: blobUrl, + filename: zipFilename + }, (res) => { + if (!res || !res.success) { + triggerBrowserDownload(blobUrl, zipFilename); + } + }); + } else { + triggerBrowserDownload(blobUrl, zipFilename); + } + + log(`Archive created: ${zipFilename} (${(zipBlob.size / (1024 * 1024)).toFixed(2)} MB)`, 'success'); +} + +function triggerBrowserDownload(url, filename) { + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +function generateCSV(activities) { + const headers = [ + 'Activity ID', + 'Date & Time', + 'Type', + 'Title', + 'Note / Caption', + 'Logged By', + 'Media File Path', + 'Food / Meal', + 'Amount Eaten', + 'Nap Start', + 'Nap End', + 'Nap Mins', + 'Potty Type', + 'Milestone' + ]; + + const rows = activities.map(a => [ + escapeCSV(a.id), + escapeCSV(a.created_at), + escapeCSV(a.activity_type), + escapeCSV(a.title), + escapeCSV(a.note), + escapeCSV(a.teacher_name), + escapeCSV(a.media_local_path || a.video_url || a.image_url || ''), + escapeCSV(a.food_type || ''), + escapeCSV(a.amount_eaten || ''), + escapeCSV(a.nap_start || ''), + escapeCSV(a.nap_end || ''), + escapeCSV(a.duration_minutes || ''), + escapeCSV(a.potty_type || ''), + escapeCSV(a.milestone_name || '') + ]); + + return [headers.join(','), ...rows.map(r => r.join(','))].join('\r\n'); +} + +function escapeCSV(val) { + if (val === null || val === undefined) return '""'; + const str = String(val).replace(/"/g, '""'); + return `"${str}"`; +} + +function sanitizeFilename(str) { + return str.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/dashboard/viewer_template.html b/dashboard/viewer_template.html new file mode 100644 index 0000000..59fd731 --- /dev/null +++ b/dashboard/viewer_template.html @@ -0,0 +1,427 @@ + + + + + + Brightwheel Memories Archive + + + +
+
+
+
BW
+
+

Brightwheel Archive

+

Offline Memory Collection

+
+
+
+
0 Activities
+
0 Media
+
+
+
+ +
+
+ +
+ + + + + + + +
+
+ +
+
Loading archive data...
+
+
+ + + + diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..e48bcd0f23a2cb1a6fe9b1397fedf83753068ea3 GIT binary patch literal 1263 zcmY*YeNfVO82`eL{QWEu*lC#&YIbRk49lT+7%ycgtC=MeD_S;lx@A*(6<>*(o@CNx z*P1EO&{bFsZ&a?reoa#|EUjj~q)^zXOwkO*G#GZ8DEv(C znE(JhFFJCAGfSo&adF0a=C->4;I8l@IsDA>!EWBMO;N-~QOvDlkj7t~0}Y{yi)sBF zMS%Dym5`t~0&gra(y5)haVLcAmwJ~lZCL@#*Xacl$qnge+}CW`XeIE5;;XA@dn}%v zs*`83Yg!A^T4om;sfSzVw`{p`*E`?ZG2tQUWGrYz^ie*WiZ z-9Po?olnr!nE~l-)z*=%H)aPEqsMvk35Mh~(A~fC-q3r6au!YF%U>S-&K4^3=e7m< zM=*u6F}-q#+m>p6FG5*0JB-6e8(-#!R6aHxBYA8SzcQ_JBYQtkkL|_U3E3|0v&3YF z%uh!$7b~MjljMf5jOgZpp>*H&Q=&#hM!!BV!f>kf9y#>C+b0(Sd=CFCBRDeP~5orp{;z=xZ}}g@HI2V zm7(xJ2_G7d&B3B36B*V_PORx8VFLfq`NP9W#L8b=QY%`UN)U9Sgp976rmFVReHaUX znXwD7U`~*_xT7oQb*BVGxM4GxWtir+rBe|8q&2WNf55>3Eh-B7`xNE_z`|;lxnL7~ z!R>a}#2UQuDlM7M&}W4XDGWJb5dY)p^H= z;Ns7Pi?-3M?5->5DL8dMUY+~X(l{iSzC44}*IeOaZ;Yu}b$?aikuDi&drkL~5xd2_c`h^Y x6u#^D5;vhRZV7#V;QlUV-Y9pfi@Ln`LXoovW;$U(&AyI;NS z)C9o|0zoW69GXmGO|D$|6Cd~1U&!Bd`k(2?gW6`BjjyV;@W}9o#ujvK?&>|4x$|Pt z;)8b61?PM{EWhvcbdSI*ABx_xZJD@e#lxq%?7KkxPkqd_OEj`7D$h5r4iGWj6SJGK zIY6cA$#*us9w(6eL}v4aKfJCzuliQ$QEoe9f5hF`xJS-uqSj~swR^g&@ot+rZSun9 cA!jQY&t6M7eJCyaFVN`>p00i_>zopr0EgCM8~^|S literal 0 HcmV?d00001 diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..96463cc1ee8216db0e4708e9f5e573365fd57463 GIT binary patch literal 580 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(FmZdjIEGZrxq2(x@3w;o>w`Bh z`BrPOUSFikuWA!2B)5n)%8^^p`b5&E1(%CkLe!N~(HBGec9Km{V?!n$uJSmTar{^e=~!rjl;1dB?{Qt*yG zc{h0lld^Ei=W|BZJs}Uyuy{wCu0CTI+*mBUCgtWDtM4(adOi=7OyfT5g*>zS#;lbArx)-Sf;w>_=i#%I1?lW2~>IG-eB{%=SI^{MJr2 z?mIISB5$9(*}B{Qzg78Z@s+pyj_IAvUu<8g5PJLMjis6&PA_Xw$yG1qe|JY@$L3i9 zGkzXlzvKDArad)o-)?I;DT>(}ACsRgV5RqI&5uS^xr!w7z5D*`7wZ3GW&4Tw?xb(w z%{!Hn@6A27=V)JL + +(c) 2009-2016 Stuart Knightley +Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. + +JSZip uses the library pako released under the MIT license : +https://github.com/nodeca/pako/blob/main/LICENSE +*/ + +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e>2,s=(3&t)<<4|r>>4,a=1>6:64,o=2>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(e){t.push({data:e,meta:t.meta})}},r.compressWorker=function(e){return new h("Deflate",e)},r.uncompressWorker=function(){return new h("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(e,t,r){"use strict";function A(e,t){var r,n="";for(r=0;r>>=8;return n}function n(e,t,r,n,i,s){var a,o,h=e.file,u=e.compression,l=s!==O.utf8encode,f=I.transformTo("string",s(h.name)),c=I.transformTo("string",O.utf8encode(h.name)),d=h.comment,p=I.transformTo("string",s(d)),m=I.transformTo("string",O.utf8encode(d)),_=c.length!==h.name.length,g=m.length!==d.length,b="",v="",y="",w=h.dir,k=h.date,x={crc32:0,compressedSize:0,uncompressedSize:0};t&&!r||(x.crc32=e.crc32,x.compressedSize=e.compressedSize,x.uncompressedSize=e.uncompressedSize);var S=0;t&&(S|=8),l||!_&&!g||(S|=2048);var z=0,C=0;w&&(z|=16),"UNIX"===i?(C=798,z|=function(e,t){var r=e;return e||(r=t?16893:33204),(65535&r)<<16}(h.unixPermissions,w)):(C=20,z|=function(e){return 63&(e||0)}(h.dosPermissions)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v=A(1,1)+A(B(f),4)+c,b+="up"+A(v.length,2)+v),g&&(y=A(1,1)+A(B(p),4)+m,b+="uc"+A(y.length,2)+y);var E="";return E+="\n\0",E+=A(S,2),E+=u.magic,E+=A(a,2),E+=A(o,2),E+=A(x.crc32,4),E+=A(x.compressedSize,4),E+=A(x.uncompressedSize,4),E+=A(f.length,2),E+=A(b.length,2),{fileRecord:R.LOCAL_FILE_HEADER+E+f+b,dirRecord:R.CENTRAL_FILE_HEADER+A(C,2)+E+A(p.length,2)+"\0\0\0\0"+A(z,4)+A(n,4)+f+b+p}}var I=e("../utils"),i=e("../stream/GenericWorker"),O=e("../utf8"),B=e("../crc32"),R=e("../signature");function s(e,t,r,n){i.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=t,this.zipPlatform=r,this.encodeFileName=n,this.streamFiles=e,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}I.inherits(s,i),s.prototype.push=function(e){var t=e.meta.percent||0,r=this.entriesCount,n=this._sources.length;this.accumulate?this.contentBuffer.push(e):(this.bytesWritten+=e.data.length,i.prototype.push.call(this,{data:e.data,meta:{currentFile:this.currentFile,percent:r?(t+100*(r-n-1))/r:100}}))},s.prototype.openedSource=function(e){this.currentSourceOffset=this.bytesWritten,this.currentFile=e.file.name;var t=this.streamFiles&&!e.file.dir;if(t){var r=n(e,t,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},s.prototype.closedSource=function(e){this.accumulate=!1;var t=this.streamFiles&&!e.file.dir,r=n(e,t,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(r.dirRecord),t)this.push({data:function(e){return R.DATA_DESCRIPTOR+A(e.crc32,4)+A(e.compressedSize,4)+A(e.uncompressedSize,4)}(e),meta:{percent:100}});else for(this.push({data:r.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},s.prototype.flush=function(){for(var e=this.bytesWritten,t=0;t=this.index;t--)r=(r<<8)+this.byteAt(t);return this.index+=e,r},readString:function(e){return n.transformTo("string",this.readData(e))},readData:function(){},lastIndexOfSignature:function(){},readAndCheckSignature:function(){},readDate:function(){var e=this.readInt(4);return new Date(Date.UTC(1980+(e>>25&127),(e>>21&15)-1,e>>16&31,e>>11&31,e>>5&63,(31&e)<<1))}},t.exports=i},{"../utils":32}],19:[function(e,t,r){"use strict";var n=e("./Uint8ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(e,t,r){"use strict";var n=e("./DataReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.byteAt=function(e){return this.data.charCodeAt(this.zero+e)},i.prototype.lastIndexOfSignature=function(e){return this.data.lastIndexOf(e)-this.zero},i.prototype.readAndCheckSignature=function(e){return e===this.readData(4)},i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./DataReader":18}],21:[function(e,t,r){"use strict";var n=e("./ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){if(this.checkOffset(e),0===e)return new Uint8Array(0);var t=this.data.subarray(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./ArrayReader":17}],22:[function(e,t,r){"use strict";var n=e("../utils"),i=e("../support"),s=e("./ArrayReader"),a=e("./StringReader"),o=e("./NodeBufferReader"),h=e("./Uint8ArrayReader");t.exports=function(e){var t=n.getTypeOf(e);return n.checkSupport(t),"string"!==t||i.uint8array?"nodebuffer"===t?new o(e):i.uint8array?new h(n.transformTo("uint8array",e)):new s(n.transformTo("array",e)):new a(e)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(e,t,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../utils");function s(e){n.call(this,"ConvertWorker to "+e),this.destType=e}i.inherits(s,n),s.prototype.processChunk=function(e){this.push({data:i.transformTo(this.destType,e.data),meta:e.meta})},t.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../crc32");function s(){n.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}e("../utils").inherits(s,n),s.prototype.processChunk=function(e){this.streamInfo.crc32=i(e.data,this.streamInfo.crc32||0),this.push(e)},t.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataLengthProbe for "+e),this.propName=e,this.withStreamInfo(e,0)}n.inherits(s,i),s.prototype.processChunk=function(e){if(e){var t=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=t+e.data.length}i.prototype.processChunk.call(this,e)},t.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataWorker");var t=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,e.then(function(e){t.dataIsReady=!0,t.data=e,t.max=e&&e.length||0,t.type=n.getTypeOf(e),t.isPaused||t._tickAndRepeat()},function(e){t.error(e)})}n.inherits(s,i),s.prototype.cleanUp=function(){i.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!i.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,n.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(n.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var e=null,t=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":e=this.data.substring(this.index,t);break;case"uint8array":e=this.data.subarray(this.index,t);break;case"array":case"nodebuffer":e=this.data.slice(this.index,t)}return this.index=t,this.push({data:e,meta:{percent:this.max?this.index/this.max*100:0}})},t.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(e,t,r){"use strict";function n(e){this.name=e||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}n.prototype={push:function(e){this.emit("data",e)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(e){this.emit("error",e)}return!0},error:function(e){return!this.isFinished&&(this.isPaused?this.generatedError=e:(this.isFinished=!0,this.emit("error",e),this.previous&&this.previous.error(e),this.cleanUp()),!0)},on:function(e,t){return this._listeners[e].push(t),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStream + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ab89cb0 --- /dev/null +++ b/manifest.json @@ -0,0 +1,41 @@ +{ + "manifest_version": 3, + "name": "Brightwheel Memories & Reports Exporter", + "version": "1.0.0", + "description": "Export all your children's memories, high-res photos, videos, daily care reports, milestones, and activity manifests from Brightwheel.", + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "action": { + "default_popup": "popup/popup.html", + "default_title": "Brightwheel Exporter", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "background": { + "service_worker": "scripts/background.js" + }, + "content_scripts": [ + { + "matches": ["https://schools.mybrightwheel.com/*"], + "js": ["scripts/content_script.js"], + "run_at": "document_idle" + } + ], + "permissions": [ + "storage", + "downloads", + "tabs" + ], + "host_permissions": [ + "https://schools.mybrightwheel.com/*", + "https://*.brightwheel.com/*", + "https://*.cloudfront.net/*", + "https://*.s3.amazonaws.com/*" + ] +} diff --git a/popup/popup.css b/popup/popup.css new file mode 100644 index 0000000..166a3bd --- /dev/null +++ b/popup/popup.css @@ -0,0 +1,186 @@ +:root { + --primary-color: #FF6B35; + --primary-hover: #E85820; + --primary-gradient: linear-gradient(135deg, #FF6B35 0%, #FF8E53 100%); + --bg-color: #FAFAF9; + --card-bg: #FFFFFF; + --text-main: #1C1917; + --text-muted: #78716C; + --border-color: #E7E5E4; + --success-color: #10B981; + --warning-color: #F59E0B; + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + width: 320px; + background-color: var(--bg-color); + font-family: var(--font-family); + color: var(--text-main); + padding: 16px; + -webkit-font-smoothing: antialiased; +} + +.popup-container { + display: flex; + flex-direction: column; + gap: 14px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.logo-wrapper { + display: flex; + align-items: center; + gap: 10px; +} + +.logo-icon { + width: 36px; + height: 36px; + background: var(--primary-gradient); + color: #FFFFFF; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 10px rgba(255, 107, 53, 0.25); +} + +.title-group h1 { + font-size: 15px; + font-weight: 700; + color: var(--text-main); + letter-spacing: -0.2px; +} + +.badge { + font-size: 10px; + font-weight: 600; + background: #FEE2E2; + color: #DC2626; + padding: 2px 6px; + border-radius: 4px; + display: inline-block; + margin-top: 1px; +} + +.content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.status-card { + display: flex; + align-items: center; + gap: 10px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px 12px; +} + +.status-indicator-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--warning-color); + flex-shrink: 0; + transition: background-color 0.3s ease; +} + +.status-indicator-dot.online { + background-color: var(--success-color); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); +} + +.status-text-group { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.status-title { + font-size: 12px; + font-weight: 600; + color: var(--text-main); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.status-subtitle { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.features-summary { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.feature-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 11.5px; + color: #44403C; +} + +.feature-bullet { + font-size: 13px; +} + +.primary-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 11px 16px; + background: var(--primary-gradient); + color: #FFFFFF; + border: none; + border-radius: 10px; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3); + transition: all 0.2s ease; +} + +.primary-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(255, 107, 53, 0.4); +} + +.primary-btn:active { + transform: translateY(0); +} + +.footer { + text-align: center; + font-size: 10.5px; + color: var(--text-muted); + border-top: 1px dashed var(--border-color); + padding-top: 8px; +} diff --git a/popup/popup.html b/popup/popup.html new file mode 100644 index 0000000..e3d8bf4 --- /dev/null +++ b/popup/popup.html @@ -0,0 +1,66 @@ + + + + + + Brightwheel Exporter + + + + + + + diff --git a/popup/popup.js b/popup/popup.js new file mode 100644 index 0000000..09e649b --- /dev/null +++ b/popup/popup.js @@ -0,0 +1,36 @@ +document.addEventListener('DOMContentLoaded', async () => { + const statusDot = document.getElementById('statusDot'); + const statusTitle = document.getElementById('statusTitle'); + const statusSubtitle = document.getElementById('statusSubtitle'); + const openDashboardBtn = document.getElementById('openDashboardBtn'); + + // Check active tab + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (activeTab && activeTab.url && activeTab.url.includes('schools.mybrightwheel.com')) { + statusDot.classList.add('online'); + statusTitle.textContent = 'Brightwheel Active Tab'; + statusSubtitle.textContent = 'Logged in session detected'; + } else { + // Check if any Brightwheel tab exists in the browser + const bwTabs = await chrome.tabs.query({ url: 'https://schools.mybrightwheel.com/*' }); + if (bwTabs && bwTabs.length > 0) { + statusDot.classList.add('online'); + statusTitle.textContent = 'Brightwheel Tab Found'; + statusSubtitle.textContent = 'Ready to export'; + } else { + statusTitle.textContent = 'Brightwheel Not Open'; + statusSubtitle.textContent = 'Dashboard will open login tab'; + } + } + } catch (err) { + console.error('Error querying tabs:', err); + } + + // Open Dashboard Button Handler + openDashboardBtn.addEventListener('click', async () => { + await chrome.runtime.sendMessage({ action: 'OPEN_DASHBOARD' }); + window.close(); + }); +}); diff --git a/scripts/background.js b/scripts/background.js new file mode 100644 index 0000000..08fe338 --- /dev/null +++ b/scripts/background.js @@ -0,0 +1,51 @@ +/** + * Brightwheel Exporter - Service Worker (Background Script) + * Manifest V3 + */ + +chrome.runtime.onInstalled.addListener(() => { + console.log('[Brightwheel Exporter] Extension installed.'); +}); + +// Handle incoming messages from popup, dashboard, or content scripts +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.action === 'OPEN_DASHBOARD') { + const dashboardUrl = chrome.runtime.getURL('dashboard/dashboard.html'); + + // Check if dashboard is already open + chrome.tabs.query({ url: dashboardUrl }, (tabs) => { + if (tabs && tabs.length > 0) { + chrome.tabs.update(tabs[0].id, { active: true }); + if (tabs[0].windowId) { + chrome.windows.update(tabs[0].windowId, { focused: true }); + } + } else { + chrome.tabs.create({ url: dashboardUrl }); + } + sendResponse({ success: true }); + }); + return true; // Keep message channel open for async response + } + + if (message.action === 'DOWNLOAD_BLOB_URL') { + chrome.downloads.download({ + url: message.url, + filename: message.filename, + saveAs: true + }, (downloadId) => { + if (chrome.runtime.lastError) { + sendResponse({ success: false, error: chrome.runtime.lastError.message }); + } else { + sendResponse({ success: true, downloadId }); + } + }); + return true; + } + + if (message.action === 'GET_BRIGHTWHEEL_TABS') { + chrome.tabs.query({ url: 'https://schools.mybrightwheel.com/*' }, (tabs) => { + sendResponse({ tabs: tabs || [] }); + }); + return true; + } +}); diff --git a/scripts/content_script.js b/scripts/content_script.js new file mode 100644 index 0000000..992a51b --- /dev/null +++ b/scripts/content_script.js @@ -0,0 +1,126 @@ +/** + * Brightwheel Exporter - Content Script + * Injected on https://schools.mybrightwheel.com/* + */ + +(function () { + console.log('[Brightwheel Exporter] Content script initialized.'); + + // Extract student ID from URL if present + function detectCurrentStudentId() { + const match = window.location.pathname.match(/\/students\/([a-zA-Z0-9-]+)/); + if (match && match[1]) { + chrome.storage.local.set({ lastActiveStudentId: match[1] }); + return match[1]; + } + return null; + } + + detectCurrentStudentId(); + + // Watch for SPA URL changes + let lastUrl = location.href; + new MutationObserver(() => { + const url = location.href; + if (url !== lastUrl) { + lastUrl = url; + detectCurrentStudentId(); + } + }).observe(document, { subtree: true, childList: true }); + + // Listen for messages from extension popup or dashboard + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.action === 'GET_PAGE_CONTEXT') { + const studentId = detectCurrentStudentId(); + sendResponse({ + url: window.location.href, + studentId: studentId, + cookiesPresent: document.cookie.includes('_brightwheel_v2') + }); + return true; + } + + if (message.action === 'FETCH_WITH_CREDENTIALS') { + // Execute fetch inside page context with credentials + fetch(message.url, { + method: message.method || 'GET', + headers: { + 'Accept': 'application/json', + ...(message.headers || {}) + }, + credentials: 'include' + }) + .then(async (res) => { + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + return sendResponse({ success: false, status: res.status, statusText: res.statusText, body: errorText }); + } + const json = await res.json(); + sendResponse({ success: true, status: res.status, data: json }); + }) + .catch((err) => { + sendResponse({ success: false, error: err.message }); + }); + return true; // async + } + }); + + // Inject a discreet floating button on Brightwheel page + function injectExporterButton() { + if (document.getElementById('bw-exporter-floating-btn')) return; + + const btn = document.createElement('button'); + btn.id = 'bw-exporter-floating-btn'; + btn.innerHTML = ` + + + + + + Export Brightwheel Data + `; + + Object.assign(btn.style, { + position: 'fixed', + bottom: '24px', + right: '24px', + zIndex: '999999', + display: 'inline-flex', + alignItems: 'center', + padding: '10px 18px', + background: 'linear-gradient(135deg, #FF6B35 0%, #FF8E53 100%)', + color: '#FFFFFF', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: '14px', + fontWeight: '600', + border: 'none', + borderRadius: '30px', + boxShadow: '0 4px 16px rgba(255, 107, 53, 0.4), 0 2px 6px rgba(0, 0, 0, 0.1)', + cursor: 'pointer', + transition: 'all 0.25s ease', + }); + + btn.addEventListener('mouseenter', () => { + btn.style.transform = 'translateY(-2px) scale(1.03)'; + btn.style.boxShadow = '0 6px 20px rgba(255, 107, 53, 0.5), 0 3px 8px rgba(0, 0, 0, 0.15)'; + }); + + btn.addEventListener('mouseleave', () => { + btn.style.transform = 'translateY(0) scale(1)'; + btn.style.boxShadow = '0 4px 16px rgba(255, 107, 53, 0.4), 0 2px 6px rgba(0, 0, 0, 0.1)'; + }); + + btn.addEventListener('click', () => { + chrome.runtime.sendMessage({ action: 'OPEN_DASHBOARD' }); + }); + + document.body.appendChild(btn); + } + + // Inject when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', injectExporterButton); + } else { + injectExporterButton(); + } +})();