/** * Brightwheel Exporter - Dashboard Controller */ // Application State const state = { students: [], selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565', selectedStudentData: null, destinationDirectoryHandle: null, isExporting: false, isPaused: false, isCancelled: false, activities: [], mediaQueue: [], executionLogs: [], 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'), chooseFolderBtn: document.getElementById('chooseFolderBtn'), clearFolderBtn: document.getElementById('clearFolderBtn'), folderPathText: document.getElementById('folderPathText'), 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 timestamp = new Date().toISOString(); const timeFormatted = new Date().toLocaleTimeString(); state.executionLogs.push({ timestamp, time: timeFormatted, level: type, message: message }); const line = document.createElement('div'); line.className = `log-line ${type}`; line.textContent = `[${timeFormatted}] ${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(); }); if (el.inspectFeedBtn) { el.inspectFeedBtn.addEventListener('click', handleInspectFeed); } if (el.chooseFolderBtn) { el.chooseFolderBtn.addEventListener('click', handleChooseFolder); } if (el.clearFolderBtn) { el.clearFolderBtn.addEventListener('click', handleClearFolder); } 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'); }); } async function handleChooseFolder() { if (!('showDirectoryPicker' in window)) { alert('Direct Folder Save requires the File System Access API (available on Chrome Desktop).'); return; } try { const handle = await window.showDirectoryPicker({ mode: 'readwrite' }); state.destinationDirectoryHandle = handle; if (el.folderPathText) { el.folderPathText.innerHTML = `Selected: 📁 ${handle.name}`; el.folderPathText.style.color = 'var(--primary)'; } if (el.clearFolderBtn) { el.clearFolderBtn.style.display = 'inline'; } log(`📁 Destination folder set to: "${handle.name}". Direct streaming enabled!`, 'success'); } catch (err) { if (err.name !== 'AbortError') { log('Could not select folder: ' + err.message, 'warn'); } } } function handleClearFolder() { state.destinationDirectoryHandle = null; if (el.folderPathText) { el.folderPathText.textContent = 'No folder selected (will download as instant ZIP)'; el.folderPathText.style.color = 'var(--text-muted)'; } if (el.clearFolderBtn) { el.clearFolderBtn.style.display = 'none'; } log('Reset destination to browser download ZIP.', 'info'); } async function handleInspectFeed() { log('🔬 Inspecting live Brightwheel tab...', 'info'); try { const [bwTab] = await chrome.tabs.query({ url: 'https://schools.mybrightwheel.com/*' }); if (!bwTab || !bwTab.id) { log('No active Brightwheel tab found at schools.mybrightwheel.com. Please ensure the feed is open in a tab.', 'error'); return; } chrome.tabs.sendMessage(bwTab.id, { action: 'INSPECT_LIVE_FEED', studentId: state.selectedStudentId }, (res) => { if (!res || !res.success) { log('Inspection response error: ' + (res?.error || 'Could not communicate with tab. Try refreshing the Brightwheel tab.'), 'error'); return; } log(`✅ Live Inspection Complete for: ${res.currentUrl}`, 'success'); log(`Page Title: "${res.pageTitle}"`, 'info'); log(`DOM Cards Detected on Page: ${res.domCards?.length || 0}`, 'info'); if (res.domCards && res.domCards.length > 0) { res.domCards.slice(0, 3).forEach((dc, i) => { log(` [DOM Card ${i + 1}] Text: "${dc.raw_text.replace(/\s+/g, ' ').substring(0, 100)}..."`, 'info'); }); } const acts = res.apiSampleActivities || []; log(`API Sample Activities Received: ${acts.length}`, 'success'); acts.forEach((a, idx) => { const type = a.activity_type || a.action_type || a.type; const presentKeys = Object.keys(a).filter(k => a[k] !== null && a[k] !== undefined && a[k] !== ''); log(`[API Item ${idx + 1}] Type="${type}", Note="${a.note || a.body || ''}", Keys=[${presentKeys.join(', ')}]`, 'info'); if (type === 'ac_video' || type === 'video') { log(` -> Video payload: media=${JSON.stringify(a.media || null)}, video_url=${a.video_url || null}`, 'warn'); } if (type === 'ac_nap' || type === 'nap') { log(` -> Nap payload: action_type=${a.action_type}, nap_start=${a.nap_start || a.start_time}, nap_end=${a.nap_end || a.end_time}, duration=${a.duration || a.duration_minutes}`, 'warn'); } if (type === 'ac_checkin' || type === 'checkin') { log(` -> Checkin payload: action_type=${a.action_type}, signed_in_by=${a.signed_in_by || a.actor_name || a.guardian_name}, event_type=${a.event_type}`, 'warn'); } }); // Automatically download inspection JSON report const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `brightwheel_inspection_${state.selectedStudentId}_${Date.now()}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); log(`📁 Downloaded full inspection report to your Downloads folder (${(blob.size/1024).toFixed(1)} KB).`, 'success'); }); } catch (e) { log('Inspection error: ' + e.message, '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; const seenTypes = new Set(); updateProgress(5, 'Harvesting activity logs...'); // 1. Also scrape live DOM feed from active Brightwheel tab if open try { const [bwTab] = await chrome.tabs.query({ url: 'https://schools.mybrightwheel.com/*' }); if (bwTab && bwTab.id) { chrome.tabs.sendMessage(bwTab.id, { action: 'SCRAPE_DOM_FEED' }, (res) => { if (res && res.success && res.activities && res.activities.length > 0) { log(`Scraped ${res.activities.length} visible activity cards from active Brightwheel page DOM.`, 'info'); } }); } } catch (e) { // Non-critical } 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) { 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) { // Schema inspection for debugging const actType = act.activity_type || act.action_type || act.type || 'unknown'; if (!seenTypes.has(actType)) { seenTypes.add(actType); const presentKeys = Object.keys(act).filter(k => act[k] !== null && act[k] !== undefined && act[k] !== ''); log(`[Schema] ${actType} fields: ${presentKeys.join(', ')}`, 'info'); } // If video activity has no immediate URL, fetch single activity detail if ((actType === 'ac_video' || actType === 'video') && !act.video_url && !act.media?.video_url) { try { const detailRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities/${act.id}`); if (detailRes && detailRes.success && detailRes.data) { const detailObj = detailRes.data.activity || detailRes.data; Object.assign(act, detailObj); } } catch (e) { // Continue } } 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; // Direct check for Brightwheel video_info object if (act.video_info && typeof act.video_info === 'object') { if (act.video_info.downloadable_url) videoUrl = act.video_info.downloadable_url; else if (act.video_info.streamable_url) videoUrl = act.video_info.streamable_url; if (act.video_info.thumbnail_url) thumbUrl = act.video_info.thumbnail_url; } // Direct check for Brightwheel media object if (act.media && typeof act.media === 'object') { if (act.media.image_url) imageUrl = act.media.image_url; if (act.media.video_url && !videoUrl) videoUrl = act.media.video_url; if (act.media.thumbnail_url && !thumbUrl) thumbUrl = act.media.thumbnail_url; } 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.action_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.downloadable_url, obj.streamable_url, 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.action_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.video_info) search(obj.video_info, depth + 1); 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.action_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.action_type || act.activity_type || act.type || 'activity').toLowerCase(); const rawSubtype = (act.action_type || act.event_type || act.subtype || act.sub_type || act.action || '').toLowerCase(); const stateVal = String(act.state ?? ''); // Extract actor / staff name const actor = act.actor ? `${act.actor.first_name || ''} ${act.actor.last_name || ''}`.trim() : ''; const teacherName = actor || act.actor_name || act.created_by?.name || act.teacher_name || act.staff_name || ''; // 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 (state 1 = start, state 0 = end/wake) if (actType.includes('nap')) { if (stateVal === '1' || rawSubtype.includes('start') || (napStart && !napEnd && !durationMinutes)) { actionSubtype = 'start_nap'; actionSummary = 'Nap Started'; } else if (stateVal === '0' || 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 (state 1 = signed in, state 2 = signed out) else if (actType.includes('checkin') || actType.includes('checkout') || actType.includes('attendance')) { const isOut = stateVal === '2' || rawSubtype.includes('out') || rawSubtype.includes('away') || rawSubtype.includes('pickup') || actType.includes('checkout'); if (isOut) { actionSubtype = 'signed_out'; actionSummary = actor ? `Checked Out / Signed Out (by ${actor})` : 'Checked Out / Signed Out'; } else { actionSubtype = 'signed_in'; actionSummary = actor ? `Checked In / Signed In (by ${actor})` : 'Checked In / Signed In'; } attendanceInfo = actor ? `Signed by: ${actor}` : ''; } // 3. 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'; } // 4. 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; } // 5. 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})`; } // 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.object_id || act.id || Math.random().toString(36).substring(2), created_at: act.event_date || act.created_at || new Date().toISOString(), activity_type: act.action_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'); if (state.destinationDirectoryHandle) { log(`⚡ Direct Streaming Mode: Writing files immediately to "${state.destinationDirectoryHandle.name}".`, '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.status = 'done'; state.stats.mediaDownloaded++; state.stats.bytesDownloaded += blob.size; if (state.destinationDirectoryHandle) { // Stream directly to local directory await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, item.localZipPath, blob); // Free blob from memory item.blob = null; } else { // Keep in memory for ZIP item.blob = blob; } } 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; const maxRetries = 3; while (retries < maxRetries) { try { const res = await fetch(url, { method: 'GET' }); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); return await res.blob(); } catch (err) { retries++; if (retries >= maxRetries) throw err; log(`Retrying media download in ${retries}s (${err.message})...`, 'warn'); await sleep(1000 * retries); } } } // Write file directly into FileSystemDirectoryHandle with auto-created subfolders async function writeBlobToDirectoryHandle(rootHandle, relativePath, blobOrString) { const parts = relativePath.split('/'); let current = rootHandle; for (let i = 0; i < parts.length - 1; i++) { current = await current.getDirectoryHandle(parts[i], { create: true }); } const filename = parts[parts.length - 1]; const fileHandle = await current.getFileHandle(filename, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(blobOrString); await writable.close(); } // ------------------------------------------------------------- // Final Packaging: Direct Folder Save OR Instant ZIP // ------------------------------------------------------------- async function buildAndDownloadZip(studentProfile) { const childName = sanitizeFilename(`${studentProfile.first_name || 'Child'}_${studentProfile.last_name || ''}`).trim() || 'Brightwheel_Archive'; // 1. Data Content Generation const exportData = { export_date: new Date().toISOString(), student: studentProfile, total_activities: state.activities.length, activities: state.activities }; const exportJsonStr = JSON.stringify(exportData, null, 2); const jobInfo = { job_id: `bw_export_${Date.now()}`, export_date: new Date().toISOString(), exporter_version: chrome.runtime?.getManifest?.()?.version || '1.0.0', browser_environment: { user_agent: navigator.userAgent, platform: navigator.platform, language: navigator.language, screen_resolution: `${window.screen?.width || 0}x${window.screen?.height || 0}`, extension_id: chrome.runtime?.id || null }, student: { id: state.selectedStudentId, name: `${studentProfile.first_name || ''} ${studentProfile.last_name || ''}`.trim(), school: studentProfile.school_name || null }, statistics: { total_activities_scraped: state.activities.length, media_total: state.stats.mediaTotal, photos_downloaded: state.mediaQueue.filter(m => !m.isVideo && m.status === 'done').length, videos_downloaded: state.mediaQueue.filter(m => m.isVideo && m.status === 'done').length, media_failed: state.stats.mediaFailed, total_bytes_downloaded: state.stats.bytesDownloaded, retry_count: state.stats.retryCount }, execution_logs: state.executionLogs }; const jobInfoStr = JSON.stringify(jobInfo, null, 2); const csvContent = el.fmtCsv.checked ? generateCSV(state.activities) : null; let viewerHtml = null; if (el.fmtViewer.checked) { try { const templateRes = await fetch(chrome.runtime.getURL('dashboard/viewer_template.html')); if (templateRes.ok) { viewerHtml = await templateRes.text(); viewerHtml = viewerHtml.replace( 'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;', `let ARCHIVE_DATA = ${JSON.stringify(exportData)};` ); } } catch (e) { log('Note: could not bundle viewer: ' + e.message, 'warn'); } } // OPTION A: Direct Local Folder Save (Fastest, zero compression lag) if (state.destinationDirectoryHandle) { updateProgress(92, 'Writing manifests and indexes directly to folder...'); log(`Saving metadata files directly to "${state.destinationDirectoryHandle.name}"...`, 'info'); if (el.fmtJson.checked) { await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'activities.json', exportJsonStr); } await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'exporter_job_info.json', jobInfoStr); if (csvContent) { await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'activities.csv', csvContent); } if (viewerHtml) { await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'index.html', viewerHtml); } log(`✅ All files saved directly to folder: "${state.destinationDirectoryHandle.name}"!`, 'success'); return; } // OPTION B: Fallback to Instant ZIP (using STORE mode for instant packing) const JSZipClass = window.JSZip || (typeof JSZip !== 'undefined' ? JSZip : null); if (!JSZipClass) { throw new Error('JSZip library is not loaded.'); } updateProgress(90, 'Packaging archive...'); log('Packaging media and manifests into ZIP archive (Instant Mode)...', 'info'); const zip = new JSZipClass(); const rootFolder = zip.folder(childName); for (const item of state.mediaQueue) { if (item.blob && item.status === 'done') { rootFolder.file(item.localZipPath, item.blob); } } if (el.fmtJson.checked) { rootFolder.file('activities.json', exportJsonStr); } rootFolder.file('exporter_job_info.json', jobInfoStr); if (csvContent) { rootFolder.file('activities.csv', csvContent); } if (viewerHtml) { rootFolder.file('index.html', viewerHtml); } updateProgress(95, 'Generating ZIP package...'); // Use STORE compression for instant packing without CPU freezing on large video files const zipBlob = await zip.generateAsync({ type: 'blob', compression: 'STORE' }); const zipFilename = `${childName}_Brightwheel_Archive_${new Date().toISOString().split('T')[0]}.zip`; 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)); }