Add an "Export" item to the row context menu with a submenu: - a folder offers ".zip" (reuses download.downloadFolder; works offline + server) - an md/docx/html file offers the OTHER two formats, each triggering a server-side conversion download via the new download.exportFile (builds the sibling-extension URL and lets the browser pull the converted bytes). File conversion is server-only, so it's hidden in offline (FS) mode; a zip is already an archive and gets no Export. menu-model's toMenuItem now passes a descriptor's `items` through as a submenu (resolved against the captured browse ctx) instead of only emitting action rows. Verified: 11/11 browse Playwright specs pass (incl. menu/context + Download ZIP); a logic harness confirms the per-type submenu contents and that clicks route to download.exportFile / downloadFolder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
9.2 KiB
JavaScript
217 lines
9.2 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.
|
|
// NOTE: an <a download> click is fire-and-forget — a server error
|
|
// (401/403/404/5xx) can't be observed here, so failures surface only as
|
|
// the browser's own download error, not a toast. This is deliberate: the
|
|
// folder path points at zddc-server's streamed virtual "<dir>.zip"
|
|
// endpoint, and buffering it through fetch() to make errors catchable
|
|
// would defeat the streaming (the archive can be arbitrarily large).
|
|
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 + ')');
|
|
// Hand JSZip the File (a Blob, backed by disk) rather than
|
|
// pre-reading every file's arrayBuffer — otherwise the whole
|
|
// tree's raw bytes sit in the JS heap at once before zipping.
|
|
// JSZip reads each Blob lazily during generateAsync.
|
|
var f = await files[i].handle.getFile();
|
|
zip.file(rootHandle.name + '/' + files[i].relPath, f);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Export a file converted to another format. Server-only: builds the
|
|
// sibling-extension URL (foo.docx → foo.md) and lets the browser pull it —
|
|
// zddc-server recognises the virtual path and converts on the fly, emitting
|
|
// Content-Disposition. fmt is a bare extension ("md" | "docx" | "html").
|
|
function exportFile(node, fmt) {
|
|
if (!node || node.isDir) {
|
|
events().statusError('Not a file: ' + (node && node.name));
|
|
return;
|
|
}
|
|
if (state.source !== 'server') {
|
|
events().statusError('Export to .' + fmt + ' needs a server connection');
|
|
return;
|
|
}
|
|
var tree = window.app.modules.tree;
|
|
var path = tree && tree.pathFor ? tree.pathFor(node) : node.url;
|
|
if (!path) {
|
|
events().statusError('No path for ' + node.name);
|
|
return;
|
|
}
|
|
var url = path.replace(/\.[^./]+$/, '') + '.' + fmt;
|
|
var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt;
|
|
events().statusInfo('Exporting ' + name + '…');
|
|
downloadUrl(name, url);
|
|
setTimeout(function () { events().statusClear(); }, 2500);
|
|
}
|
|
|
|
window.app.modules.download = {
|
|
downloadFile: downloadFile,
|
|
downloadFolder: downloadFolder,
|
|
exportFile: exportFile
|
|
};
|
|
})();
|