diff --git a/browse/js/download.js b/browse/js/download.js index 3ec7e70..aa2af0f 100644 --- a/browse/js/download.js +++ b/browse/js/download.js @@ -24,6 +24,32 @@ 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] === '_'; } @@ -202,8 +228,8 @@ events().statusError('No path for ' + node.name); return; } - var url = path.replace(/\.[^./]+$/, '') + '.' + fmt; - var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt; + var url = convertUrl(path, fmt); + var name = convertUrl(node.name, fmt); events().statusInfo('Exporting ' + name + '…'); downloadUrl(name, url); setTimeout(function () { events().statusClear(); }, 2500); @@ -212,6 +238,8 @@ window.app.modules.download = { downloadFile: downloadFile, downloadFolder: downloadFolder, - exportFile: exportFile + exportFile: exportFile, + exportTargets: exportTargets, + convertUrl: convertUrl }; })(); diff --git a/browse/js/menu-model.js b/browse/js/menu-model.js index 73366f1..1935300 100644 --- a/browse/js/menu-model.js +++ b/browse/js/menu-model.js @@ -47,9 +47,16 @@ 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']; + // The Export submenu's convertible-format set comes from the download + // module's canonical matrix (download.exportTargets), which mirrors the + // server's conversion matrix — the single source of truth shared with the + // markdown editor's DOCX/HTML/PDF buttons. exportTargets(ext) returns the + // target formats for a source extension (e.g. md → docx, html, pdf), or [] + // when the extension isn't a convertible source. + function exportTargets(ext) { + var d = window.app.modules.download; + return (d && d.exportTargets) ? d.exportTargets(ext) : []; + } function cap() { return window.zddc && window.zddc.cap; } function canVerb(node, verb) { @@ -181,9 +188,10 @@ } }, { - // 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. + // Export submenu: a folder offers ".zip" (both modes); a convertible + // file (md/docx/html) offers its server-side conversion targets — + // md → docx/html/pdf, docx → md/html, html → md/docx (server mode + // only). A zip is already an archive — no Export. id: 'export', group: 'io', surfaces: ['row'], label: 'Export', appliesTo: function (ctx) { @@ -191,7 +199,7 @@ 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; + return isServer() && exportTargets(n.ext).length > 0; }, items: function (ctx) { var n = ctx.node; @@ -200,8 +208,8 @@ 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) { + // exportTargets already excludes the source format. + return exportTargets(n.ext).map(function (fmt) { return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } }; }); } diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index 778eacb..84e98d1 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -465,11 +465,18 @@ // and routes through ServeConverted. Cleaner than the // old `?convert=` query form — right-clicking the link // gives a sensible "Save as .docx" prompt. - var mdUrlBase = node.url.replace(/\.md$/i, ''); - ['docx', 'html', 'pdf'].forEach(function (fmt) { + // + // Format set + URL come from the download module's canonical + // conversion matrix (download.exportTargets / convertUrl) — the + // SAME source of truth the Export context-menu uses, so the + // editor's buttons and the menu never offer different formats. + var dl = window.app.modules.download; + var mdTargets = (dl && dl.exportTargets) ? dl.exportTargets('md') : ['docx', 'html', 'pdf']; + mdTargets.forEach(function (fmt) { var a = document.createElement('a'); a.className = 'btn btn-sm btn-secondary md-shell__download'; - a.href = mdUrlBase + '.' + fmt; + a.href = (dl && dl.convertUrl) ? dl.convertUrl(node.url, fmt) + : node.url.replace(/\.md$/i, '') + '.' + fmt; // target=_blank: clicks open in a new tab. The server // sends Content-Disposition: inline, so the new tab // either renders (HTML → web page; PDF → browser's