From 382645b2d247b1676fbd7be6e163e2a04839e7c9 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 4 Jun 2026 21:05:51 -0500 Subject: [PATCH] =?UTF-8?q?feat(browse):=20Export=20context-menu=20submenu?= =?UTF-8?q?=20(folder=E2=86=92.zip,=20file=E2=86=92other=20formats)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- browse/js/download.js | 29 ++++++++++++++++++++++++++- browse/js/menu-model.js | 43 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/browse/js/download.js b/browse/js/download.js index b489ff7..3ec7e70 100644 --- a/browse/js/download.js +++ b/browse/js/download.js @@ -183,8 +183,35 @@ } } + // 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 + downloadFolder: downloadFolder, + exportFile: exportFile }; })(); diff --git a/browse/js/menu-model.js b/browse/js/menu-model.js index 56bb50a..73366f1 100644 --- a/browse/js/menu-model.js +++ b/browse/js/menu-model.js @@ -46,6 +46,10 @@ function isServer() { return state.source === 'server'; } function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); } function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); } + + // Formats the Export submenu offers for a file (server-side conversion): + // a file of one of these extensions can be exported as the other two. + var EXPORT_FORMATS = ['md', 'docx', 'html']; function cap() { return window.zddc && window.zddc.cap; } function canVerb(node, verb) { @@ -176,6 +180,32 @@ else d.downloadFile(ctx.node); } }, + { + // Export submenu: a folder offers ".zip" (both modes); a md/docx/html + // file offers the OTHER two formats (server-side conversion, so + // server mode only). A zip is already an archive — no Export. + id: 'export', group: 'io', surfaces: ['row'], + label: 'Export', + appliesTo: function (ctx) { + var n = ctx.node; + if (!n || n.virtual) return false; + if (n.isDir) return true; + if (n.isZip) return false; + return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1; + }, + items: function (ctx) { + var n = ctx.node; + var d = window.app.modules.download; + if (!d) return []; + if (n.isDir) { + return [{ label: '.zip', action: function () { d.downloadFolder(n); } }]; + } + var cur = (n.ext || '').toLowerCase(); + return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) { + return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } }; + }); + } + }, // ── create (folder rows + pane; NOT file rows) ── // Create actions are HIDDEN unless the user can create here (the @@ -361,7 +391,7 @@ } function toMenuItem(d, ctx) { - return { + var item = { label: resolve(d.label, ctx), accel: d.accel, danger: d.danger, @@ -370,9 +400,16 @@ disabled: function () { return !resolveBool(d.enabled, ctx, true); }, tooltip: function () { return resolveBool(d.enabled, ctx, true) ? '' : (resolve(d.tooltip, ctx) || ''); - }, - action: function () { if (d.action) d.action(ctx); } + } }; + // A descriptor with `items` becomes a submenu (resolved against the + // captured browse ctx); otherwise it's a normal action row. + if (d.items) { + item.items = function () { return resolve(d.items, ctx); }; + } else { + item.action = function () { if (d.action) d.action(ctx); }; + } + return item; } function project(surface, ctx) {