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