/** * 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)); }