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
+36 -22
View File
@@ -158,40 +158,45 @@
</div> </div>
</section> </section>
<!-- Card 4: Packaging Formats --> <!-- Card 4: Destination & Output -->
<section class="card"> <section class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title-group"> <div class="card-title-group">
<span class="step-num">4</span> <span class="step-num">4</span>
<h2>Archive Formats</h2> <h2>Destination & Output</h2>
</div> </div>
</div> </div>
<div class="card-content"> <div class="card-content">
<!-- Direct Folder Destination Picker (Recommended) -->
<div class="folder-destination-box" style="background: rgba(255, 107, 53, 0.05); border: 1.5px dashed var(--primary); border-radius: var(--radius-md); padding: 14px 16px; margin-bottom: 18px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 20px;">📁</span>
<div>
<strong style="font-size: 13.5px; color: var(--text);">Direct Local Folder Save</strong>
<span class="badge" style="background: var(--primary); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; margin-left: 6px;">Fastest & Zero Lag</span>
</div>
</div>
<button type="button" id="chooseFolderBtn" class="secondary-btn" style="padding: 6px 12px; font-size: 12px; white-space: nowrap; border-color: var(--primary); color: var(--primary); font-weight: 600;">
Choose Folder...
</button>
</div>
<div id="folderStatusBox" style="font-size: 12px; color: var(--text-muted);">
<span id="folderPathText">No folder selected (will download as instant ZIP)</span>
<button type="button" id="clearFolderBtn" style="display: none; background: none; border: none; color: var(--error); font-size: 11px; text-decoration: underline; cursor: pointer; margin-left: 8px;">Reset</button>
</div>
<small style="display: block; margin-top: 6px; color: var(--text-muted); font-size: 11px;">
⚡ Streams photos & videos directly to your hard drive in real-time as they download. No zip compression delay!
</small>
</div>
<div class="checkbox-group"> <div class="checkbox-group">
<label class="custom-checkbox">
<input type="checkbox" id="fmtZip" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Folder-Organized ZIP Archive</strong>
<small>Structured by <code>/YYYY/MM/photos/</code> and <code>/videos/</code></small>
</div>
</label>
<label class="custom-checkbox">
<input type="checkbox" id="fmtViewer" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Offline Interactive Timeline Viewer</strong>
<small>Standalone <code>index.html</code> bundled inside the archive</small>
</div>
</label>
<label class="custom-checkbox"> <label class="custom-checkbox">
<input type="checkbox" id="fmtJson" checked> <input type="checkbox" id="fmtJson" checked>
<span class="checkmark"></span> <span class="checkmark"></span>
<div class="checkbox-label"> <div class="checkbox-label">
<strong>Raw JSON Manifest (<code>manifest.json</code>)</strong> <strong>Unified JSON Archive (<code>activities.json</code>)</strong>
<small>Complete structured metadata for data portability</small> <small>Single source of truth containing all structured & raw data</small>
</div> </div>
</label> </label>
@@ -203,6 +208,15 @@
<small>Open in Excel, Numbers, or Google Sheets</small> <small>Open in Excel, Numbers, or Google Sheets</small>
</div> </div>
</label> </label>
<label class="custom-checkbox">
<input type="checkbox" id="fmtViewer" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Offline Memories Web Viewer (<code>index.html</code>)</strong>
<small>Standalone interactive browser app with gallery & timeline</small>
</div>
</label>
</div> </div>
</div> </div>
</section> </section>
+138 -50
View File
@@ -7,6 +7,7 @@ const state = {
students: [], students: [],
selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565', selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565',
selectedStudentData: null, selectedStudentData: null,
destinationDirectoryHandle: null,
isExporting: false, isExporting: false,
isPaused: false, isPaused: false,
isCancelled: false, isCancelled: false,
@@ -58,7 +59,9 @@ function initDOMElements() {
catMilestones: document.getElementById('catMilestones'), catMilestones: document.getElementById('catMilestones'),
catAttendance: document.getElementById('catAttendance'), catAttendance: document.getElementById('catAttendance'),
catMessages: document.getElementById('catMessages'), catMessages: document.getElementById('catMessages'),
fmtZip: document.getElementById('fmtZip'), chooseFolderBtn: document.getElementById('chooseFolderBtn'),
clearFolderBtn: document.getElementById('clearFolderBtn'),
folderPathText: document.getElementById('folderPathText'),
fmtViewer: document.getElementById('fmtViewer'), fmtViewer: document.getElementById('fmtViewer'),
fmtJson: document.getElementById('fmtJson'), fmtJson: document.getElementById('fmtJson'),
fmtCsv: document.getElementById('fmtCsv'), fmtCsv: document.getElementById('fmtCsv'),
@@ -277,6 +280,14 @@ function setupEventListeners() {
el.inspectFeedBtn.addEventListener('click', handleInspectFeed); 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', () => { el.setManualIdBtn.addEventListener('click', () => {
const customId = el.manualStudentId.value.trim(); const customId = el.manualStudentId.value.trim();
if (!customId) return; 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() { async function handleInspectFeed() {
log('🔬 Inspecting live Brightwheel tab...', 'info'); log('🔬 Inspecting live Brightwheel tab...', 'info');
try { try {
@@ -938,6 +985,9 @@ async function downloadAllMediaFiles() {
const videoCount = queue.filter(q => q.isVideo).length; const videoCount = queue.filter(q => q.isVideo).length;
const photoCount = queue.length - videoCount; const photoCount = queue.length - videoCount;
log(`Media to download: ${photoCount} photos, ${videoCount} videos.`, 'info'); 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...`); updateProgress(35, `Downloading 0 / ${queue.length} media files...`);
@@ -954,10 +1004,19 @@ async function downloadAllMediaFiles() {
try { try {
const blob = await fetchMediaBlob(item.url); const blob = await fetchMediaBlob(item.url);
item.blob = blob;
item.status = 'done'; item.status = 'done';
state.stats.mediaDownloaded++; state.stats.mediaDownloaded++;
state.stats.bytesDownloaded += blob.size; 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) { } catch (err) {
item.status = 'failed'; item.status = 'failed';
state.stats.mediaFailed++; 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) { 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 childName = sanitizeFilename(`${studentProfile.first_name || 'Child'}_${studentProfile.last_name || ''}`).trim() || 'Brightwheel_Archive';
const rootFolder = zip.folder(childName);
// 1. Add Media Files // 1. Data Content Generation
for (const item of state.mediaQueue) {
if (item.blob && item.status === 'done') {
rootFolder.file(item.localZipPath, item.blob);
}
}
// 2. Single Source of Truth for Data Records: activities.json
if (el.fmtJson.checked) {
const exportData = { const exportData = {
export_date: new Date().toISOString(), export_date: new Date().toISOString(),
student: studentProfile, student: studentProfile,
total_activities: state.activities.length, total_activities: state.activities.length,
activities: state.activities activities: state.activities
}; };
rootFolder.file('activities.json', JSON.stringify(exportData, null, 2)); const exportJsonStr = JSON.stringify(exportData, null, 2);
}
// 3. Exporter Job Information & Debug Logs: exporter_job_info.json
const jobInfo = { const jobInfo = {
job_id: `bw_export_${Date.now()}`, job_id: `bw_export_${Date.now()}`,
export_date: new Date().toISOString(), export_date: new Date().toISOString(),
@@ -1058,52 +1111,87 @@ async function buildAndDownloadZip(studentProfile) {
}, },
execution_logs: state.executionLogs 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 const csvContent = el.fmtCsv.checked ? generateCSV(state.activities) : null;
if (el.fmtCsv.checked) {
const csvContent = generateCSV(state.activities);
rootFolder.file('activities.csv', csvContent);
}
// 5. Add Standalone Interactive Offline Viewer let viewerHtml = null;
if (el.fmtViewer.checked) { if (el.fmtViewer.checked) {
try { try {
const templateRes = await fetch(chrome.runtime.getURL('dashboard/viewer_template.html')); const templateRes = await fetch(chrome.runtime.getURL('dashboard/viewer_template.html'));
if (templateRes.ok) { if (templateRes.ok) {
let viewerHtml = await templateRes.text(); 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( viewerHtml = viewerHtml.replace(
'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;', 'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;',
`let ARCHIVE_DATA = ${embeddedManifest};` `let ARCHIVE_DATA = ${JSON.stringify(exportData)};`
); );
rootFolder.file('index.html', viewerHtml);
} }
} catch (e) { } 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 // OPTION A: Direct Local Folder Save (Fastest, zero compression lag)
updateProgress(94, 'Compressing archive...'); 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({ const zipBlob = await zip.generateAsync({
type: 'blob', type: 'blob',
compression: 'DEFLATE', compression: 'STORE'
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`; const zipFilename = `${childName}_Brightwheel_Archive_${new Date().toISOString().split('T')[0]}.zip`;
// 6. Trigger Download
const blobUrl = URL.createObjectURL(zipBlob); const blobUrl = URL.createObjectURL(zipBlob);
if (chrome.downloads) { if (chrome.downloads) {
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
action: 'DOWNLOAD_BLOB_URL', action: 'DOWNLOAD_BLOB_URL',