From 8efceada1f218a02a0210696ec27b410218f96f8 Mon Sep 17 00:00:00 2001 From: KodjoSuprem Date: Mon, 25 May 2026 01:08:41 +0200 Subject: [PATCH] feat(frontend): allow copying task details as Markdown Add a button in the task detail actions panel that copies the task as Markdown to the clipboard. The output format is: # Task title Description as markdown (links, bold, lists preserved) Uses prosemirror-markdown (already bundled via @tiptap/pm) to convert the HTML description to proper Markdown. No new dependencies added. --- .../src/composables/useCopyToClipboard.ts | 17 +-- frontend/src/helpers/taskMarkdown.test.ts | 100 ++++++++++++++++++ frontend/src/helpers/taskMarkdown.ts | 92 ++++++++++++++++ frontend/src/i18n/lang/en.json | 2 + frontend/src/views/tasks/TaskDetailView.vue | 17 +++ 5 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 frontend/src/helpers/taskMarkdown.test.ts create mode 100644 frontend/src/helpers/taskMarkdown.ts diff --git a/frontend/src/composables/useCopyToClipboard.ts b/frontend/src/composables/useCopyToClipboard.ts index 338d0fd93..64450e795 100644 --- a/frontend/src/composables/useCopyToClipboard.ts +++ b/frontend/src/composables/useCopyToClipboard.ts @@ -4,7 +4,7 @@ import {useI18n} from 'vue-i18n' export function useCopyToClipboard() { const {t} = useI18n({useScope: 'global'}) - function fallbackCopyTextToClipboard(text: string) { + function fallbackCopyTextToClipboard(text: string): boolean { const textArea = document.createElement('textarea') textArea.value = text @@ -17,12 +17,13 @@ export function useCopyToClipboard() { textArea.focus() textArea.select() + let success = false try { // NOTE: the execCommand is deprecated but as of 2022_09 // widely supported and works without https - const successful = document.execCommand('copy') - if (!successful) { - throw new Error() + success = document.execCommand('copy') + if (!success) { + error(t('misc.copyError')) } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { @@ -30,18 +31,20 @@ export function useCopyToClipboard() { } document.body.removeChild(textArea) + return success } - return async (text: string) => { + return async (text: string): Promise => { if (!navigator.clipboard) { - fallbackCopyTextToClipboard(text) - return + return fallbackCopyTextToClipboard(text) } try { await navigator.clipboard.writeText(text) + return true // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch(e) { error(t('misc.copyError')) + return false } } } diff --git a/frontend/src/helpers/taskMarkdown.test.ts b/frontend/src/helpers/taskMarkdown.test.ts new file mode 100644 index 000000000..f1a10e1c6 --- /dev/null +++ b/frontend/src/helpers/taskMarkdown.test.ts @@ -0,0 +1,100 @@ +import {describe, expect, it} from 'vitest' + +import {formatTaskAsMarkdown} from './taskMarkdown' + +describe('formatTaskAsMarkdown', () => { + it('formats a task with title and description', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Write release notes', + description: '

Summarize the changes for v1.0

', + }) + + expect(markdown).toBe('# Write release notes\n\nSummarize the changes for v1.0') + }) + + it('formats a task with title only when description is empty', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Write release notes', + description: '', + }) + + expect(markdown).toBe('# Write release notes') + }) + + it('treats TipTap empty content as no description', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Write release notes', + description: '

', + }) + + expect(markdown).toBe('# Write release notes') + }) + + it('converts links to markdown links', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Check docs', + description: '

See the documentation for details.

', + }) + + expect(markdown).toBe('# Check docs\n\nSee [the documentation](https://vikunja.io/docs) for details.') + }) + + it('converts bold and italic to markdown', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Formatting test', + description: '

This is important and urgent

', + }) + + expect(markdown).toBe('# Formatting test\n\nThis is **important** and *urgent*') + }) + + it('converts bullet lists to markdown', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Shopping list', + description: '', + }) + + expect(markdown).toContain('* Milk') + expect(markdown).toContain('* Eggs') + expect(markdown).toContain('* Bread') + }) + + it('converts task lists with checked/unchecked state', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Checklist', + description: '', + }) + + expect(markdown).toContain('[x] Done item') + expect(markdown).toContain('[ ] Todo item') + }) + + it('converts tables to pipe-separated rows', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Table test', + description: '
NameStatus
Task ADone
Task BPending
', + }) + + expect(markdown).toContain('Name | Status') + expect(markdown).toContain('Task A | Done') + expect(markdown).toContain('Task B | Pending') + }) + + it('preserves line breaks across paragraphs', () => { + const markdown = formatTaskAsMarkdown({ + title: 'Multi-paragraph', + description: '

First paragraph

Second paragraph

