ZDDC/browse/js/download.js
ZDDC 167a56dc07 refactor: virtual file extensions for subtree zip + MD conversion
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>
2026-05-14 12:23:37 -05:00

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
};
})();