// 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 the virtual ".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 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 ".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 at the virtual ".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 }; })();