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:
parent
16d88010a6
commit
382645b2d2
2 changed files with 68 additions and 4 deletions
|
|
@ -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 = {
|
window.app.modules.download = {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
downloadFolder: downloadFolder
|
downloadFolder: downloadFolder,
|
||||||
|
exportFile: exportFile
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@
|
||||||
function isServer() { return state.source === 'server'; }
|
function isServer() { return state.source === 'server'; }
|
||||||
function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); }
|
function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); }
|
||||||
function appliesToFile(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 cap() { return window.zddc && window.zddc.cap; }
|
||||||
|
|
||||||
function canVerb(node, verb) {
|
function canVerb(node, verb) {
|
||||||
|
|
@ -176,6 +180,32 @@
|
||||||
else d.downloadFile(ctx.node);
|
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 (folder rows + pane; NOT file rows) ──
|
||||||
// Create actions are HIDDEN unless the user can create here (the
|
// Create actions are HIDDEN unless the user can create here (the
|
||||||
|
|
@ -361,7 +391,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMenuItem(d, ctx) {
|
function toMenuItem(d, ctx) {
|
||||||
return {
|
var item = {
|
||||||
label: resolve(d.label, ctx),
|
label: resolve(d.label, ctx),
|
||||||
accel: d.accel,
|
accel: d.accel,
|
||||||
danger: d.danger,
|
danger: d.danger,
|
||||||
|
|
@ -370,9 +400,16 @@
|
||||||
disabled: function () { return !resolveBool(d.enabled, ctx, true); },
|
disabled: function () { return !resolveBool(d.enabled, ctx, true); },
|
||||||
tooltip: function () {
|
tooltip: function () {
|
||||||
return resolveBool(d.enabled, ctx, true) ? '' : (resolve(d.tooltip, ctx) || '');
|
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) {
|
function project(surface, ctx) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue