A "⤓ Download (zip)" button in the browse toolbar (shown once a
directory is loaded) downloads the directory you're currently
viewing — and everything under it you're allowed to see — as a single
.zip. Navigate into a subfolder first to grab just that subtree.
- Server mode: an <a download> at "<currentPath>?zip=1" — zddc-server
streams the ACL-filtered zip (see the previous commit), nothing held
in the browser.
- Offline (file://) mode: new browse/js/download.js walks the picked
folder with the FS-Access API in two passes — metadata first (so it
can confirm() before loading >~2000 files / ~500 MB into memory),
then bytes — bundles with the already-vendored JSZip, and triggers a
blob download. Hidden entries (".":/"_"-prefixed) are skipped, the
zip's top level is "<folderName>/…" so it unpacks tidily, and the
status bar shows progress.
Wired in browse/js/events.js (button click + show/hide alongside the
refresh button); concatenated into browse/build.sh; ARCHITECTURE.md +
AGENTS.md note the ?zip=1 endpoint and the button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
5.5 KiB
JavaScript
141 lines
5.5 KiB
JavaScript
// download.js — "Download (zip)" for the currently-viewed directory.
|
|
//
|
|
// Server mode: just point an <a download> at "<currentPath>?zip=1" —
|
|
// zddc-server streams an ACL-filtered .zip of the subtree, so nothing
|
|
// is held in the browser.
|
|
//
|
|
// FS-API (offline) mode: there's no server, so we walk the picked
|
|
// folder ourselves, bundle every file with JSZip, and download the
|
|
// blob. A two-pass walk (metadata first, then bytes) lets us warn
|
|
// before loading a very large tree into memory.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
|
|
// Soft thresholds for the offline bundle: above either, confirm()
|
|
// before loading everything into memory.
|
|
var WARN_FILE_COUNT = 2000;
|
|
var WARN_TOTAL_BYTES = 500 * 1024 * 1024;
|
|
|
|
function events() { return window.app.modules.events; }
|
|
|
|
function isHiddenName(name) {
|
|
return name.length === 0 || name[0] === '.' || name[0] === '_';
|
|
}
|
|
|
|
function fmtMB(bytes) { return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }
|
|
|
|
// Trigger a browser download of a Blob (revokes the object URL after).
|
|
function downloadBlob(filename, blob) {
|
|
var a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
setTimeout(function () {
|
|
URL.revokeObjectURL(a.href);
|
|
a.remove();
|
|
}, 0);
|
|
}
|
|
|
|
// Trigger a download from a same-origin server URL via Content-Disposition.
|
|
function downloadUrl(filename, url) {
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename; // hint; the server's Content-Disposition wins
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
setTimeout(function () { a.remove(); }, 0);
|
|
}
|
|
|
|
// Recursively collect every (non-hidden) file under dirHandle into
|
|
// `out` as { relPath, handle, size }, accumulating into `tally`.
|
|
// relPrefix is the slash-terminated path within the picked root
|
|
// ("" at the root).
|
|
async function collectFiles(dirHandle, relPrefix, out, tally) {
|
|
for await (var pair of dirHandle.entries()) {
|
|
var name = pair[0];
|
|
var handle = pair[1];
|
|
if (isHiddenName(name)) continue;
|
|
if (handle.kind === 'directory') {
|
|
await collectFiles(handle, relPrefix + name + '/', out, tally);
|
|
} else {
|
|
var size = 0;
|
|
try {
|
|
var f = await handle.getFile();
|
|
size = f.size || 0;
|
|
} catch (_e) { /* permission lost — count it as 0 */ }
|
|
out.push({ relPath: relPrefix + name, handle: handle, size: size });
|
|
tally.count++;
|
|
tally.bytes += size;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function downloadFsSubtree(rootHandle) {
|
|
var ev = events();
|
|
ev.statusInfo('Scanning ' + rootHandle.name + '…');
|
|
var files = [];
|
|
var tally = { count: 0, bytes: 0 };
|
|
await collectFiles(rootHandle, '', files, tally);
|
|
if (files.length === 0) {
|
|
ev.statusInfo(rootHandle.name + ' is empty — nothing to download.');
|
|
return;
|
|
}
|
|
if (tally.count > WARN_FILE_COUNT || tally.bytes > WARN_TOTAL_BYTES) {
|
|
var ok = window.confirm(
|
|
'This folder has ' + tally.count + ' files (~' + fmtMB(tally.bytes) + ').\n\n'
|
|
+ 'Building the zip loads them all into memory — it may be slow or crash the tab.\n\n'
|
|
+ 'Continue?');
|
|
if (!ok) { ev.statusClear(); return; }
|
|
}
|
|
var zip = new window.JSZip();
|
|
for (var i = 0; i < files.length; i++) {
|
|
ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')');
|
|
var f = await files[i].handle.getFile();
|
|
var buf = await f.arrayBuffer();
|
|
zip.file(rootHandle.name + '/' + files[i].relPath, buf);
|
|
}
|
|
ev.statusInfo('Generating ' + rootHandle.name + '.zip…');
|
|
var blob = await zip.generateAsync({ type: 'blob' });
|
|
downloadBlob(rootHandle.name + '.zip', blob);
|
|
ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)');
|
|
}
|
|
|
|
function downloadServerSubtree() {
|
|
var dir = (state.currentPath || '/').replace(/\/$/, '');
|
|
var name = (dir.split('/').filter(Boolean).pop()) || 'download';
|
|
events().statusInfo('Preparing ' + name + '.zip…');
|
|
downloadUrl(name + '.zip', dir + '/?zip=1');
|
|
// The browser owns the download from here; clear the hint shortly.
|
|
setTimeout(function () { events().statusClear(); }, 2500);
|
|
}
|
|
|
|
var busy = false;
|
|
|
|
async function downloadCurrentSubtree() {
|
|
if (busy) return;
|
|
var btn = document.getElementById('downloadZipBtn');
|
|
busy = true;
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
if (state.source === 'server') {
|
|
downloadServerSubtree();
|
|
} else if (state.source === 'fs' && state.rootHandle) {
|
|
await downloadFsSubtree(state.rootHandle);
|
|
} else {
|
|
events().statusError('Nothing to download — open a directory first.');
|
|
}
|
|
} catch (e) {
|
|
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
|
} finally {
|
|
busy = false;
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
window.app.modules.download = {
|
|
downloadCurrentSubtree: downloadCurrentSubtree
|
|
};
|
|
})();
|