feat(browse): Export context-menu submenu (folder→.zip, file→other formats)

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>
This commit is contained in:
ZDDC 2026-06-04 21:05:51 -05:00
parent 16d88010a6
commit 382645b2d2
2 changed files with 68 additions and 4 deletions

View file

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

View file

@ -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) {