diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index c18a919..47ead24 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -158,40 +158,45 @@ - +
4 -

Archive Formats

+

Destination & Output

+ +
+
+
+ 📁 +
+ Direct Local Folder Save + Fastest & Zero Lag +
+
+ +
+
+ No folder selected (will download as instant ZIP) + +
+ + ⚡ Streams photos & videos directly to your hard drive in real-time as they download. No zip compression delay! + +
+
- - - - @@ -203,6 +208,15 @@ Open in Excel, Numbers, or Google Sheets
+ +
diff --git a/dashboard/dashboard.js b/dashboard/dashboard.js index eda0b4b..53b580a 100644 --- a/dashboard/dashboard.js +++ b/dashboard/dashboard.js @@ -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 = `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 { @@ -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',