ZDDC/browse/js/download.js
ZDDC 3823946d4f feat(browse): unify export formats via one matrix; add PDF to Export menu
The Export context-menu offered only md↔docx↔html (a symmetric set), so PDF
— which the server supports only as md→pdf — was missing. The markdown
editor's DOCX/HTML/PDF buttons hardcoded their own list, so the two could
drift.

Introduce a single source of truth in download.js: EXPORT_MATRIX mirrors
zddc/internal/convert.Convert() exactly (md→docx|html|pdf, docx→md|html,
html→md|docx), exposed as download.exportTargets(ext) + download.convertUrl().
The Export submenu and the editor's buttons both consume it, so a .md file now
offers PDF in the menu and the two surfaces can never disagree. PDF stays
markdown-only (no docx→pdf / html→pdf path exists server-side).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:12:01 -05:00

245 lines
10 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; }
// Canonical document-conversion matrix — mirrors zddc/internal/convert
// Convert(): which target formats a given source extension can be exported
// to. PDF is markdown-only (md→pdf) because the server has no docx→pdf /
// html→pdf path. This is the SINGLE source of truth for both the Export
// context-menu (download.exportTargets) and the markdown editor's
// DOCX/HTML/PDF buttons (preview-markdown.js), so the two never drift.
var EXPORT_MATRIX = {
md: ['docx', 'html', 'pdf'],
docx: ['md', 'html'],
html: ['md', 'docx']
};
// exportTargets returns the formats a file of extension `ext` can be
// exported to (excludes the source format itself), or [] if `ext` is not a
// convertible source. Case-insensitive.
function exportTargets(ext) {
return EXPORT_MATRIX[String(ext || '').toLowerCase()] || [];
}
// convertUrl maps a source path/URL to its sibling virtual-conversion URL
// (foo.md → foo.pdf). zddc-server recognises the sibling-extension pattern
// and converts on the fly. Shared by exportFile and the editor buttons.
function convertUrl(path, fmt) {
return String(path || '').replace(/\.[^./]+$/, '') + '.' + fmt;
}
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 = convertUrl(path, fmt);
var name = convertUrl(node.name, fmt);
events().statusInfo('Exporting ' + name + '…');
downloadUrl(name, url);
setTimeout(function () { events().statusClear(); }, 2500);
}
window.app.modules.download = {
downloadFile: downloadFile,
downloadFolder: downloadFolder,
exportFile: exportFile,
exportTargets: exportTargets,
convertUrl: convertUrl
};
})();