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>
|
</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
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user