feat: add Direct Local Folder Save via File System Access API and zero-lag STORE zip mode
This commit is contained in:
+144
-56
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user