', + }) + + expect(markdown).toBe('# Multi-paragraph\n\nFirst paragraph\n\nSecond paragraph') + }) + + it('trims whitespace from the title', () => { + const markdown = formatTaskAsMarkdown({ + title: ' Spaces around ', + description: '', + }) + + expect(markdown).toBe('# Spaces around') + }) +}) diff --git a/frontend/src/helpers/taskMarkdown.ts b/frontend/src/helpers/taskMarkdown.ts new file mode 100644 index 000000000..0a78799d4 --- /dev/null +++ b/frontend/src/helpers/taskMarkdown.ts @@ -0,0 +1,92 @@ +import type {ITask} from '@/modelTypes/ITask' +import {DOMParser as ProseMirrorDOMParser} from '@tiptap/pm/model' +import {defaultMarkdownSerializer, MarkdownSerializer, schema} from '@tiptap/pm/markdown' + +type MarkdownTask = Pick + +const pmDOMParser = ProseMirrorDOMParser.fromSchema(schema) + +// Non-strict mode: unknown nodes render their text content instead of throwing +const markdownSerializer = new MarkdownSerializer( + defaultMarkdownSerializer.nodes, + defaultMarkdownSerializer.marks, + {strict: false}, +) + +// Placeholders for task checkboxes — replaced after serialization +// because the serializer would escape the brackets +const UNCHECKED_MARKER = '\u{FFFC}\u{2610}' +const CHECKED_MARKER = '\u{FFFC}\u{2611}' + +/** + * Pre-processes TipTap HTML so the default ProseMirror schema can parse it. + */ +function preprocessHTML(html: string): string { + const doc = new DOMParser().parseFromString(html, 'text/html') + + for (const item of doc.querySelectorAll('li[data-type="taskItem"]')) { + const checked = item.getAttribute('data-checked') === 'true' + const prefix = checked ? CHECKED_MARKER : UNCHECKED_MARKER + const firstBlock = item.querySelector('p, div') + if (firstBlock) { + firstBlock.insertBefore(doc.createTextNode(prefix), firstBlock.firstChild) + } else { + item.insertBefore(doc.createTextNode(prefix), item.firstChild) + } + item.removeAttribute('data-type') + item.removeAttribute('data-checked') + } + + for (const list of doc.querySelectorAll('ul[data-type="taskList"]')) { + list.removeAttribute('data-type') + } + + for (const table of doc.querySelectorAll('table')) { + const rows: string[] = [] + for (const tr of table.querySelectorAll('tr')) { + const cells: string[] = [] + for (const cell of tr.querySelectorAll('td, th')) { + cells.push((cell.textContent || '').trim()) + } + rows.push(cells.join(' | ')) + } + const replacement = doc.createElement('div') + for (const row of rows) { + const p = doc.createElement('p') + p.textContent = row + replacement.appendChild(p) + } + table.replaceWith(replacement) + } + + return doc.body.innerHTML +} + +function htmlToMarkdown(html: string): string { + if (!html || html === '

') { + return '' + } + + const preprocessed = preprocessHTML(html) + const dom = new DOMParser().parseFromString(preprocessed, 'text/html') + const doc = pmDOMParser.parse(dom.body) + + return markdownSerializer.serialize(doc) + .replace(new RegExp(UNCHECKED_MARKER, 'g'), '[ ] ') + .replace(new RegExp(CHECKED_MARKER, 'g'), '[x] ') + .trim() +} + +/** + * Format a task as Markdown (title as heading + description). + */ +export function formatTaskAsMarkdown(task: MarkdownTask): string { + const parts: string[] = [`# ${task.title.trim()}`] + + const description = htmlToMarkdown(task.description) + if (description) { + parts.push('', description) + } + + return parts.join('\n') +} diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 3b90d69ed..b768770e2 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -965,6 +965,7 @@ "updateSuccess": "The task was saved successfully.", "deleteSuccess": "The task has been deleted successfully.", "duplicateSuccess": "The task was duplicated successfully.", + "copyMarkdownSuccess": "The task was copied as Markdown.", "noBucket": "No bucket", "bucketChangedSuccess": "The task bucket has been changed successfully.", "belongsToProject": "This task belongs to project '{project}'", @@ -996,6 +997,7 @@ "relatedTasks": "Add Relation", "moveProject": "Move", "duplicate": "Duplicate", + "copyMarkdown": "Copy as Markdown", "color": "Set Color", "delete": "Delete", "favorite": "Add to Favorites", diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index 055e4f1ea..ea757da5d 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -534,6 +534,13 @@ > {{ $t('task.detail.actions.duplicate') }} + + {{ $t('task.detail.actions.copyMarkdown') }} + {{ $t('task.detail.dateAndTime') }} @@ -685,6 +692,8 @@ import {useBaseStore} from '@/stores/base' import {useTitle} from '@/composables/useTitle' import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts' +import {useCopyToClipboard} from '@/composables/useCopyToClipboard' +import {formatTaskAsMarkdown} from '@/helpers/taskMarkdown' import {success} from '@/message' import type {Action as MessageAction} from '@/message' @@ -701,6 +710,7 @@ defineEmits<{ const router = useRouter() const route = useRoute() const {t} = useI18n({useScope: 'global'}) +const copy = useCopyToClipboard() const projectStore = useProjectStore() const taskStore = useTaskStore() @@ -1146,6 +1156,13 @@ async function duplicateCurrentTask() { } } +async function copyTaskAsMarkdown() { + const copied = await copy(formatTaskAsMarkdown(task.value)) + if (copied) { + success({message: t('task.detail.copyMarkdownSuccess')}) + } +} + async function setPriority(priority: Priority) { const newTask: ITask = { ...task.value,