mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 10:13:51 +00:00
feat(export): Export note works in markdown mode (md, html, docx, pdf)
Some checks are pending
Build and push Joplock image / build-and-push (push) Waiting to run
Some checks are pending
Build and push Joplock image / build-and-push (push) Waiting to run
Export was rendered-mode only: the toolbar button was hidden in markdown mode, the menu hid HTML/DOCX/PDF, the mobile sheet hid Export, and each exporter alerted "only available in rendered mode". Markdown mode now posts the live markdown (format:'markdown'). The HTML and DOCX endpoints render it with renderMarkdown() (PDF already did) before the shared strip-links / inline-images pipeline, so images and attachments come out identical to rendered-mode exports. DOCX without a format keeps the legacy pandoc-native markdown input; rendered mode is unchanged. Tests: tests/exportMarkdownMode.test.js (pandoc stubbed; server pipeline + client routing) and a Playwright test that downloads .md/.html/.docx/.pdf from markdown mode against the real container, plus a mobile-sheet check.
This commit is contained in:
parent
653f480432
commit
b6b0618697
6 changed files with 301 additions and 30 deletions
|
|
@ -32,6 +32,7 @@ Use this guide when working in this repository.
|
|||
- **Auth**: reuses Joplin Server `sessionId` cookie
|
||||
- **DB access**: reads direct from shared Postgres; writes go through stock Joplin Server API
|
||||
- **Exports**: `POST /api/export/{docx,pdf,html}` in `app/routes/api.js` — DOCX and PDF are rendered server-side with `pandoc` (PDF via `weasyprint` plus pandoc's print CSS), HTML is a single self-contained file with inlined theme CSS, base64 images and base64 attachment links. `public/html-docx.js` is the legacy client-side DOCX path.
|
||||
- Exports work in BOTH editor modes. Rendered mode posts TinyMCE HTML (`format:'html'`); markdown mode posts the markdown (`format:'markdown'`, via `_exportSource()`) and all three endpoints render it with `renderMarkdown()` before the shared strip-links / inline-images pipeline, so images and attachments come out the same in either mode. DOCX with no `format` keeps the legacy pandoc-native markdown input. Pinned by `tests/exportMarkdownMode.test.js` (pandoc stubbed) and the real-container download test in `playwright-tests/md-extras.spec.js`.
|
||||
- **Ops surface**: admin user management (`/admin/users`), `pg_dump` backup/restore (`/admin/backups`, `/admin/restore`), break-glass recovery mode (`/recovery`), and login rate limiting — all served by the same sidecar process.
|
||||
- **Script load order** (`app/templates/pages.js`): `htmx` → `turndown` → `codemirror` → `cm-extras` → `prism` → `tinymce` → `hljs` → `html-docx` → inline config → `app.js` (`defer`). `app.js`, `codemirror.min.js` and `cm-extras.js` are cache-busted with `ASSET_VERSION`.
|
||||
|
||||
|
|
|
|||
|
|
@ -971,14 +971,18 @@ const handleExportDocx = async (url, request, response, ctx) => {
|
|||
response.end(JSON.stringify({ error: 'content is required' }));
|
||||
return true;
|
||||
}
|
||||
const inputFormat = format === 'html' ? 'html' : 'markdown';
|
||||
// format 'markdown' (sent by markdown mode) is rendered with the same
|
||||
// renderMarkdown() as rendered mode / the PDF export, so Joplin `:/id`
|
||||
// images and attachment links resolve instead of reaching pandoc raw.
|
||||
// Any other value keeps the legacy pandoc-native markdown input.
|
||||
const renderedFromMarkdown = format === 'markdown';
|
||||
const inputFormat = format === 'html' || renderedFromMarkdown ? 'html' : 'markdown';
|
||||
|
||||
// Pre-process HTML the same way as PDF: strip dead resource links and
|
||||
// inline resource images as base64 data URIs so pandoc can embed them.
|
||||
// Markdown input is left unchanged — pandoc handles it natively.
|
||||
let processedContent = content;
|
||||
let processedContent = renderedFromMarkdown ? renderMarkdown(content) : content;
|
||||
if (inputFormat === 'html') {
|
||||
processedContent = stripResourceLinks(content);
|
||||
processedContent = stripResourceLinks(processedContent);
|
||||
processedContent = await inlineResourceImages(processedContent, auth.user.id, ctx.itemService);
|
||||
}
|
||||
|
||||
|
|
@ -1200,7 +1204,8 @@ const handleExportPdf = async (url, request, response, ctx) => {
|
|||
// ---------------------------------------------------------------------------
|
||||
// POST /api/export/html — single self-contained HTML file: inlined theme CSS,
|
||||
// base64 images, base64 attachment links. No pandoc needed — the source is
|
||||
// already TinyMCE-rendered HTML.
|
||||
// TinyMCE-rendered HTML (rendered mode) or markdown rendered server-side with
|
||||
// renderMarkdown() (markdown mode, format:'markdown').
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const escapeHtmlAttr = value => `${value || ''}`
|
||||
|
|
@ -1219,13 +1224,15 @@ const handleExportHtml = async (url, request, response, ctx) => {
|
|||
return true;
|
||||
}
|
||||
const body = await parseBody(request);
|
||||
const { content, title } = body;
|
||||
if (!content) {
|
||||
const { content: rawContent, title, format } = body;
|
||||
if (!rawContent) {
|
||||
response.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ error: 'content is required' }));
|
||||
return true;
|
||||
}
|
||||
const theme = /^[a-z0-9-]{1,40}$/.test(`${body.theme || ''}`) ? body.theme : 'earth';
|
||||
// Markdown mode sends markdown; render it like rendered mode / PDF do.
|
||||
const content = format === 'markdown' ? renderMarkdown(rawContent) : rawContent;
|
||||
|
||||
const imagedHtml = await inlineResourceImages(content, auth.user.id, ctx.itemService);
|
||||
const inlinedHtml = await inlineResourceLinks(imagedHtml, auth.user.id, ctx.itemService);
|
||||
|
|
|
|||
|
|
@ -222,6 +222,50 @@ test.describe('Markdown mode extras', () => {
|
|||
expect(content).toBe('"\u2b07\ufe0f"');
|
||||
});
|
||||
|
||||
test('Export note works in markdown mode: .md, .html, .docx and .pdf', async ({ page }) => {
|
||||
test.setTimeout(150000);
|
||||
await setDoc(page, '# Export me\n\nBody with **bold** text.\n\n- [x] done\n- [ ] todo\n\n');
|
||||
await dropFile(page, { name: 'export-pic.png', mime: 'image/png', data: IMG_B64 });
|
||||
await expect(page.locator('#editor-panel .cm-jl-img img')).toBeVisible({ timeout: 15000 });
|
||||
await waitForSaved(page);
|
||||
|
||||
const readDownload = async (itemText) => {
|
||||
await page.locator('#editor-panel #export-note-btn').click();
|
||||
const menu = page.locator('#export-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
// Every format is offered while in markdown mode.
|
||||
await expect(menu.locator('button', { hasText: /Markdown|HTML|Word|PDF/ })).toHaveCount(4);
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 90000 }),
|
||||
menu.locator('button', { hasText: itemText }).click(),
|
||||
]);
|
||||
const file = await download.path();
|
||||
return { name: download.suggestedFilename(), data: fs.readFileSync(file) };
|
||||
};
|
||||
|
||||
const md = await readDownload('Markdown');
|
||||
expect(md.name).toMatch(/\.md$/);
|
||||
expect(md.data.toString('utf8')).toContain('# Export me');
|
||||
|
||||
const html = await readDownload('HTML');
|
||||
expect(html.name).toMatch(/\.html$/);
|
||||
const htmlText = html.data.toString('utf8');
|
||||
expect(htmlText).toContain('<h1>Export me</h1>');
|
||||
expect(htmlText).toContain('data:image/png;base64,');
|
||||
expect(htmlText).not.toMatch(/:\/[0-9a-f]{32}/);
|
||||
|
||||
const docx = await readDownload('Word');
|
||||
expect(docx.name).toMatch(/\.docx$/);
|
||||
expect(docx.data.subarray(0, 2).toString()).toBe('PK');
|
||||
expect(docx.data.includes(Buffer.from('word/media/'))).toBe(true); // the image is embedded
|
||||
|
||||
const pdf = await readDownload('PDF');
|
||||
expect(pdf.name).toMatch(/\.pdf$/);
|
||||
expect(pdf.data.subarray(0, 5).toString()).toBe('%PDF-');
|
||||
expect(pdf.data.length).toBeGreaterThan(2000);
|
||||
await page.screenshot({ path: 'test-results/mdx-export.png' });
|
||||
});
|
||||
|
||||
test('clicking a preview selects its source so Delete removes the image', async ({ page }) => {
|
||||
await setDoc(page, 'Top\n\n');
|
||||
await dropFile(page, { name: 'del.png', mime: 'image/png', data: IMG_B64 });
|
||||
|
|
@ -471,6 +515,12 @@ test.describe('Markdown mode extras (mobile shell)', () => {
|
|||
await expect(status).toBeVisible();
|
||||
const sb = await status.boundingBox();
|
||||
expect(sb.y + sb.height).toBeLessThanOrEqual(page.viewportSize().height + 1);
|
||||
// Export is reachable from the mobile editor menu in markdown mode too.
|
||||
await page.locator('#mobile-editor-menu-btn').click();
|
||||
await expect(page.locator('#mobile-ctx-export')).toBeVisible();
|
||||
await page.locator('#mobile-ctx-export').click();
|
||||
await expect(page.locator('#export-menu')).toBeVisible();
|
||||
await expect(page.locator('#export-menu #export-pdf-btn')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/mdx-mobile.png' });
|
||||
} finally {
|
||||
await teardownTestData(page, { titlePrefixes: ['pw-mdx-mobile'] });
|
||||
|
|
|
|||
|
|
@ -481,7 +481,7 @@ function tinyMCESetContent(html){if(_tinymceEditor)_tinymceEditor.setContent(htm
|
|||
function tinyMCESyncToTA(){var ta=getTA();if(ta&&_tinymceEditor){var _synNoteId=_formNoteId(activeEditorForm());if(_tinymceContentNoteId&&_synNoteId&&_tinymceContentNoteId!==_synNoteId){_log('tinyMCESyncToTA skipped: TinyMCE content belongs to another note',_tinymceContentNoteId,_synNoteId);return false}var host=document.getElementById('tinymce-host');if(!host||!host.classList.contains('tinymce-host-visible')){_log('tinyMCESyncToTA skipped: TinyMCE host not visible');return false}var html=_tinymceEditor.getContent();var md=tinymceToMarkdown(html,ta.value);if(ta.value!==md){ta.value=md;ta.dispatchEvent(new Event('input',{bubbles:true}));return true}}return false}
|
||||
function _isMarkdownModeActive(){return _editorMode==='markdown'||_editorMode==='md'}
|
||||
|
||||
/* ---------------- Note export (rendered mode only): MD / HTML / DOCX / PDF ---------------- */
|
||||
/* ---------------- Note export (both modes): MD / HTML / DOCX / PDF ---------------- */
|
||||
function _downloadBlob(blob,filename){
|
||||
var url=URL.createObjectURL(blob);
|
||||
var a=document.createElement('a');
|
||||
|
|
@ -511,11 +511,6 @@ function toggleExportMenu(anchorEl){
|
|||
||document.getElementById('mobile-editor-menu-btn');
|
||||
}
|
||||
if(!menu||!btn)return;
|
||||
var mdMode=_isMarkdownModeActive();
|
||||
['#export-html-btn','#export-docx-btn','#export-pdf-btn'].forEach(function(sel){
|
||||
var b=menu.querySelector(sel);
|
||||
if(b)b.style.display=mdMode?'none':'';
|
||||
});
|
||||
if(!menu.hidden){menu.hidden=true;return}
|
||||
menu.hidden=false;
|
||||
var r=btn.getBoundingClientRect();
|
||||
|
|
@ -541,15 +536,29 @@ document.addEventListener('click',function(e){
|
|||
});
|
||||
window.addEventListener('scroll',closeExportMenu,true);
|
||||
window.addEventListener('resize',closeExportMenu);
|
||||
// What to send to the HTML / DOCX / PDF exporters. Rendered mode sends the
|
||||
// TinyMCE HTML. Markdown mode sends the markdown itself (format:'markdown'); the
|
||||
// server renders it with renderMarkdown() — the same renderer rendered mode loads
|
||||
// its body from — so images, attachments, tables, checkboxes and code blocks come
|
||||
// out identical in both modes.
|
||||
function _exportSource(){
|
||||
if(_isMarkdownModeActive()){
|
||||
cmSyncToTA();
|
||||
var ta=getTA();
|
||||
return {content:ta?ta.value:'',format:'markdown'};
|
||||
}
|
||||
return {content:tinyMCEContent(),format:'html'};
|
||||
}
|
||||
function exportNoteAsMarkdown(){
|
||||
cmSyncToTA();
|
||||
var ta=getTA();
|
||||
var md=ta?ta.value:'';
|
||||
_downloadBlob(new Blob([md],{type:'text/markdown'}),_exportFilenameBase()+'.md');
|
||||
}
|
||||
function exportNoteAsHtml(){
|
||||
if(_isMarkdownModeActive()){alert('HTML export is only available in rendered mode. Switch to rendered mode to export.');return}
|
||||
var html=tinyMCEContent();
|
||||
if(!html){alert('Nothing to export.');return}
|
||||
var src=_exportSource();
|
||||
var html=src.content;
|
||||
if(!html||!String(html).trim()){alert('Nothing to export.');return}
|
||||
var title=document.querySelector('.editor-title')?.textContent||document.getElementById('note-title')?.value||'note';
|
||||
var theme='earth';
|
||||
var classes=(document.body.className||'').split(/\s+/);
|
||||
|
|
@ -558,18 +567,17 @@ function exportNoteAsHtml(){
|
|||
}
|
||||
var btn=document.getElementById('export-note-btn');
|
||||
if(btn){btn.disabled=true;btn.style.opacity='0.5'}
|
||||
fetch('/api/export/html',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:html,title:title,theme:theme})})
|
||||
fetch('/api/export/html',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:html,format:src.format,title:title,theme:theme})})
|
||||
.then(function(r){if(!r.ok)throw new Error('Export failed: '+r.status);return r.blob()})
|
||||
.then(function(blob){var url=URL.createObjectURL(blob);var a=document.createElement('a');a.href=url;a.download=(title.replace(/[^a-zA-Z0-9_-]/g,'_').slice(0,80)||'note')+'.html';document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url)})
|
||||
.catch(function(err){console.error('HTML export failed:',err);alert('HTML export failed: '+err.message)})
|
||||
.finally(function(){if(btn){btn.disabled=false;btn.style.opacity=''}});
|
||||
}
|
||||
function exportNoteAsDocx(){
|
||||
if(_isMarkdownModeActive()){alert('DOCX export is only available in rendered mode. Switch to rendered mode to export.');return}
|
||||
var html=tinyMCEContent();
|
||||
if(!html){alert('Nothing to export.');return}
|
||||
var format='html';
|
||||
var content=html;
|
||||
var src=_exportSource();
|
||||
if(!src.content||!String(src.content).trim()){alert('Nothing to export.');return}
|
||||
var format=src.format;
|
||||
var content=src.content;
|
||||
var title=document.querySelector('.editor-title')?.textContent||document.getElementById('note-title')?.value||'note';
|
||||
var btn=document.getElementById('export-note-btn');
|
||||
if(btn){btn.disabled=true;btn.style.opacity='0.5'}
|
||||
|
|
@ -580,11 +588,10 @@ function exportNoteAsDocx(){
|
|||
.finally(function(){if(btn){btn.disabled=false;btn.style.opacity=''}});
|
||||
}
|
||||
function exportNoteAsPdf(){
|
||||
if(_isMarkdownModeActive()){alert('PDF export is only available in rendered mode. Switch to rendered mode to export.');return}
|
||||
var html=tinyMCEContent();
|
||||
if(!html){alert('Nothing to export.');return}
|
||||
var format='html';
|
||||
var content=html;
|
||||
var src=_exportSource();
|
||||
if(!src.content||!String(src.content).trim()){alert('Nothing to export.');return}
|
||||
var format=src.format;
|
||||
var content=src.content;
|
||||
var title=document.querySelector('.editor-title')?.textContent||document.getElementById('note-title')?.value||'note';
|
||||
var btn=document.getElementById('export-note-btn');
|
||||
if(btn){btn.disabled=true;btn.style.opacity='0.5'}
|
||||
|
|
@ -5195,7 +5202,7 @@ function confirmLogout(event){
|
|||
var moveBtn=document.getElementById('mobile-ctx-move');
|
||||
var delBtn=document.getElementById('mobile-ctx-delete');
|
||||
if(titleEl)titleEl.textContent=noteTitle||'Untitled';
|
||||
if(exportBtn)exportBtn.style.display=(opts.isEditorContext&&!_isMarkdownModeActive())?'':'none';
|
||||
if(exportBtn)exportBtn.style.display=opts.isEditorContext?'':'none';
|
||||
if(metaEl){
|
||||
var mbody=document.getElementById('mobile-editor-body');
|
||||
var metaSrc=mbody?mbody.querySelector('#note-meta'):null;
|
||||
|
|
|
|||
|
|
@ -1502,8 +1502,7 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Export menu (rendered mode only) */
|
||||
.editor-markdown-mode #export-note-btn { display: none; }
|
||||
/* Export menu */
|
||||
.export-menu {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
|
|
|
|||
207
tests/exportMarkdownMode.test.js
Normal file
207
tests/exportMarkdownMode.test.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
'use strict';
|
||||
|
||||
// Export in MARKDOWN mode. Markdown mode sends the note's markdown with
|
||||
// format:'markdown'; the server renders it with renderMarkdown() (the renderer
|
||||
// rendered mode loads its body from) and runs the same image-inlining pipeline.
|
||||
// pandoc / weasyprint are not required: child_process.spawn is stubbed and the
|
||||
// stub records what the exporter would have piped into pandoc.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const childProcess = require('node:child_process');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const spawned = [];
|
||||
childProcess.spawn = (cmd, args) => {
|
||||
const proc = new EventEmitter();
|
||||
let stdin = '';
|
||||
proc.stdin = { write: d => { stdin += d; }, end: () => {} };
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
spawned.push({ cmd, args, stdin: () => stdin });
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('FAKE-OUTPUT'));
|
||||
proc.emit('close', 0);
|
||||
});
|
||||
return proc;
|
||||
};
|
||||
|
||||
// Required AFTER the stub so api.js's `const { spawn } = require('child_process')` picks it up.
|
||||
const { handleExportHtml, handleExportDocx, handleExportPdf } = require('../app/routes/api');
|
||||
|
||||
const RID = 'a1b2c3d4e5f6789012345678abcdef00';
|
||||
const MD = `# Trip report\n\nSee the photo:\n\n\n\nAnd the [spec sheet](:/${RID}).\n\n- [x] done\n- [ ] todo\n\n| a | b |\n|---|---|\n| 1 | 2 |\n`;
|
||||
|
||||
const req = body => {
|
||||
const r = new EventEmitter();
|
||||
r.headers = { 'content-type': 'application/json' };
|
||||
r.method = 'POST';
|
||||
r.setEncoding = () => {};
|
||||
process.nextTick(() => { r.emit('data', JSON.stringify(body)); r.emit('end'); });
|
||||
return r;
|
||||
};
|
||||
const res = () => {
|
||||
const chunks = [];
|
||||
const out = { status: null, headers: null, writeHead(s, h) { out.status = s; out.headers = h; }, end(d) { if (d) chunks.push(d); out.done = true; }, body: () => chunks.map(c => Buffer.isBuffer(c) ? c.toString('latin1') : c).join('') };
|
||||
return out;
|
||||
};
|
||||
const ctx = () => ({
|
||||
authenticatedUser: async () => ({ user: { id: 'u1', sessionId: 's1' } }),
|
||||
itemService: {
|
||||
resourceBlobByUserId: async () => Buffer.from('PNGDATA'),
|
||||
resourceMetaByUserId: async () => ({ mime: 'image/png', filename: 'shot.png' }),
|
||||
},
|
||||
});
|
||||
const wait = async r => { for (let i = 0; i < 50 && !r.done; i++) await new Promise(x => setTimeout(x, 5)); };
|
||||
|
||||
test('HTML export of markdown: rendered, theme + inlined image, no raw :/id left', async () => {
|
||||
const r = res();
|
||||
await handleExportHtml(new URL('http://x/api/export/html'), req({ content: MD, format: 'markdown', title: 'Trip', theme: 'earth' }), r, ctx());
|
||||
assert.equal(r.status, 200);
|
||||
const body = r.body();
|
||||
assert.ok(body.includes('<h1>Trip report</h1>'), 'markdown heading rendered to HTML');
|
||||
assert.ok(body.includes('data:image/png;base64,'), 'image inlined');
|
||||
assert.ok(!body.includes(`:/${RID}`), 'no raw Joplin resource reference leaks into the export');
|
||||
assert.ok(!body.includes(`/resources/${RID}`), 'no authenticated resource URL leaks into the export');
|
||||
assert.ok(/<table/.test(body) && /md-checkbox/.test(body), 'tables and task checkboxes rendered');
|
||||
assert.ok(body.includes('class="theme-earth"'));
|
||||
});
|
||||
|
||||
test('HTML export without format still treats content as HTML (rendered mode unchanged)', async () => {
|
||||
const r = res();
|
||||
await handleExportHtml(new URL('http://x/api/export/html'), req({ content: '<p>**not markdown**</p>', title: 'n' }), r, ctx());
|
||||
assert.ok(r.body().includes('<p>**not markdown**</p>'));
|
||||
});
|
||||
|
||||
test('DOCX export of markdown: rendered to HTML, images inlined, pandoc told html (not markdown)', async () => {
|
||||
spawned.length = 0;
|
||||
const r = res();
|
||||
await handleExportDocx(new URL('http://x/api/export/docx'), req({ content: MD, format: 'markdown', title: 'Trip' }), r, ctx());
|
||||
await wait(r);
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.headers['Content-Disposition'].includes('Trip.docx'));
|
||||
assert.equal(spawned.length, 1);
|
||||
const { args, stdin } = spawned[0];
|
||||
assert.deepEqual(args.slice(0, 4), ['-f', 'html', '-t', 'docx']);
|
||||
const html = stdin();
|
||||
assert.ok(html.includes('<h1>Trip report</h1>'));
|
||||
assert.ok(html.includes('data:image/png;base64,'), 'image embedded so pandoc can put it in the .docx');
|
||||
assert.ok(!html.includes(`:/${RID}`) && !html.includes(`/resources/${RID}`));
|
||||
assert.ok(!/<a\b[^>]*resources\//.test(html), 'dead attachment link anchors stripped');
|
||||
});
|
||||
|
||||
test('DOCX export keeps html input and the legacy pandoc-markdown input for other callers', async () => {
|
||||
spawned.length = 0;
|
||||
let r = res();
|
||||
await handleExportDocx(new URL('http://x/api/export/docx'), req({ content: '<p>hi</p>', format: 'html', title: 'n' }), r, ctx());
|
||||
await wait(r);
|
||||
assert.deepEqual(spawned[0].args.slice(0, 2), ['-f', 'html']);
|
||||
assert.equal(spawned[0].stdin(), '<p>hi</p>');
|
||||
|
||||
spawned.length = 0;
|
||||
r = res();
|
||||
await handleExportDocx(new URL('http://x/api/export/docx'), req({ content: '# hi', title: 'n' }), r, ctx());
|
||||
await wait(r);
|
||||
assert.deepEqual(spawned[0].args.slice(0, 2), ['-f', 'markdown']);
|
||||
assert.equal(spawned[0].stdin(), '# hi');
|
||||
});
|
||||
|
||||
test('PDF export of markdown: rendered, images inlined, handed to pandoc as html', async () => {
|
||||
spawned.length = 0;
|
||||
const r = res();
|
||||
await handleExportPdf(new URL('http://x/api/export/pdf'), req({ content: MD, format: 'markdown', title: 'Trip' }), r, ctx());
|
||||
await wait(r);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(spawned.length, 1);
|
||||
assert.deepEqual(spawned[0].args.slice(0, 4), ['-f', 'html', '-t', 'pdf']);
|
||||
const html = spawned[0].stdin();
|
||||
assert.ok(html.includes('<h1>Trip report</h1>') && html.includes('data:image/png;base64,'));
|
||||
assert.ok(!html.includes(`:/${RID}`) && !html.includes(`/resources/${RID}`));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client: markdown mode must reach the exporters (it used to alert "only
|
||||
// available in rendered mode"), sending markdown from the live CM document.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
|
||||
function extractFn(name) {
|
||||
const start = appSrc.indexOf(`function ${name}(`);
|
||||
assert.ok(start !== -1, `${name} not found`);
|
||||
let depth = 0;
|
||||
for (let i = start; i < appSrc.length; i++) {
|
||||
if (appSrc[i] === '{') depth++;
|
||||
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
|
||||
}
|
||||
throw new Error('unbalanced');
|
||||
}
|
||||
|
||||
function clientCtx({ mode, cmDoc, tinyHtml }) {
|
||||
const alerts = [];
|
||||
const posts = [];
|
||||
const ta = { value: '' };
|
||||
const ctx = vm.createContext({
|
||||
alert: m => alerts.push(m),
|
||||
Blob: class { constructor(parts, o) { this.parts = parts; this.type = o && o.type; } },
|
||||
URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} },
|
||||
setTimeout: fn => fn(),
|
||||
console,
|
||||
Promise,
|
||||
_editorMode: mode,
|
||||
document: {
|
||||
body: { className: 'theme-nord x', appendChild() {}, removeChild() {} },
|
||||
querySelector: sel => (sel === '.editor-title' ? { textContent: 'My Note' } : null),
|
||||
getElementById: () => null,
|
||||
createElement: () => ({ click() {}, remove() {} }),
|
||||
},
|
||||
fetch: (url, opts) => { posts.push({ url, body: JSON.parse(opts.body) }); return Promise.resolve({ ok: true, blob: () => Promise.resolve({}) }); },
|
||||
cmSyncToTA: () => { ta.value = cmDoc; },
|
||||
getTA: () => ta,
|
||||
tinyMCEContent: () => tinyHtml || '',
|
||||
});
|
||||
vm.runInContext('function queryActiveEditor(){return null}', ctx);
|
||||
for (const fn of ['_isMarkdownModeActive', '_exportSource', 'exportNoteAsHtml', 'exportNoteAsDocx', 'exportNoteAsPdf']) vm.runInContext(extractFn(fn), ctx);
|
||||
return { ctx, alerts, posts };
|
||||
}
|
||||
const flush = () => new Promise(r => setImmediate(r));
|
||||
|
||||
test('client: every export format works in markdown mode and sends the markdown', async () => {
|
||||
const { ctx, alerts, posts } = clientCtx({ mode: 'markdown', cmDoc: MD });
|
||||
vm.runInContext('exportNoteAsHtml();exportNoteAsDocx();exportNoteAsPdf();', ctx);
|
||||
await flush();
|
||||
assert.deepEqual(alerts, [], 'no "only available in rendered mode" alerts');
|
||||
assert.deepEqual(posts.map(p => p.url), ['/api/export/html', '/api/export/docx', '/api/export/pdf']);
|
||||
for (const p of posts) {
|
||||
assert.equal(p.body.format, 'markdown');
|
||||
assert.equal(p.body.content, MD);
|
||||
assert.equal(p.body.title, 'My Note');
|
||||
}
|
||||
assert.equal(posts[0].body.theme, 'nord');
|
||||
});
|
||||
|
||||
test('client: rendered mode still sends TinyMCE html', async () => {
|
||||
const { ctx, alerts, posts } = clientCtx({ mode: 'rich', cmDoc: 'IGNORED', tinyHtml: '<p>rendered</p>' });
|
||||
vm.runInContext('exportNoteAsHtml();exportNoteAsDocx();exportNoteAsPdf();', ctx);
|
||||
await flush();
|
||||
assert.deepEqual(alerts, []);
|
||||
for (const p of posts) { assert.equal(p.body.format, 'html'); assert.equal(p.body.content, '<p>rendered</p>'); }
|
||||
});
|
||||
|
||||
test('client: an empty note says "Nothing to export" instead of posting', async () => {
|
||||
const { ctx, alerts, posts } = clientCtx({ mode: 'markdown', cmDoc: ' \n' });
|
||||
vm.runInContext('exportNoteAsHtml();exportNoteAsDocx();exportNoteAsPdf();', ctx);
|
||||
await flush();
|
||||
assert.equal(posts.length, 0);
|
||||
assert.deepEqual(alerts, ['Nothing to export.', 'Nothing to export.', 'Nothing to export.']);
|
||||
});
|
||||
|
||||
test('export entry points are visible in markdown mode (toolbar button, menu items, mobile sheet)', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '../public/styles.css'), 'utf8');
|
||||
assert.ok(!/\.editor-markdown-mode\s+#export-note-btn\s*\{\s*display:\s*none/.test(css), 'toolbar export button must not be hidden in markdown mode');
|
||||
const toggle = extractFn('toggleExportMenu');
|
||||
assert.ok(!toggle.includes('_isMarkdownModeActive'), 'the export menu must offer HTML/DOCX/PDF in markdown mode too');
|
||||
assert.ok(!/mobile-ctx-export'\);[\s\S]{0,400}_isMarkdownModeActive/.test(appSrc), 'mobile export entry must not be gated on rendered mode');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue