feat: add Direct Local Folder Save via File System Access API and zero-lag STORE zip mode
This commit is contained in:
+36
-22
@@ -158,40 +158,45 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Card 4: Packaging Formats -->
|
||||
<!-- Card 4: Destination & Output -->
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-group">
|
||||
<span class="step-num">4</span>
|
||||
<h2>Archive Formats</h2>
|
||||
<h2>Destination & Output</h2>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
<input type="checkbox" id="fmtJson" checked>
|
||||
<span class="checkmark"></span>
|
||||
<div class="checkbox-label">
|
||||
<strong>Raw JSON Manifest (<code>manifest.json</code>)</strong>
|
||||
<small>Complete structured metadata for data portability</small>
|
||||
<strong>Unified JSON Archive (<code>activities.json</code>)</strong>
|
||||
<small>Single source of truth containing all structured & raw data</small>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -203,6 +208,15 @@
|
||||
<small>Open in Excel, Numbers, or Google Sheets</small>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
+138
-50
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Single Source of Truth for Data Records: activities.json
|
||||
if (el.fmtJson.checked) {
|
||||
// 1. Data Content Generation
|
||||
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));
|
||||
}
|
||||
const exportJsonStr = 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