813 lines
26 KiB
JavaScript
813 lines
26 KiB
JavaScript
/**
|
|
* Brightwheel Exporter - Dashboard Controller
|
|
*/
|
|
|
|
// Application State
|
|
const state = {
|
|
students: [],
|
|
selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565',
|
|
selectedStudentData: null,
|
|
isExporting: false,
|
|
isPaused: false,
|
|
isCancelled: false,
|
|
activities: [],
|
|
mediaQueue: [],
|
|
stats: {
|
|
activitiesCount: 0,
|
|
mediaTotal: 0,
|
|
mediaDownloaded: 0,
|
|
mediaFailed: 0,
|
|
bytesDownloaded: 0,
|
|
retryCount: 0
|
|
}
|
|
};
|
|
|
|
// DOM Elements
|
|
let el = {};
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
initDOMElements();
|
|
setupEventListeners();
|
|
initDatePresets();
|
|
|
|
if (window.JSZip || typeof JSZip !== 'undefined') {
|
|
log('Zip compression engine initialized (JSZip v3.10.1).', 'info');
|
|
} else {
|
|
log('Warning: JSZip library not detected on page load.', 'warn');
|
|
}
|
|
|
|
await checkSessionAndDiscoverStudents();
|
|
});
|
|
|
|
function initDOMElements() {
|
|
el = {
|
|
sessionDot: document.getElementById('sessionDot'),
|
|
sessionStatusText: document.getElementById('sessionStatusText'),
|
|
refreshSessionBtn: document.getElementById('refreshSessionBtn'),
|
|
studentsList: document.getElementById('studentsList'),
|
|
manualDetails: document.getElementById('manualDetails'),
|
|
manualStudentId: document.getElementById('manualStudentId'),
|
|
setManualIdBtn: document.getElementById('setManualIdBtn'),
|
|
quickDatePills: document.getElementById('quickDatePills'),
|
|
customDateRow: document.getElementById('customDateRow'),
|
|
startDate: document.getElementById('startDate'),
|
|
endDate: document.getElementById('endDate'),
|
|
catPhotos: document.getElementById('catPhotos'),
|
|
catCare: document.getElementById('catCare'),
|
|
catMilestones: document.getElementById('catMilestones'),
|
|
catAttendance: document.getElementById('catAttendance'),
|
|
catMessages: document.getElementById('catMessages'),
|
|
fmtZip: document.getElementById('fmtZip'),
|
|
fmtViewer: document.getElementById('fmtViewer'),
|
|
fmtJson: document.getElementById('fmtJson'),
|
|
fmtCsv: document.getElementById('fmtCsv'),
|
|
concurrencySelect: document.getElementById('concurrencySelect'),
|
|
startExportBtn: document.getElementById('startExportBtn'),
|
|
inProgressControls: document.getElementById('inProgressControls'),
|
|
pauseExportBtn: document.getElementById('pauseExportBtn'),
|
|
cancelExportBtn: document.getElementById('cancelExportBtn'),
|
|
progressStatusText: document.getElementById('progressStatusText'),
|
|
progressPercent: document.getElementById('progressPercent'),
|
|
progressBarFill: document.getElementById('progressBarFill'),
|
|
metricActivitiesCount: document.getElementById('metricActivitiesCount'),
|
|
metricMediaCount: document.getElementById('metricMediaCount'),
|
|
metricSize: document.getElementById('metricSize'),
|
|
metricRateLimit: document.getElementById('metricRateLimit'),
|
|
terminalLog: document.getElementById('terminalLog'),
|
|
clearLogBtn: document.getElementById('clearLogBtn')
|
|
};
|
|
}
|
|
|
|
function log(message, type = 'info') {
|
|
const line = document.createElement('div');
|
|
line.className = `log-line ${type}`;
|
|
const timestamp = new Date().toLocaleTimeString();
|
|
line.textContent = `[${timestamp}] ${message}`;
|
|
el.terminalLog.appendChild(line);
|
|
el.terminalLog.scrollTop = el.terminalLog.scrollHeight;
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// Session & Student Discovery
|
|
// -------------------------------------------------------------
|
|
|
|
async function checkSessionAndDiscoverStudents() {
|
|
log('Checking active Brightwheel browser session...', 'info');
|
|
|
|
// Check stored active student ID first
|
|
const stored = await chrome.storage.local.get(['lastActiveStudentId']);
|
|
if (stored.lastActiveStudentId) {
|
|
state.selectedStudentId = stored.lastActiveStudentId;
|
|
el.manualStudentId.value = stored.lastActiveStudentId;
|
|
}
|
|
|
|
try {
|
|
// 1. Try to fetch user/guardian profile to list students
|
|
const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me');
|
|
|
|
if (userRes && userRes.success && userRes.data) {
|
|
el.sessionDot.classList.add('online');
|
|
el.sessionStatusText.textContent = 'Brightwheel Session Active';
|
|
log('Successfully connected to Brightwheel user profile.', 'success');
|
|
|
|
const userData = userRes.data;
|
|
const studentsFound = userData.students || userData.children || (userData.guardians && userData.guardians[0]?.students) || [];
|
|
|
|
if (studentsFound.length > 0) {
|
|
state.students = studentsFound;
|
|
renderStudentsList();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 2. Try fetching specific student profile if selected
|
|
if (state.selectedStudentId) {
|
|
const studentRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${state.selectedStudentId}`);
|
|
if (studentRes && studentRes.success && studentRes.data) {
|
|
el.sessionDot.classList.add('online');
|
|
el.sessionStatusText.textContent = 'Brightwheel Session Active';
|
|
|
|
const s = studentRes.data.student || studentRes.data;
|
|
state.students = [s];
|
|
state.selectedStudentData = s;
|
|
renderStudentsList();
|
|
log(`Loaded student profile: ${s.first_name || 'Student'} (${state.selectedStudentId})`, 'success');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback: Default student card with manual ID
|
|
el.sessionDot.classList.add('online');
|
|
el.sessionStatusText.textContent = 'Session Connected';
|
|
state.students = [{
|
|
id: state.selectedStudentId,
|
|
first_name: 'Student',
|
|
last_name: `(${state.selectedStudentId.substring(0, 8)}...)`,
|
|
school_name: 'Brightwheel School'
|
|
}];
|
|
renderStudentsList();
|
|
log(`Using student UUID: ${state.selectedStudentId}`, 'info');
|
|
|
|
} catch (err) {
|
|
log(`Session check note: ${err.message}. Ready with manual ID.`, 'warn');
|
|
renderStudentsList();
|
|
}
|
|
}
|
|
|
|
async function fetchBrightwheelAPI(url) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return { success: false, status: res.status, statusText: res.statusText };
|
|
}
|
|
const json = await res.json();
|
|
return { success: true, status: res.status, data: json };
|
|
} catch (err) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
function renderStudentsList() {
|
|
el.studentsList.innerHTML = '';
|
|
|
|
if (state.students.length === 0) {
|
|
el.studentsList.innerHTML = '<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) {
|
|
const JSZipClass = window.JSZip || (typeof JSZip !== 'undefined' ? JSZip : null);
|
|
if (!JSZipClass) {
|
|
throw new Error('JSZip library is not loaded.');
|
|
}
|
|
|
|
updateProgress(88, 'Generating ZIP package...');
|
|
log('Packaging media and manifests into ZIP archive...', 'info');
|
|
|
|
const zip = new JSZipClass();
|
|
const childName = sanitizeFilename(`${studentProfile.first_name || 'Child'}_${studentProfile.last_name || ''}`).trim() || 'Brightwheel_Archive';
|
|
const rootFolder = zip.folder(childName);
|
|
|
|
// 1. Add Media Files
|
|
for (const item of state.mediaQueue) {
|
|
if (item.blob && item.status === 'done') {
|
|
rootFolder.file(item.localZipPath, item.blob);
|
|
}
|
|
}
|
|
|
|
// 2. Add JSON Manifest
|
|
if (el.fmtJson.checked) {
|
|
const manifestData = {
|
|
export_date: new Date().toISOString(),
|
|
student: studentProfile,
|
|
total_activities: state.activities.length,
|
|
activities: state.activities
|
|
};
|
|
rootFolder.file('manifest.json', JSON.stringify(manifestData, null, 2));
|
|
}
|
|
|
|
// 3. Add CSV Spreadsheet
|
|
if (el.fmtCsv.checked) {
|
|
const csvContent = generateCSV(state.activities);
|
|
rootFolder.file('activities.csv', csvContent);
|
|
}
|
|
|
|
// 4. Add Standalone Interactive Offline Viewer
|
|
if (el.fmtViewer.checked) {
|
|
try {
|
|
const templateRes = await fetch(chrome.runtime.getURL('dashboard/viewer_template.html'));
|
|
if (templateRes.ok) {
|
|
let viewerHtml = await templateRes.text();
|
|
// Embed manifest data into index.html for instant zero-server loading
|
|
const embeddedManifest = JSON.stringify({
|
|
export_date: new Date().toISOString(),
|
|
student: studentProfile,
|
|
activities: state.activities
|
|
});
|
|
viewerHtml = viewerHtml.replace(
|
|
'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;',
|
|
`let ARCHIVE_DATA = ${embeddedManifest};`
|
|
);
|
|
rootFolder.file('index.html', viewerHtml);
|
|
}
|
|
} catch (e) {
|
|
log('Note: could not embed standalone index.html viewer: ' + e.message, 'warn');
|
|
}
|
|
}
|
|
|
|
// 5. Generate ZIP Blob
|
|
updateProgress(94, 'Compressing archive...');
|
|
const zipBlob = await zip.generateAsync({
|
|
type: 'blob',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 }
|
|
}, (metadata) => {
|
|
updateProgress(88 + (metadata.percent * 0.08), `Compressing ZIP: ${Math.round(metadata.percent)}%`);
|
|
});
|
|
|
|
const zipFilename = `${childName}_Brightwheel_Archive_${new Date().toISOString().split('T')[0]}.zip`;
|
|
|
|
// 6. Trigger Download
|
|
const blobUrl = URL.createObjectURL(zipBlob);
|
|
|
|
if (chrome.downloads) {
|
|
chrome.runtime.sendMessage({
|
|
action: 'DOWNLOAD_BLOB_URL',
|
|
url: blobUrl,
|
|
filename: zipFilename
|
|
}, (res) => {
|
|
if (!res || !res.success) {
|
|
triggerBrowserDownload(blobUrl, zipFilename);
|
|
}
|
|
});
|
|
} else {
|
|
triggerBrowserDownload(blobUrl, zipFilename);
|
|
}
|
|
|
|
log(`Archive created: ${zipFilename} (${(zipBlob.size / (1024 * 1024)).toFixed(2)} MB)`, 'success');
|
|
}
|
|
|
|
function triggerBrowserDownload(url, filename) {
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
|
|
function generateCSV(activities) {
|
|
const headers = [
|
|
'Activity ID',
|
|
'Date & Time',
|
|
'Type',
|
|
'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));
|
|
}
|