Replace `?zip=1` / `?convert=docx|html|pdf` query forms with path-suffix URLs that look like ordinary files. `<dir>.zip` and `<file>.docx` / `.html` / `.pdf` are virtual files served by the dispatcher when stat fails at the requested path AND the corresponding base resource exists: GET /Project-1/archive.zip ← if archive/ is a real directory GET /Project-1/notes.docx ← if notes.md exists Real on-disk files always win — a genuine archive.zip in the tree serves its bytes normally. The virtual forms only fire when nothing real is there. Why: the URL form lets clients emit plain <a href> without query- string handling; `curl -O` writes a sensible filename; mirror tools pick up the path through normal recursion; the protocol surface becomes "every URL is a file". Bash + filesystem mental model. Server: - New helpers handler.RecognizeVirtualSubtreeZip / RecognizeVirtualConvert (in subtreezip.go and converthandler.go). - Dispatcher's stat-fails branch checks them between IsDefaultMdlSpec and MatchAppHTML. ACL is enforced on the base resource (the source directory for zip, the .md source for convert). - Three legacy query-form branches removed from main.go. Client: - browse/js/download.js: `dir + '.zip'` instead of `dir + '/?zip=1'`. - browse/js/preview-markdown.js: convert anchor hrefs become `<mdUrl-minus-.md>.<fmt>` instead of `<mdUrl>?convert=<fmt>`. - shared/zddc-source.js downloadConverted: same transform. Tests: subtreezip_test.go test URLs cosmetically updated to the new shape (the handler is exercised directly, so the URL is metadata only, but the test reads better). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
7.3 KiB
JavaScript
181 lines
7.3 KiB
JavaScript
// 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 <a download> at the virtual "<node-path>.zip"
|
|
// URL — zddc-server recognises the suffix and 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. Server mode points
|
|
// an <a download> at the virtual "<node-path>.zip" URL (the
|
|
// dispatcher recognises the suffix and streams the subtree). 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');
|
|
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
|
|
};
|
|
})();
|