diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index f0c044f..b0219c9 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -9,6 +9,7 @@ import { Post, Put, Req, + Res, } from '@nestjs/common'; import { CreatePageInput, @@ -20,6 +21,7 @@ import { savePageStateInputSchema, updatePageInputSchema, } from '@dorfteich/shared'; +import type { Response } from 'express'; import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; @@ -49,6 +51,19 @@ export class PagesController { return this.pages.getState(request.user!, id); } + /** Markdown export (issue #30) — downloads `.md`. */ + @Get('pages/:id/export/markdown') + async exportMarkdown( + @Param('id') id: string, + @Req() request: AuthedRequest, + @Res({ passthrough: true }) response: Response, + ): Promise { + const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id); + response.set('Content-Type', 'text/markdown; charset=utf-8'); + response.set('Content-Disposition', `attachment; filename="${slug}.md"`); + return markdown; + } + /** Resolves the pond-slug + page-slug pair the `/p/:pondSlug/:pageSlug` route * navigates to (issue #25); the pond id must already be known to the caller * (e.g. from `GET /ponds/:slug`). */ diff --git a/apps/api/src/pages/pages.e2e.db.test.ts b/apps/api/src/pages/pages.e2e.db.test.ts index 25e3565..d5f785b 100644 --- a/apps/api/src/pages/pages.e2e.db.test.ts +++ b/apps/api/src/pages/pages.e2e.db.test.ts @@ -147,6 +147,34 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => { expect(cache.html).toBe(`

hello from ${suffix}

`); }); + it('exports the page as a downloadable Markdown file (issue #30)', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Export Me ${suffix}` }) + .expect(201); + await api() + .put(`/api/v1/pages/${created.body.id}/state`) + .set('Cookie', ownerCookie) + .send({ state: stateWithText(`markdown export ${suffix}`) }) + .expect(200); + + const res = await api() + .get(`/api/v1/pages/${created.body.id}/export/markdown`) + .set('Cookie', ownerCookie) + .expect(200); + expect(res.headers['content-type']).toContain('text/markdown'); + expect(res.headers['content-disposition']).toBe( + `attachment; filename="${created.body.slug}.md"`, + ); + expect(res.text).toBe(`markdown export ${suffix}`); + + await api() + .get(`/api/v1/pages/${created.body.id}/export/markdown`) + .set('Cookie', outsiderCookie) + .expect(404); + }); + it('rejects state saves beyond the document size limit', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 1804818..b523fcf 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -230,6 +230,16 @@ export class PagesService { return this.viewOf(updated); } + /** Markdown export (issue #30) — serves the already-derived + * `page_content_cache.markdown` (refreshed on every state save, #23) + * rather than re-decoding the Yjs state, so export always matches what + * the app itself considers the page's current Markdown representation. */ + async exportMarkdown(user: User, id: string): Promise<{ slug: string; markdown: string }> { + const page = await this.findVisiblePage(user, id); + const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); + return { slug: page.slug, markdown: cache?.markdown ?? '' }; + } + async softDelete(user: User, id: string): Promise { const page = await this.findModifiablePage(user, id); await this.prisma.page.update({ diff --git a/apps/web/e2e/markdown.spec.ts b/apps/web/e2e/markdown.spec.ts new file mode 100644 index 0000000..6312d96 --- /dev/null +++ b/apps/web/e2e/markdown.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Markdown copy/paste/export pack (issue #30). Runs against the local dev + * stack (api + web); no Mailpit needed. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +async function createPage( + context: Awaited>, + title: string, +): Promise<{ pondSlug: string; pageSlug: string; pageId: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title }, + }); + const page = await created.json(); + return { pondSlug: pond.slug, pageSlug: page.slug, pageId: page.id }; +} + +async function enterEditMode(page: Page): Promise { + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); +} + +test('copying a heading+list selection yields Markdown on the clipboard', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const { pondSlug, pageSlug } = await createPage(context, `E2E MD Copy ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + await page.locator('.ProseMirror').click(); + await page.keyboard.type('# Title'); + await page.keyboard.press('Enter'); + await page.keyboard.type('one'); + await page.keyboard.press('Enter'); + await page.keyboard.type('two'); + + // Turn the second/third lines into a bullet list, then copy everything. + await page.keyboard.press('Shift+Home'); + await page.keyboard.press('Shift+ArrowUp'); + await page.getByRole('button', { name: /bullet list|aufzählungsliste/i }).click(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.press('ControlOrMeta+c'); + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toContain('- one'); + expect(clipboardText).toContain('- two'); + + await context.close(); +}); + +test('pasting a Markdown document into an empty page recreates structure', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E MD Paste ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + await page.locator('.ProseMirror').click(); + + await page.evaluate(() => { + const el = document.querySelector('.ProseMirror'); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', '# Welcome\n\n- alpha\n- beta\n\n1. first\n2. second\n'); + el!.dispatchEvent( + new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }), + ); + }); + + const content = page.locator('.ProseMirror'); + await expect(content.locator('h1')).toHaveText('Welcome'); + await expect(content.locator('ul li')).toHaveCount(2); + await expect(content.locator('ol li')).toHaveCount(2); + + await context.close(); +}); + +test('a plain-text paste is not mangled into rich structure', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E MD Plain ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + await page.locator('.ProseMirror').click(); + + const plainText = "Just a *reminder* to buy milk - don't forget!"; + await page.evaluate((text) => { + const el = document.querySelector('.ProseMirror'); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', text); + el!.dispatchEvent( + new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }), + ); + }, plainText); + + const content = page.locator('.ProseMirror'); + await expect(content).toContainText(plainText); + await expect(content.locator('ul, ol, h1, h2, h3, h4')).toHaveCount(0); + + await context.close(); +}); + +test('page menu downloads the page as Markdown matching its content', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + await page.locator('.ProseMirror').click(); + await page.keyboard.type('export me please'); + await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 }); + + const exported = await context.request.get(`/api/v1/pages/${pageId}/export/markdown`); + expect(exported.headers()['content-disposition']).toContain(`${pageSlug}.md`); + expect(await exported.text()).toBe('export me please'); + + await context.close(); +}); diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index d158195..d8704c1 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -1,5 +1,6 @@ import type { AnyExtension } from '@tiptap/core'; +import { MarkdownClipboard } from './markdown-clipboard'; import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks'; import { Image } from './nodes/image'; import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists'; @@ -45,4 +46,5 @@ export const documentExtensions: AnyExtension[] = [ CodeMark, Strikethrough, LinkMark, + MarkdownClipboard, ]; diff --git a/apps/web/src/editor/markdown-clipboard.test.ts b/apps/web/src/editor/markdown-clipboard.test.ts new file mode 100644 index 0000000..5159a12 --- /dev/null +++ b/apps/web/src/editor/markdown-clipboard.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { looksLikeMarkdown } from './markdown-clipboard'; + +describe('looksLikeMarkdown (issue #30)', () => { + it('recognizes a heading + list document', () => { + expect(looksLikeMarkdown('# Title\n\n- one\n- two')).toBe(true); + }); + + it('recognizes a fenced code block on its own', () => { + expect(looksLikeMarkdown('```\nconst x = 1;\n```')).toBe(true); + }); + + it('recognizes an ordered list and a table', () => { + expect(looksLikeMarkdown('1. first\n2. second')).toBe(true); + expect(looksLikeMarkdown('| a | b |\n| --- | --- |\n| 1 | 2 |')).toBe(true); + }); + + it('does not mangle plain prose that merely contains markdown-ish characters', () => { + expect(looksLikeMarkdown("Just a *reminder* to buy milk - don't forget!")).toBe(false); + expect(looksLikeMarkdown('Meeting at 3pm. Bring #2 pencils and a laptop.')).toBe(false); + expect(looksLikeMarkdown('Rated 5/5 - would recommend.')).toBe(false); + }); + + it('requires at least two signals for a lone weak marker', () => { + expect(looksLikeMarkdown('# Just one heading, nothing else')).toBe(false); + }); + + it('treats empty or whitespace-only text as not markdown', () => { + expect(looksLikeMarkdown('')).toBe(false); + expect(looksLikeMarkdown(' \n ')).toBe(false); + }); +}); diff --git a/apps/web/src/editor/markdown-clipboard.ts b/apps/web/src/editor/markdown-clipboard.ts new file mode 100644 index 0000000..af78370 --- /dev/null +++ b/apps/web/src/editor/markdown-clipboard.ts @@ -0,0 +1,94 @@ +import { docToMarkdown, editorSchema, markdownToDoc } from '@dorfteich/shared'; +import { Extension } from '@tiptap/core'; +import { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { Plugin } from '@tiptap/pm/state'; + +const MARKDOWN_LINE_PATTERNS = [ + /^#{1,6}\s+\S/, // heading + /^[-*+]\s+\S/, // bullet list item + /^\d+\.\s+\S/, // ordered list item + /^>\s*\S/, // blockquote + /^\|.+\|\s*$/, // table row +]; + +/** + * Conservative "does this look like Markdown, not prose" heuristic (issue + * #30). A single matching line is too weak a signal on its own — prose + * often starts a line with a hyphen, a number, or a `>` — so two or more + * are required, except a fenced code block, which is unambiguous by itself. + */ +export function looksLikeMarkdown(text: string): boolean { + if (text.includes('```')) return true; + const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0); + const matches = lines.filter((line) => + MARKDOWN_LINE_PATTERNS.some((pattern) => pattern.test(line)), + ); + return matches.length >= 2; +} + +/** + * Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying + * puts Markdown on `text/plain` alongside the browser's own HTML (so + * pasting into a plain-text destination yields Markdown, not a naive text + * dump), and pasting text that looks like a Markdown document converts it + * to rich nodes instead of one flat paragraph. Clipboard content that + * carries real HTML (anything copied from a rich-text source, including + * Dorfteich itself) is left entirely to ProseMirror's own HTML-based + * paste — the heuristic only ever applies to plain text. + */ +export const MarkdownClipboard = Extension.create({ + name: 'markdownClipboard', + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + clipboardTextSerializer(slice) { + try { + // Mirror image of the `handlePaste` re-hydration below: the + // selected slice's nodes belong to the live view's own schema + // instance, not the canonical `editorSchema` from + // packages/shared — wrapping the fragment directly in an + // `editorSchema` doc node fails schema-identity validation + // (`RangeError: Invalid content for node doc`), so it has to + // go through JSON first. + const doc = ProseMirrorNode.fromJSON(editorSchema, { + type: 'doc', + content: slice.content.toJSON() ?? [], + }); + return docToMarkdown(doc); + } catch { + // Falls back to a plain-text join rather than ever throwing + // out of a copy/cut — worst case, the clipboard just isn't + // Markdown-formatted. + return slice.content.textBetween(0, slice.content.size, '\n\n', ' '); + } + }, + handlePaste(view, event) { + const html = event.clipboardData?.getData('text/html'); + if (html && html.trim() !== '') return false; + const text = event.clipboardData?.getData('text/plain'); + if (!text || !looksLikeMarkdown(text)) return false; + + let doc: ProseMirrorNode; + try { + // `markdownToDoc` builds nodes against the canonical schema + // instance from packages/shared, but TipTap builds its own + // separate (structurally identical) `Schema` object for the + // live editor (see spec-utils.ts) — `tr.replaceWith` silently + // drops content whose node types aren't `===` the state's own + // schema, so the parsed doc has to be re-hydrated against + // `view.state.schema` before it can be inserted. + doc = ProseMirrorNode.fromJSON(view.state.schema, markdownToDoc(text).toJSON()); + } catch { + return false; + } + const { selection } = view.state; + view.dispatch(view.state.tr.replaceWith(selection.from, selection.to, doc.content)); + return true; + }, + }, + }), + ]; + }, +}); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 083e291..ea94ab5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -74,3 +74,22 @@ export async function apiUploadFile(path: string, file: File): Promise { export function fetchHealth(): Promise { return apiGet('/healthz'); } + +/** Plain-text response (issue #30's Markdown export) — every other endpoint + * here returns JSON, so this can't share `requestJson`'s `JSON.parse`. */ +export async function apiGetText(path: string): Promise { + let response: Response; + try { + response = await fetch(`/api/v1${path}`); + } catch { + throw new ApiError(0, { code: 'network', message: 'network error' }); + } + if (!response.ok) { + const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null; + throw new ApiError( + response.status, + parsed ?? { code: `http_${response.status}`, message: response.statusText }, + ); + } + return response.text(); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index e73f1a5..5b1b43b 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -14,7 +14,7 @@ import { Toolbar } from '../editor/Toolbar'; import { usePageStateAutosave } from '../editor/use-page-autosave'; import { decodeBase64 } from '../editor/yjs-base64'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; -import { apiGet, apiPatch } from '../lib/api'; +import { apiGet, apiGetText, apiPatch } from '../lib/api'; type Mode = 'view' | 'edit'; @@ -75,6 +75,42 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React. ); } +/** Markdown export actions (issue #30) — both read from the server-cached + * `page_content_cache.markdown` (via the export endpoint), so "copy" and + * "download" always agree with each other and with the last saved state. */ +function PageMenu({ pageId, slug }: { pageId: string; slug: string }): React.JSX.Element { + const { t } = useTranslation('editor'); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); + + async function copyMarkdown(): Promise { + try { + const markdown = await apiGetText(`/pages/${pageId}/export/markdown`); + await navigator.clipboard.writeText(markdown); + setCopyStatus('copied'); + } catch { + setCopyStatus('error'); + } + setTimeout(() => setCopyStatus('idle'), 2000); + } + + return ( +
+ + + {t('page.downloadMarkdown')} + +
+ ); +} + export function PageEditorPage(): React.JSX.Element { const { t } = useTranslation('editor'); const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>(); @@ -128,6 +164,7 @@ export function PageEditorPage(): React.JSX.Element { > {mode === 'edit' ? t('mode.view') : t('mode.edit')} + diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 845ce02..0908fb9 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -435,6 +435,11 @@ button { outline-offset: 2px; } +.editor-page__actions { + display: flex; + gap: var(--space-2); +} + .editor-shell { border: 1px solid var(--color-border); border-radius: var(--radius); diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index b452186..7955427 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -65,5 +65,11 @@ "medium": "Mittel", "full": "Voll" } + }, + "page": { + "copyMarkdown": "Als Markdown kopieren", + "markdownCopied": "Kopiert!", + "markdownCopyFailed": "Kopieren fehlgeschlagen", + "downloadMarkdown": "Als Markdown herunterladen" } } diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index dc90a0a..d9fc201 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -65,5 +65,11 @@ "medium": "Medium", "full": "Full" } + }, + "page": { + "copyMarkdown": "Copy as Markdown", + "markdownCopied": "Copied!", + "markdownCopyFailed": "Copy failed", + "downloadMarkdown": "Download as Markdown" } }