// download.js — per-node downloads, surfaced through the tree's // right-click menu (downloadFile / downloadFolder). // // downloadFile: a single file. Server mode lets the browser pull // node.url (zddc-server emits Content-Disposition); FS-API mode // reads bytes through the file handle and blob-downloads. // // downloadFolder: an arbitrary directory node as a .zip. Server // mode points an at "/?zip=1" so zddc-server // streams an ACL-filtered archive without buffering on the client. // FS-API mode walks the picked handle in two passes — metadata // first, then bytes — so we can 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)'); } var busy = false; // Download a single file node. Server mode: rely on the node's // own URL (the server emits Content-Disposition). FS mode: read // bytes through the handle and trigger a blob download. Works // for ordinary files, for .zip members (the loader sets node.url // for zip members in server mode and a ZipFileHandle offline), // and for the .zip file itself. async function downloadFile(node) { if (busy) return; if (!node || node.isDir) { events().statusError('Not a file: ' + (node && node.name)); return; } busy = true; try { if (node.url) { events().statusInfo('Downloading ' + node.name + '…'); downloadUrl(node.name, node.url); setTimeout(function () { events().statusClear(); }, 2500); } else if (node.handle && typeof node.handle.getFile === 'function') { events().statusInfo('Preparing ' + node.name + '…'); var f = await node.handle.getFile(); var blob = new Blob([await f.arrayBuffer()]); downloadBlob(node.name, blob); events().statusInfo('Downloaded ' + node.name); } else { events().statusError('No download path for ' + node.name); } } catch (e) { events().statusError('Download failed: ' + (e && e.message ? e.message : e)); } finally { busy = false; } } // Download an arbitrary folder node as a .zip — same dispatch as // downloadCurrentSubtree but scoped to the picked node instead of // state.currentPath / state.rootHandle. Server mode hits // "/?zip=1"; FS mode walks the directory handle. async function downloadFolder(node) { if (busy) return; if (!node || !node.isDir) { events().statusError('Not a folder: ' + (node && node.name)); return; } busy = true; try { if (state.source === 'server') { var tree = window.app.modules.tree; var dir = tree.pathFor(node).replace(/\/$/, ''); events().statusInfo('Preparing ' + node.name + '.zip…'); downloadUrl(node.name + '.zip', dir + '/?zip=1'); setTimeout(function () { events().statusClear(); }, 2500); } else if (state.source === 'fs' && node.handle && node.handle.kind === 'directory') { await downloadFsSubtree(node.handle); } else { events().statusError('Cannot download ' + node.name); } } catch (e) { events().statusError('Download failed: ' + (e && e.message ? e.message : e)); } finally { busy = false; } } window.app.modules.download = { downloadFile: downloadFile, downloadFolder: downloadFolder }; })();