feat: initial release of Brightwheel Memories & Reports Exporter Chrome extension
This commit is contained in:
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* Brightwheel Exporter - Dashboard Controller
|
||||
*/
|
||||
|
||||
// Application State
|
||||
const state = {
|
||||
students: [],
|
||||
selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565',
|
||||
selectedStudentData: null,
|
||||
isExporting: false,
|
||||
isPaused: false,
|
||||
isCancelled: false,
|
||||
activities: [],
|
||||
mediaQueue: [],
|
||||
stats: {
|
||||
activitiesCount: 0,
|
||||
mediaTotal: 0,
|
||||
mediaDownloaded: 0,
|
||||
mediaFailed: 0,
|
||||
bytesDownloaded: 0,
|
||||
retryCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
// DOM Elements
|
||||
let el = {};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initDOMElements();
|
||||
setupEventListeners();
|
||||
initDatePresets();
|
||||
await checkSessionAndDiscoverStudents();
|
||||
});
|
||||
|
||||
function initDOMElements() {
|
||||
el = {
|
||||
sessionDot: document.getElementById('sessionDot'),
|
||||
sessionStatusText: document.getElementById('sessionStatusText'),
|
||||
refreshSessionBtn: document.getElementById('refreshSessionBtn'),
|
||||
studentsList: document.getElementById('studentsList'),
|
||||
manualDetails: document.getElementById('manualDetails'),
|
||||
manualStudentId: document.getElementById('manualStudentId'),
|
||||
setManualIdBtn: document.getElementById('setManualIdBtn'),
|
||||
quickDatePills: document.getElementById('quickDatePills'),
|
||||
customDateRow: document.getElementById('customDateRow'),
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
catPhotos: document.getElementById('catPhotos'),
|
||||
catCare: document.getElementById('catCare'),
|
||||
catMilestones: document.getElementById('catMilestones'),
|
||||
catAttendance: document.getElementById('catAttendance'),
|
||||
catMessages: document.getElementById('catMessages'),
|
||||
fmtZip: document.getElementById('fmtZip'),
|
||||
fmtViewer: document.getElementById('fmtViewer'),
|
||||
fmtJson: document.getElementById('fmtJson'),
|
||||
fmtCsv: document.getElementById('fmtCsv'),
|
||||
concurrencySelect: document.getElementById('concurrencySelect'),
|
||||
startExportBtn: document.getElementById('startExportBtn'),
|
||||
inProgressControls: document.getElementById('inProgressControls'),
|
||||
pauseExportBtn: document.getElementById('pauseExportBtn'),
|
||||
cancelExportBtn: document.getElementById('cancelExportBtn'),
|
||||
progressStatusText: document.getElementById('progressStatusText'),
|
||||
progressPercent: document.getElementById('progressPercent'),
|
||||
progressBarFill: document.getElementById('progressBarFill'),
|
||||
metricActivitiesCount: document.getElementById('metricActivitiesCount'),
|
||||
metricMediaCount: document.getElementById('metricMediaCount'),
|
||||
metricSize: document.getElementById('metricSize'),
|
||||
metricRateLimit: document.getElementById('metricRateLimit'),
|
||||
terminalLog: document.getElementById('terminalLog'),
|
||||
clearLogBtn: document.getElementById('clearLogBtn')
|
||||
};
|
||||
}
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const line = document.createElement('div');
|
||||
line.className = `log-line ${type}`;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
line.textContent = `[${timestamp}] ${message}`;
|
||||
el.terminalLog.appendChild(line);
|
||||
el.terminalLog.scrollTop = el.terminalLog.scrollHeight;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Session & Student Discovery
|
||||
// -------------------------------------------------------------
|
||||
|
||||
async function checkSessionAndDiscoverStudents() {
|
||||
log('Checking active Brightwheel browser session...', 'info');
|
||||
|
||||
// Check stored active student ID first
|
||||
const stored = await chrome.storage.local.get(['lastActiveStudentId']);
|
||||
if (stored.lastActiveStudentId) {
|
||||
state.selectedStudentId = stored.lastActiveStudentId;
|
||||
el.manualStudentId.value = stored.lastActiveStudentId;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Try to fetch user/guardian profile to list students
|
||||
const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me');
|
||||
|
||||
if (userRes && userRes.success && userRes.data) {
|
||||
el.sessionDot.classList.add('online');
|
||||
el.sessionStatusText.textContent = 'Brightwheel Session Active';
|
||||
log('Successfully connected to Brightwheel user profile.', 'success');
|
||||
|
||||
const userData = userRes.data;
|
||||
const studentsFound = userData.students || userData.children || (userData.guardians && userData.guardians[0]?.students) || [];
|
||||
|
||||
if (studentsFound.length > 0) {
|
||||
state.students = studentsFound;
|
||||
renderStudentsList();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try fetching specific student profile if selected
|
||||
if (state.selectedStudentId) {
|
||||
const studentRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${state.selectedStudentId}`);
|
||||
if (studentRes && studentRes.success && studentRes.data) {
|
||||
el.sessionDot.classList.add('online');
|
||||
el.sessionStatusText.textContent = 'Brightwheel Session Active';
|
||||
|
||||
const s = studentRes.data.student || studentRes.data;
|
||||
state.students = [s];
|
||||
state.selectedStudentData = s;
|
||||
renderStudentsList();
|
||||
log(`Loaded student profile: ${s.first_name || 'Student'} (${state.selectedStudentId})`, 'success');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Default student card with manual ID
|
||||
el.sessionDot.classList.add('online');
|
||||
el.sessionStatusText.textContent = 'Session Connected';
|
||||
state.students = [{
|
||||
id: state.selectedStudentId,
|
||||
first_name: 'Student',
|
||||
last_name: `(${state.selectedStudentId.substring(0, 8)}...)`,
|
||||
school_name: 'Brightwheel School'
|
||||
}];
|
||||
renderStudentsList();
|
||||
log(`Using student UUID: ${state.selectedStudentId}`, 'info');
|
||||
|
||||
} catch (err) {
|
||||
log(`Session check note: ${err.message}. Ready with manual ID.`, 'warn');
|
||||
renderStudentsList();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBrightwheelAPI(url) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, status: res.status, statusText: res.statusText };
|
||||
}
|
||||
const json = await res.json();
|
||||
return { success: true, status: res.status, data: json };
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function renderStudentsList() {
|
||||
el.studentsList.innerHTML = '';
|
||||
|
||||
if (state.students.length === 0) {
|
||||
el.studentsList.innerHTML = '<div class="loading-placeholder">No students auto-detected. Use manual UUID below.</div>';
|
||||
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 = `<img src="${student.picture_url || student.avatar_url}" alt="${firstName}">`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="student-avatar">${avatarContent}</div>
|
||||
<div class="student-info">
|
||||
<h4>${firstName} ${lastName}</h4>
|
||||
<p>${school} • <code>${sId.substring(0, 8)}...</code></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<div class="log-line info">[System] Log cleared.</div>';
|
||||
});
|
||||
|
||||
el.startExportBtn.addEventListener('click', () => {
|
||||
if (!state.isExporting) {
|
||||
startExportProcess();
|
||||
}
|
||||
});
|
||||
|
||||
el.pauseExportBtn.addEventListener('click', () => {
|
||||
state.isPaused = !state.isPaused;
|
||||
el.pauseExportBtn.textContent = state.isPaused ? 'Resume' : 'Pause';
|
||||
log(state.isPaused ? 'Export process PAUSED.' : 'Export process RESUMED.', 'warn');
|
||||
});
|
||||
|
||||
el.cancelExportBtn.addEventListener('click', () => {
|
||||
state.isCancelled = true;
|
||||
log('Cancelling export process...', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Main Export Process Engine
|
||||
// -------------------------------------------------------------
|
||||
|
||||
async function startExportProcess() {
|
||||
const studentId = state.selectedStudentId;
|
||||
if (!studentId) {
|
||||
alert('Please select or enter a Student UUID first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset State
|
||||
state.isExporting = true;
|
||||
state.isPaused = false;
|
||||
state.isCancelled = false;
|
||||
state.activities = [];
|
||||
state.mediaQueue = [];
|
||||
state.stats = {
|
||||
activitiesCount: 0,
|
||||
mediaTotal: 0,
|
||||
mediaDownloaded: 0,
|
||||
mediaFailed: 0,
|
||||
bytesDownloaded: 0,
|
||||
retryCount: 0
|
||||
};
|
||||
|
||||
updateUIForExportStart();
|
||||
log(`Starting export for Student ID: ${studentId}`, 'info');
|
||||
|
||||
try {
|
||||
// 1. Fetch Student Profile details
|
||||
let studentProfile = state.selectedStudentData;
|
||||
if (!studentProfile) {
|
||||
log('Fetching student profile details...', 'info');
|
||||
const pRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${studentId}`);
|
||||
if (pRes && pRes.success && pRes.data) {
|
||||
studentProfile = pRes.data.student || pRes.data;
|
||||
} else {
|
||||
studentProfile = { id: studentId, first_name: 'Student', last_name: 'Brightwheel' };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Activities Feed with pagination
|
||||
await fetchAllActivities(studentId);
|
||||
|
||||
if (state.isCancelled) {
|
||||
log('Export cancelled by user.', 'error');
|
||||
finalizeExportUI(false);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Total matching activities retrieved: ${state.activities.length}`, 'success');
|
||||
|
||||
// 3. Extract media downloads queue if selected
|
||||
if (el.catPhotos.checked) {
|
||||
extractMediaQueue();
|
||||
log(`Found ${state.mediaQueue.length} media files to download.`, 'info');
|
||||
}
|
||||
|
||||
// 4. Download media files with concurrency and pacing
|
||||
if (state.mediaQueue.length > 0) {
|
||||
await downloadAllMediaFiles();
|
||||
}
|
||||
|
||||
if (state.isCancelled) {
|
||||
log('Export cancelled during media download.', 'error');
|
||||
finalizeExportUI(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Build ZIP & Bundled Archives
|
||||
await buildAndDownloadZip(studentProfile);
|
||||
|
||||
log('🎉 Export completed successfully! Enjoy your memories.', 'success');
|
||||
updateProgress(100, 'Export Complete!');
|
||||
finalizeExportUI(true);
|
||||
|
||||
} catch (err) {
|
||||
log(`Fatal Export Error: ${err.message}`, 'error');
|
||||
console.error(err);
|
||||
finalizeExportUI(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUIForExportStart() {
|
||||
el.startExportBtn.disabled = true;
|
||||
el.startExportBtn.style.display = 'none';
|
||||
el.inProgressControls.style.display = 'flex';
|
||||
el.pauseExportBtn.textContent = 'Pause';
|
||||
updateProgress(0, 'Initializing export...');
|
||||
updateMetrics();
|
||||
}
|
||||
|
||||
function finalizeExportUI(success) {
|
||||
state.isExporting = false;
|
||||
el.startExportBtn.disabled = false;
|
||||
el.startExportBtn.style.display = 'flex';
|
||||
el.inProgressControls.style.display = 'none';
|
||||
if (!success) {
|
||||
el.progressStatusText.textContent = 'Export Stopped';
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(percent, statusText) {
|
||||
el.progressPercent.textContent = `${Math.round(percent)}%`;
|
||||
el.progressBarFill.style.width = `${percent}%`;
|
||||
if (statusText) el.progressStatusText.textContent = statusText;
|
||||
}
|
||||
|
||||
function updateMetrics() {
|
||||
el.metricActivitiesCount.textContent = state.stats.activitiesCount;
|
||||
el.metricMediaCount.textContent = `${state.stats.mediaDownloaded} / ${state.stats.mediaTotal}`;
|
||||
const mb = (state.stats.bytesDownloaded / (1024 * 1024)).toFixed(1);
|
||||
el.metricSize.textContent = `${mb} MB`;
|
||||
el.metricRateLimit.textContent = state.stats.retryCount;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Activity Feed Harvester with Pagination & Backoff
|
||||
// -------------------------------------------------------------
|
||||
|
||||
async function fetchAllActivities(studentId) {
|
||||
const pageSize = 50;
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
const startDate = el.startDate.value;
|
||||
const endDate = el.endDate.value;
|
||||
const pacingDelay = parseInt(el.concurrencySelect.value) || 200;
|
||||
|
||||
updateProgress(5, 'Harvesting activity logs...');
|
||||
|
||||
while (hasMore && !state.isCancelled) {
|
||||
while (state.isPaused && !state.isCancelled) {
|
||||
await sleep(500);
|
||||
}
|
||||
if (state.isCancelled) break;
|
||||
|
||||
// Build URL query
|
||||
let url = `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=${pageSize}&page=${page}`;
|
||||
if (startDate) url += `&start_date=${startDate}`;
|
||||
if (endDate) url += `&end_date=${endDate}`;
|
||||
|
||||
log(`Fetching activities page ${page}...`, 'info');
|
||||
|
||||
let response = null;
|
||||
let retries = 0;
|
||||
const maxRetries = 5;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
response = await fetchBrightwheelAPI(url);
|
||||
|
||||
if (response && response.status === 429) {
|
||||
state.stats.retryCount++;
|
||||
updateMetrics();
|
||||
const backoffTime = Math.pow(2, retries) * 1000;
|
||||
log(`Rate limited (429). Backing off for ${backoffTime / 1000}s...`, 'warn');
|
||||
await sleep(backoffTime);
|
||||
retries++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response || !response.success || !response.data) {
|
||||
// If page 1 failed or reached end
|
||||
if (page === 1) {
|
||||
// Try fallback endpoint /api/v1/activities?student_ids[]
|
||||
log('Trying alternate activity feed endpoint...', 'info');
|
||||
const altUrl = `https://schools.mybrightwheel.com/api/v1/activities?student_ids[]=${studentId}&page_size=${pageSize}&page=${page}`;
|
||||
const altRes = await fetchBrightwheelAPI(altUrl);
|
||||
if (altRes && altRes.success && altRes.data) {
|
||||
response = altRes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawActivities = response?.data?.activities || response?.data?.items || response?.data || [];
|
||||
|
||||
if (!Array.isArray(rawActivities) || rawActivities.length === 0) {
|
||||
log(`No more activities found after page ${page - 1}. Done harvesting.`, 'info');
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Filter and normalize activities
|
||||
for (const act of rawActivities) {
|
||||
if (matchesSelectedCategories(act)) {
|
||||
state.activities.push(normalizeActivity(act));
|
||||
}
|
||||
}
|
||||
|
||||
state.stats.activitiesCount = state.activities.length;
|
||||
updateMetrics();
|
||||
updateProgress(Math.min(30, 5 + page * 2), `Harvested ${state.activities.length} activities (Page ${page})`);
|
||||
|
||||
if (rawActivities.length < pageSize) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
await sleep(pacingDelay);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesSelectedCategories(act) {
|
||||
const type = (act.activity_type || act.action_type || act.type || '').toLowerCase();
|
||||
|
||||
if (act.media || act.image_url || act.video_url || type.includes('photo') || type.includes('video')) {
|
||||
if (el.catPhotos.checked) return true;
|
||||
}
|
||||
if (type.includes('food') || type.includes('meal') || type.includes('nap') || type.includes('potty') || type.includes('diaper') || type.includes('med')) {
|
||||
if (el.catCare.checked) return true;
|
||||
}
|
||||
if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos')) {
|
||||
if (el.catMilestones.checked) return true;
|
||||
}
|
||||
if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident')) {
|
||||
if (el.catAttendance.checked) return true;
|
||||
}
|
||||
if (type.includes('note') || type.includes('message') || type.includes('announcement')) {
|
||||
if (el.catMessages.checked) return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeActivity(act) {
|
||||
const mediaObj = act.media || {};
|
||||
const highResImage = mediaObj.image_url || act.image_url || mediaObj.url || act.url;
|
||||
const highResVideo = mediaObj.video_url || act.video_url;
|
||||
|
||||
return {
|
||||
id: act.id || act.object_id || Math.random().toString(36).substring(2),
|
||||
created_at: act.created_at || act.event_date || new Date().toISOString(),
|
||||
activity_type: act.activity_type || act.action_type || act.type || 'activity',
|
||||
title: act.title || '',
|
||||
note: act.note || act.description || act.body || '',
|
||||
teacher_name: act.actor_name || act.created_by?.name || act.teacher_name || '',
|
||||
image_url: highResImage || null,
|
||||
video_url: highResVideo || null,
|
||||
thumb_url: mediaObj.thumb_url || act.thumb_url || null,
|
||||
food_type: act.food_type || act.meal_type || null,
|
||||
amount_eaten: act.amount_eaten || act.quantity || null,
|
||||
nap_start: act.start_time || act.nap_start || null,
|
||||
nap_end: act.end_time || act.nap_end || null,
|
||||
duration_minutes: act.duration_minutes || null,
|
||||
potty_type: act.potty_type || act.diaper_type || null,
|
||||
milestone_name: act.milestone_name || act.tag_name || null,
|
||||
media_local_path: null // Populated during ZIP bundling
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Media Downloader with Concurrency & Progress
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function extractMediaQueue() {
|
||||
state.mediaQueue = [];
|
||||
|
||||
state.activities.forEach((act) => {
|
||||
const url = act.video_url || act.image_url;
|
||||
if (url) {
|
||||
const isVideo = !!act.video_url || url.includes('.mp4') || act.activity_type === 'video';
|
||||
const ext = isVideo ? 'mp4' : 'jpg';
|
||||
const datePrefix = (act.created_at || '').substring(0, 10) || 'unknown_date';
|
||||
const filename = `${datePrefix}_${act.id.substring(0, 12)}.${ext}`;
|
||||
const folder = isVideo ? 'videos' : 'photos';
|
||||
const yearMonth = datePrefix.substring(0, 7) || 'all';
|
||||
const localZipPath = `${folder}/${yearMonth}/${filename}`;
|
||||
|
||||
act.media_local_path = localZipPath;
|
||||
|
||||
state.mediaQueue.push({
|
||||
activityId: act.id,
|
||||
url: url,
|
||||
filename: filename,
|
||||
localZipPath: localZipPath,
|
||||
blob: null,
|
||||
status: 'pending'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
state.stats.mediaTotal = state.mediaQueue.length;
|
||||
updateMetrics();
|
||||
}
|
||||
|
||||
async function downloadAllMediaFiles() {
|
||||
const queue = state.mediaQueue;
|
||||
const concurrency = 3;
|
||||
let activeIndex = 0;
|
||||
|
||||
updateProgress(35, `Downloading 0 / ${queue.length} media files...`);
|
||||
|
||||
async function worker() {
|
||||
while (activeIndex < queue.length && !state.isCancelled) {
|
||||
while (state.isPaused && !state.isCancelled) {
|
||||
await sleep(500);
|
||||
}
|
||||
if (state.isCancelled) break;
|
||||
|
||||
const itemIndex = activeIndex++;
|
||||
const item = queue[itemIndex];
|
||||
if (!item) break;
|
||||
|
||||
try {
|
||||
const blob = await fetchMediaBlob(item.url);
|
||||
item.blob = blob;
|
||||
item.status = 'done';
|
||||
state.stats.mediaDownloaded++;
|
||||
state.stats.bytesDownloaded += blob.size;
|
||||
} catch (err) {
|
||||
item.status = 'failed';
|
||||
state.stats.mediaFailed++;
|
||||
log(`Failed to download media ${item.filename}: ${err.message}`, 'warn');
|
||||
}
|
||||
|
||||
updateMetrics();
|
||||
const progressPercent = 35 + (state.stats.mediaDownloaded / queue.length) * 50;
|
||||
updateProgress(progressPercent, `Downloading media: ${state.stats.mediaDownloaded} / ${queue.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = [];
|
||||
for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function fetchMediaBlob(url) {
|
||||
let retries = 0;
|
||||
while (retries < 3) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.blob();
|
||||
} catch (err) {
|
||||
retries++;
|
||||
if (retries >= 3) throw err;
|
||||
await sleep(1000 * retries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// ZIP Archiving & Packaging
|
||||
// -------------------------------------------------------------
|
||||
|
||||
async function buildAndDownloadZip(studentProfile) {
|
||||
if (typeof JSZip === 'undefined') {
|
||||
throw new Error('JSZip library is not loaded.');
|
||||
}
|
||||
|
||||
updateProgress(88, 'Generating ZIP package...');
|
||||
log('Packaging media and manifests into ZIP archive...', 'info');
|
||||
|
||||
const zip = new JSZip();
|
||||
const childName = sanitizeFilename(`${studentProfile.first_name || 'Child'}_${studentProfile.last_name || ''}`).trim() || 'Brightwheel_Archive';
|
||||
const rootFolder = zip.folder(childName);
|
||||
|
||||
// 1. Add Media Files
|
||||
for (const item of state.mediaQueue) {
|
||||
if (item.blob && item.status === 'done') {
|
||||
rootFolder.file(item.localZipPath, item.blob);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add JSON Manifest
|
||||
if (el.fmtJson.checked) {
|
||||
const manifestData = {
|
||||
export_date: new Date().toISOString(),
|
||||
student: studentProfile,
|
||||
total_activities: state.activities.length,
|
||||
activities: state.activities
|
||||
};
|
||||
rootFolder.file('manifest.json', JSON.stringify(manifestData, null, 2));
|
||||
}
|
||||
|
||||
// 3. Add CSV Spreadsheet
|
||||
if (el.fmtCsv.checked) {
|
||||
const csvContent = generateCSV(state.activities);
|
||||
rootFolder.file('activities.csv', csvContent);
|
||||
}
|
||||
|
||||
// 4. Add Standalone Interactive Offline Viewer
|
||||
if (el.fmtViewer.checked) {
|
||||
try {
|
||||
const templateRes = await fetch(chrome.runtime.getURL('dashboard/viewer_template.html'));
|
||||
if (templateRes.ok) {
|
||||
let viewerHtml = await templateRes.text();
|
||||
// Embed manifest data into index.html for instant zero-server loading
|
||||
const embeddedManifest = JSON.stringify({
|
||||
export_date: new Date().toISOString(),
|
||||
student: studentProfile,
|
||||
activities: state.activities
|
||||
});
|
||||
viewerHtml = viewerHtml.replace(
|
||||
'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;',
|
||||
`let ARCHIVE_DATA = ${embeddedManifest};`
|
||||
);
|
||||
rootFolder.file('index.html', viewerHtml);
|
||||
}
|
||||
} catch (e) {
|
||||
log('Note: could not embed standalone index.html viewer: ' + e.message, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Generate ZIP Blob
|
||||
updateProgress(94, 'Compressing archive...');
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
}, (metadata) => {
|
||||
updateProgress(88 + (metadata.percent * 0.08), `Compressing ZIP: ${Math.round(metadata.percent)}%`);
|
||||
});
|
||||
|
||||
const zipFilename = `${childName}_Brightwheel_Archive_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
|
||||
// 6. Trigger Download
|
||||
const blobUrl = URL.createObjectURL(zipBlob);
|
||||
|
||||
if (chrome.downloads) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'DOWNLOAD_BLOB_URL',
|
||||
url: blobUrl,
|
||||
filename: zipFilename
|
||||
}, (res) => {
|
||||
if (!res || !res.success) {
|
||||
triggerBrowserDownload(blobUrl, zipFilename);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
triggerBrowserDownload(blobUrl, zipFilename);
|
||||
}
|
||||
|
||||
log(`Archive created: ${zipFilename} (${(zipBlob.size / (1024 * 1024)).toFixed(2)} MB)`, 'success');
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(url, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
function generateCSV(activities) {
|
||||
const headers = [
|
||||
'Activity ID',
|
||||
'Date & Time',
|
||||
'Type',
|
||||
'Title',
|
||||
'Note / Caption',
|
||||
'Logged By',
|
||||
'Media File Path',
|
||||
'Food / Meal',
|
||||
'Amount Eaten',
|
||||
'Nap Start',
|
||||
'Nap End',
|
||||
'Nap Mins',
|
||||
'Potty Type',
|
||||
'Milestone'
|
||||
];
|
||||
|
||||
const rows = activities.map(a => [
|
||||
escapeCSV(a.id),
|
||||
escapeCSV(a.created_at),
|
||||
escapeCSV(a.activity_type),
|
||||
escapeCSV(a.title),
|
||||
escapeCSV(a.note),
|
||||
escapeCSV(a.teacher_name),
|
||||
escapeCSV(a.media_local_path || a.video_url || a.image_url || ''),
|
||||
escapeCSV(a.food_type || ''),
|
||||
escapeCSV(a.amount_eaten || ''),
|
||||
escapeCSV(a.nap_start || ''),
|
||||
escapeCSV(a.nap_end || ''),
|
||||
escapeCSV(a.duration_minutes || ''),
|
||||
escapeCSV(a.potty_type || ''),
|
||||
escapeCSV(a.milestone_name || '')
|
||||
]);
|
||||
|
||||
return [headers.join(','), ...rows.map(r => r.join(','))].join('\r\n');
|
||||
}
|
||||
|
||||
function escapeCSV(val) {
|
||||
if (val === null || val === undefined) return '""';
|
||||
const str = String(val).replace(/"/g, '""');
|
||||
return `"${str}"`;
|
||||
}
|
||||
|
||||
function sanitizeFilename(str) {
|
||||
return str.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user