/** * 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(); if (window.JSZip || typeof JSZip !== 'undefined') { log('Zip compression engine initialized (JSZip v3.10.1).', 'info'); } else { log('Warning: JSZip library not detected on page load.', 'warn'); } 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 (type.includes('photo') || type.includes('video') || act.media || act.image_url || act.video_url || act.video_stream_url) { 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') || type.includes('bottle')) { if (el.catCare.checked) return true; } if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos') || type.includes('skill')) { if (el.catMilestones.checked) return true; } if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident') || type.includes('screening') || type.includes('name_to_face')) { if (el.catAttendance.checked) return true; } if (type.includes('note') || type.includes('message') || type.includes('announcement') || type.includes('reminder') || type.includes('custom')) { if (el.catMessages.checked) return true; } // Default: include all other action types return true; } // Deep recursive media extractor to find high-res photos and videos anywhere in object/array function extractMediaUrls(act) { let imageUrl = null; let videoUrl = null; let thumbUrl = null; const isVideoStr = (url) => { if (!url || typeof url !== 'string') return false; const lower = url.toLowerCase(); return lower.includes('.mp4') || lower.includes('.mov') || lower.includes('.webm') || lower.includes('/videos/') || lower.includes('video_stream') || lower.includes('transcoded_video') || lower.includes('media_videos'); }; const isImageStr = (url) => { if (!url || typeof url !== 'string') return false; const lower = url.toLowerCase(); return lower.includes('.jpg') || lower.includes('.jpeg') || lower.includes('.png') || lower.includes('.webp') || lower.includes('.gif') || lower.includes('/images/') || lower.includes('/photos/') || lower.includes('media_images'); }; function search(obj, depth = 0) { if (!obj || depth > 5) return; if (typeof obj === 'string') { if (obj.startsWith('http://') || obj.startsWith('https://')) { if (isVideoStr(obj) && !videoUrl) videoUrl = obj; else if (isImageStr(obj) && !imageUrl) imageUrl = obj; else if (!imageUrl && !videoUrl) { if (act.activity_type === 'ac_video' || act.type === 'video') videoUrl = obj; else imageUrl = obj; } } return; } if (Array.isArray(obj)) { for (const item of obj) search(item, depth + 1); return; } if (typeof obj === 'object') { // Check video keys const possibleVideos = [ obj.video_url, obj.video_stream_url, obj.video_hd_url, obj.video_transcode_url, obj.transcoded_video_url, obj.original_video_url, obj.stream_url, obj.mp4_url, obj.video, obj.source_url, obj.download_url ]; for (const v of possibleVideos) { if (typeof v === 'string' && v.startsWith('http')) { if (!videoUrl) videoUrl = v; break; } } // Check image keys const possibleImages = [ obj.image_url, obj.photo_url, obj.full_url, obj.picture_url, obj.original_url, obj.cover_url, obj.large_url ]; for (const img of possibleImages) { if (typeof img === 'string' && img.startsWith('http')) { if (!imageUrl && img !== videoUrl) imageUrl = img; break; } } // Check generic url if (typeof obj.url === 'string' && obj.url.startsWith('http')) { if (isVideoStr(obj.url) || act.activity_type === 'ac_video' || act.type === 'video') { if (!videoUrl) videoUrl = obj.url; } else if (!imageUrl) { imageUrl = obj.url; } } // Check thumbnail const possibleThumbs = [obj.thumb_url, obj.thumbnail_url, obj.thumbnail, obj.preview_url, obj.preview_image_url]; for (const th of possibleThumbs) { if (typeof th === 'string' && th.startsWith('http') && !thumbUrl) { thumbUrl = th; break; } } // Recurse into nested common containers if (obj.media) search(obj.media, depth + 1); if (obj.video_data) search(obj.video_data, depth + 1); if (obj.attachments) search(obj.attachments, depth + 1); if (obj.items) search(obj.items, depth + 1); if (obj.photos) search(obj.photos, depth + 1); if (obj.videos) search(obj.videos, depth + 1); if (obj.assets) search(obj.assets, depth + 1); } } // Scan root act and common child objects search(act); // If it's an ac_video activity and imageUrl was found but points to video, fix assignment if ((act.activity_type === 'ac_video' || act.type === 'video') && !videoUrl && imageUrl) { videoUrl = imageUrl; imageUrl = null; } return { imageUrl, videoUrl, thumbUrl }; } function normalizeActivity(act) { const { imageUrl, videoUrl, thumbUrl } = extractMediaUrls(act); const actType = (act.activity_type || act.action_type || act.type || 'activity').toLowerCase(); const rawSubtype = (act.action_type || act.event_type || act.subtype || act.sub_type || act.action || act.state || act.status || '').toLowerCase(); // Extract staff / actor name const teacherName = act.actor_name || act.created_by?.name || act.teacher_name || act.staff_name || (act.actor ? `${act.actor.first_name || ''} ${act.actor.last_name || ''}`.trim() : '') || ''; // Parse Subtypes and Human-readable Summaries for each category let actionSummary = ''; let actionSubtype = rawSubtype; let foodType = act.food_type || act.meal_type || act.menu_type || null; let amountEaten = act.amount_eaten || act.quantity || act.amount || null; let napStart = act.start_time || act.nap_start || act.started_at || null; let napEnd = act.end_time || act.nap_end || act.ended_at || null; let durationMinutes = act.duration_minutes || act.duration_in_minutes || act.duration || null; let pottyType = act.potty_type || act.diaper_type || act.potty || act.diaper || null; let milestoneName = act.milestone_name || act.tag_name || act.skill_name || act.milestone || null; let attendanceInfo = ''; // 1. NAP if (actType.includes('nap')) { if (rawSubtype.includes('start') || (napStart && !napEnd && !durationMinutes)) { actionSubtype = 'start_nap'; actionSummary = 'Nap Started'; } else if (rawSubtype.includes('end') || rawSubtype.includes('wake') || napEnd || durationMinutes) { actionSubtype = 'end_nap'; const durText = durationMinutes ? `${durationMinutes} mins` : ''; actionSummary = durText ? `Nap Ended (${durText})` : 'Nap Ended'; } else { actionSubtype = 'nap'; actionSummary = durationMinutes ? `Nap (${durationMinutes} mins)` : 'Nap Log'; } } // 2. CHECK-IN / ATTENDANCE else if (actType.includes('checkin') || actType.includes('checkout') || actType.includes('attendance')) { const isOut = rawSubtype.includes('out') || rawSubtype.includes('away') || rawSubtype.includes('pickup') || actType.includes('checkout'); const signedBy = act.signed_in_by || act.signed_out_by || act.guardian_name || act.actor_name || teacherName || ''; if (isOut) { actionSubtype = 'signed_out'; actionSummary = signedBy ? `Checked Out / Signed Out (by ${signedBy})` : 'Checked Out / Signed Out'; } else { actionSubtype = 'signed_in'; actionSummary = signedBy ? `Checked In / Signed In (by ${signedBy})` : 'Checked In / Signed In'; } attendanceInfo = signedBy ? `Signed by: ${signedBy}` : ''; } // 3. FOOD / MEALS else if (actType.includes('food') || actType.includes('meal')) { if (!foodType && rawSubtype) foodType = rawSubtype; const mealLabel = foodType ? capitalize(foodType) : 'Meal'; const eatLabel = amountEaten ? `Ate ${amountEaten}` : ''; actionSubtype = foodType || 'meal'; actionSummary = eatLabel ? `${mealLabel} (${eatLabel})` : mealLabel; } // 4. POTTY / DIAPER else if (actType.includes('potty') || actType.includes('diaper')) { if (!pottyType && rawSubtype) pottyType = rawSubtype; const pLabel = pottyType ? capitalize(pottyType) : 'Check'; actionSubtype = pottyType || 'potty'; actionSummary = `Potty / Diaper (${pLabel})`; } // 5. PHOTOS & VIDEOS else if (actType.includes('video') || videoUrl) { actionSubtype = 'video'; actionSummary = act.note ? `Video: "${act.note}"` : 'Video Clip'; } else if (actType.includes('photo') || imageUrl) { actionSubtype = 'photo'; actionSummary = act.note ? `Photo: "${act.note}"` : 'Photo'; } // 6. MILESTONES & OBSERVATIONS else if (actType.includes('milestone') || actType.includes('observation') || actType.includes('learn') || actType.includes('kudos')) { actionSubtype = 'milestone'; actionSummary = milestoneName ? `Milestone: ${milestoneName}` : (act.title || 'Learning Milestone'); } // 7. MEDICATIONS else if (actType.includes('med')) { actionSubtype = 'medication'; actionSummary = `Medication Administered: ${act.medication_name || act.title || ''}`; } // 8. INCIDENTS else if (actType.includes('incident')) { actionSubtype = 'incident'; actionSummary = `Incident Report: ${act.title || act.incident_type || ''}`; } // 9. HEALTH / SCREENING else if (actType.includes('health') || actType.includes('screening')) { actionSubtype = 'health_screening'; actionSummary = act.temperature ? `Health Screening (Temp: ${act.temperature}°)` : 'Health Screening'; attendanceInfo = act.temperature ? `Temp: ${act.temperature}°` : ''; } // 10. DEFAULT / NOTES else { actionSubtype = rawSubtype || actType; actionSummary = act.title || act.note || capitalize(actType.replace('ac_', '')); } 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 || actType, action_subtype: actionSubtype, action_summary: actionSummary, title: act.title || '', note: act.note || act.description || act.body || act.comment || '', teacher_name: teacherName, image_url: imageUrl || null, video_url: videoUrl || null, thumb_url: thumbUrl || null, food_type: foodType, amount_eaten: amountEaten, nap_start: napStart, nap_end: napEnd, duration_minutes: durationMinutes, potty_type: pottyType, milestone_name: milestoneName, attendance_info: attendanceInfo, media_local_path: null, // Populated during ZIP bundling raw: act // Preserve 100% full raw backend payload }; } function capitalize(s) { if (!s || typeof s !== 'string') return ''; return s.charAt(0).toUpperCase() + s.slice(1).replace(/_/g, ' '); } // ------------------------------------------------------------- // 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 || act.activity_type === 'ac_video' || act.activity_type === 'video' || url.includes('.mp4'); 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, isVideo: isVideo, 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; const videoCount = queue.filter(q => q.isVideo).length; const photoCount = queue.length - videoCount; log(`Media to download: ${photoCount} photos, ${videoCount} videos.`, 'info'); 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 ${item.isVideo ? 'video' : 'photo'} ${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, { credentials: 'omit' }); 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) { const JSZipClass = window.JSZip || (typeof JSZip !== 'undefined' ? JSZip : null); if (!JSZipClass) { 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 JSZipClass(); 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', 'Event Action / Summary', 'Subtype', 'Title', 'Note / Caption', 'Staff / Logged By', 'Media File Path', 'Food / Meal', 'Amount Eaten', 'Nap Start', 'Nap End', 'Nap Duration (Mins)', 'Potty / Diaper Type', 'Milestone', 'Attendance / Health Info' ]; const rows = activities.map(a => [ escapeCSV(a.id), escapeCSV(a.created_at), escapeCSV(a.activity_type), escapeCSV(a.action_summary || ''), escapeCSV(a.action_subtype || ''), 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 || ''), escapeCSV(a.attendance_info || '') ]); 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)); }