This commit is contained in:
KodjoSuprem 2026-06-30 07:03:57 -05:00 committed by GitHub
commit dc580bcb29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 221 additions and 7 deletions

View File

@ -4,7 +4,7 @@ import {useI18n} from 'vue-i18n'
export function useCopyToClipboard() { export function useCopyToClipboard() {
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
function fallbackCopyTextToClipboard(text: string) { function fallbackCopyTextToClipboard(text: string): boolean {
const textArea = document.createElement('textarea') const textArea = document.createElement('textarea')
textArea.value = text textArea.value = text
@ -17,12 +17,13 @@ export function useCopyToClipboard() {
textArea.focus() textArea.focus()
textArea.select() textArea.select()
let success = false
try { try {
// NOTE: the execCommand is deprecated but as of 2022_09 // NOTE: the execCommand is deprecated but as of 2022_09
// widely supported and works without https // widely supported and works without https
const successful = document.execCommand('copy') success = document.execCommand('copy')
if (!successful) { if (!success) {
throw new Error() error(t('misc.copyError'))
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) { } catch (e) {
@ -30,18 +31,20 @@ export function useCopyToClipboard() {
} }
document.body.removeChild(textArea) document.body.removeChild(textArea)
return success
} }
return async (text: string) => { return async (text: string): Promise<boolean> => {
if (!navigator.clipboard) { if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text) return fallbackCopyTextToClipboard(text)
return
} }
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
return true
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch(e) { } catch(e) {
error(t('misc.copyError')) error(t('misc.copyError'))
return false
} }
} }
} }

View File

@ -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: '<p>Summarize the changes for v1.0</p>',
})
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: '<p></p>',
})
expect(markdown).toBe('# Write release notes')
})
it('converts links to markdown links', () => {
const markdown = formatTaskAsMarkdown({
title: 'Check docs',
description: '<p>See <a href="https://vikunja.io/docs">the documentation</a> for details.</p>',
})
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: '<p>This is <strong>important</strong> and <em>urgent</em></p>',
})
expect(markdown).toBe('# Formatting test\n\nThis is **important** and *urgent*')
})
it('converts bullet lists to markdown', () => {
const markdown = formatTaskAsMarkdown({
title: 'Shopping list',
description: '<ul><li><p>Milk</p></li><li><p>Eggs</p></li><li><p>Bread</p></li></ul>',
})
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: '<ul data-type="taskList"><li data-type="taskItem" data-checked="true"><p>Done item</p></li><li data-type="taskItem" data-checked="false"><p>Todo item</p></li></ul>',
})
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: '<table><tr><th>Name</th><th>Status</th></tr><tr><td>Task A</td><td>Done</td></tr><tr><td>Task B</td><td>Pending</td></tr></table>',
})
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: '<p>First paragraph</p><p>Second paragraph</p>',
})
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')
})
})

View File

@ -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<ITask, 'title' | 'description'>
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 === '<p></p>') {
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')
}

View File

@ -969,6 +969,7 @@
"updateSuccess": "The task was saved successfully.", "updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.", "deleteSuccess": "The task has been deleted successfully.",
"duplicateSuccess": "The task was duplicated successfully.", "duplicateSuccess": "The task was duplicated successfully.",
"copyMarkdownSuccess": "The task was copied as Markdown.",
"noBucket": "No bucket", "noBucket": "No bucket",
"bucketChangedSuccess": "The task bucket has been changed successfully.", "bucketChangedSuccess": "The task bucket has been changed successfully.",
"belongsToProject": "This task belongs to project '{project}'", "belongsToProject": "This task belongs to project '{project}'",
@ -1001,6 +1002,7 @@
"relatedTasks": "Add Relation", "relatedTasks": "Add Relation",
"moveProject": "Move", "moveProject": "Move",
"duplicate": "Duplicate", "duplicate": "Duplicate",
"copyMarkdown": "Copy as Markdown",
"color": "Set Color", "color": "Set Color",
"delete": "Delete", "delete": "Delete",
"favorite": "Add to Favorites", "favorite": "Add to Favorites",

View File

@ -543,6 +543,13 @@
> >
{{ $t('task.detail.actions.duplicate') }} {{ $t('task.detail.actions.duplicate') }}
</XButton> </XButton>
<XButton
variant="secondary"
icon="paste"
@click="copyTaskAsMarkdown"
>
{{ $t('task.detail.actions.copyMarkdown') }}
</XButton>
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span> <span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
@ -707,6 +714,8 @@ import {useConfigStore} from '@/stores/config'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts' import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {formatTaskAsMarkdown} from '@/helpers/taskMarkdown'
import {success} from '@/message' import {success} from '@/message'
import type {Action as MessageAction} from '@/message' import type {Action as MessageAction} from '@/message'
@ -723,6 +732,7 @@ defineEmits<{
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const copy = useCopyToClipboard()
const projectStore = useProjectStore() const projectStore = useProjectStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
@ -1178,6 +1188,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) { async function setPriority(priority: Priority) {
const newTask: ITask = { const newTask: ITask = {
...task.value, ...task.value,