feat: add Direct Local Folder Save via File System Access API and zero-lag STORE zip mode

This commit is contained in:
2026-08-23 14:02:35 -07:00
parent 20d81a2b4d
commit 82cea2002c
2 changed files with 180 additions and 78 deletions
+144 -56
View File
@@ -7,6 +7,7 @@ const state = {
students: [],
selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565',
selectedStudentData: null,
destinationDirectoryHandle: null,
isExporting: false,
isPaused: false,
isCancelled: false,
@@ -58,7 +59,9 @@ function initDOMElements() {
catMilestones: document.getElementById('catMilestones'),
catAttendance: document.getElementById('catAttendance'),
catMessages: document.getElementById('catMessages'),
fmtZip: document.getElementById('fmtZip'),
chooseFolderBtn: document.getElementById('chooseFolderBtn'),
clearFolderBtn: document.getElementById('clearFolderBtn'),
folderPathText: document.getElementById('folderPathText'),
fmtViewer: document.getElementById('fmtViewer'),
fmtJson: document.getElementById('fmtJson'),
fmtCsv: document.getElementById('fmtCsv'),
@@ -277,6 +280,14 @@ function setupEventListeners() {
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;
@@ -322,6 +333,42 @@ function setupEventListeners() {
});
}
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 = `<strong>Selected:</strong> 📁 ${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 {
@@ -938,6 +985,9 @@ async function downloadAllMediaFiles() {
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...`);
@@ -954,10 +1004,19 @@ async function downloadAllMediaFiles() {
try {
const blob = await fetchMediaBlob(item.url);
item.blob = blob;
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++;
@@ -995,42 +1054,36 @@ async function fetchMediaBlob(url) {
}
}
// 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();
}
// -------------------------------------------------------------
// ZIP Archiving & Packaging
// Final Packaging: Direct Folder Save OR Instant ZIP
// -------------------------------------------------------------
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);
}
}
// 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);
// 2. Single Source of Truth for Data Records: activities.json
if (el.fmtJson.checked) {
const exportData = {
export_date: new Date().toISOString(),
student: studentProfile,
total_activities: state.activities.length,
activities: state.activities
};
rootFolder.file('activities.json', JSON.stringify(exportData, null, 2));
}
// 3. Exporter Job Information & Debug Logs: exporter_job_info.json
const jobInfo = {
job_id: `bw_export_${Date.now()}`,
export_date: new Date().toISOString(),
@@ -1058,52 +1111,87 @@ async function buildAndDownloadZip(studentProfile) {
},
execution_logs: state.executionLogs
};
rootFolder.file('exporter_job_info.json', JSON.stringify(jobInfo, null, 2));
const jobInfoStr = JSON.stringify(jobInfo, null, 2);
// 4. Add CSV Spreadsheet
if (el.fmtCsv.checked) {
const csvContent = generateCSV(state.activities);
rootFolder.file('activities.csv', csvContent);
}
const csvContent = el.fmtCsv.checked ? generateCSV(state.activities) : null;
// 5. Add Standalone Interactive Offline Viewer
let viewerHtml = null;
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 = await templateRes.text();
viewerHtml = viewerHtml.replace(
'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;',
`let ARCHIVE_DATA = ${embeddedManifest};`
`let ARCHIVE_DATA = ${JSON.stringify(exportData)};`
);
rootFolder.file('index.html', viewerHtml);
}
} catch (e) {
log('Note: could not embed standalone index.html viewer: ' + e.message, 'warn');
log('Note: could not bundle viewer: ' + e.message, 'warn');
}
}
// 5. Generate ZIP Blob
updateProgress(94, 'Compressing archive...');
// 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: 'DEFLATE',
compressionOptions: { level: 6 }
}, (metadata) => {
updateProgress(88 + (metadata.percent * 0.08), `Compressing ZIP: ${Math.round(metadata.percent)}%`);
compression: 'STORE'
});
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',