diff --git a/pkg/richtext/main_test.go b/pkg/richtext/main_test.go
new file mode 100644
index 000000000..7c7dd8a3e
--- /dev/null
+++ b/pkg/richtext/main_test.go
@@ -0,0 +1,47 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see
some bold text
", + }, + { + name: "link", + md: "see [the site](https://vikunja.io)", + want: `see the site
`, + }, + { + name: "task list becomes tiptap dom", + md: "- [x] done\n- [ ] todo", + want: "done
todo
parent
child
task with bold and a link
| a | \nb | \n
|---|---|
| 1 | \n2 | \n
gone
, , and
+// .
+func collectMentionTextNodes(n *html.Node, inSkip bool, out *[]*html.Node) {
+ if n.Type == html.TextNode {
+ if !inSkip {
+ *out = append(*out, n)
+ }
+ return
+ }
+
+ skip := inSkip
+ if n.Type == html.ElementNode {
+ switch n.Data {
+ case "code", "pre", "a", "mention-user":
+ skip = true
+ }
+ }
+
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ collectMentionTextNodes(c, skip, out)
+ }
+}
+
+// findMentionCandidates returns the usernames mentioned in text (word-boundary
+// "@" only).
+func findMentionCandidates(text string) []string {
+ var names []string
+ for _, m := range mentionTokenRegex.FindAllStringSubmatchIndex(text, -1) {
+ if mentionPrecededByWordChar(text, m[0]) {
+ continue
+ }
+ names = append(names, text[m[2]:m[3]])
+ }
+ return names
+}
+
+// replaceMentionsInTextNode splits tn, swapping known @mentions for nodes.
+func replaceMentionsInTextNode(tn *html.Node, users map[string]*user.User) {
+ text := tn.Data
+
+ var newNodes []*html.Node
+ cursor := 0
+ for _, m := range mentionTokenRegex.FindAllStringSubmatchIndex(text, -1) {
+ start, end := m[0], m[1]
+ if mentionPrecededByWordChar(text, start) {
+ continue
+ }
+ u, ok := users[text[m[2]:m[3]]]
+ if !ok {
+ continue
+ }
+
+ if start > cursor {
+ newNodes = append(newNodes, &html.Node{Type: html.TextNode, Data: text[cursor:start]})
+ }
+ newNodes = append(newNodes, newMentionNode(u))
+ cursor = end
+ }
+
+ if len(newNodes) == 0 {
+ return
+ }
+ if cursor < len(text) {
+ newNodes = append(newNodes, &html.Node{Type: html.TextNode, Data: text[cursor:]})
+ }
+
+ parent := tn.Parent
+ for _, nn := range newNodes {
+ parent.InsertBefore(nn, tn)
+ }
+ parent.RemoveChild(tn)
+}
+
+// newMentionNode builds @Name .
+// data-id carries the username so extractMentionedUsernames can re-resolve it.
+func newMentionNode(u *user.User) *html.Node {
+ n := &html.Node{
+ Type: html.ElementNode,
+ Data: "mention-user",
+ Attr: []html.Attribute{
+ {Key: "data-id", Val: u.Username},
+ {Key: "data-label", Val: u.GetName()},
+ },
+ }
+ n.AppendChild(&html.Node{Type: html.TextNode, Data: "@" + u.GetName()})
+ return n
+}
+
+// mentionPrecededByWordChar reports whether the rune just before atIndex is a
+// letter, digit or underscore — i.e. the "@" is mid-token (an email), not a mention.
+func mentionPrecededByWordChar(text string, atIndex int) bool {
+ if atIndex == 0 {
+ return false
+ }
+ r, _ := utf8.DecodeLastRuneInString(text[:atIndex])
+ return unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_'
+}
diff --git a/pkg/richtext/mentions_html_test.go b/pkg/richtext/mentions_html_test.go
new file mode 100644
index 000000000..d20aa0d30
--- /dev/null
+++ b/pkg/richtext/mentions_html_test.go
@@ -0,0 +1,107 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package richtext
+
+import (
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMarkdownToHTMLWithMentions(t *testing.T) {
+ t.Run("known mention is rebuilt", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ got, err := MarkdownToHTMLWithMentions(s, "hi @user1")
+ require.NoError(t, err)
+ assert.Equal(t, `hi @user1
`, got)
+ })
+
+ t.Run("unknown mention stays literal text", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ got, err := MarkdownToHTMLWithMentions(s, "hi @nosuchuser")
+ require.NoError(t, err)
+ assert.Equal(t, "hi @nosuchuser
", got)
+ })
+
+ t.Run("mention next to punctuation", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ got, err := MarkdownToHTMLWithMentions(s, "cc @user1, please review")
+ require.NoError(t, err)
+ assert.Equal(t, `cc @user1 , please review
`, got)
+ })
+
+ t.Run("multiple mentions resolve in one pass", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ got, err := MarkdownToHTMLWithMentions(s, "ping @user1 and @user2")
+ require.NoError(t, err)
+ assert.Contains(t, got, `@user1 `)
+ assert.Contains(t, got, `@user2 `)
+ })
+
+ t.Run("email is not a mention", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ got, err := MarkdownToHTMLWithMentions(s, "reach me at user1@example.com please")
+ require.NoError(t, err)
+ assert.NotContains(t, got, "mention-user")
+ })
+
+ t.Run("mention inside code span is ignored", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ got, err := MarkdownToHTMLWithMentions(s, "use `@user1` literally")
+ require.NoError(t, err)
+ assert.NotContains(t, got, "mention-user")
+ assert.Contains(t, got, "@user1")
+ })
+
+ t.Run("mention inside task list item", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ got, err := MarkdownToHTMLWithMentions(s, "- [ ] ping @user1")
+ require.NoError(t, err)
+ assert.Contains(t, got, `data-type="taskItem"`)
+ assert.Contains(t, got, `@user1 `)
+ })
+
+ t.Run("no session leaves mention as text", func(t *testing.T) {
+ got, err := MarkdownToHTML("hi @user1")
+ require.NoError(t, err)
+ assert.Equal(t, "hi @user1
", got)
+ })
+}
diff --git a/pkg/richtext/tasklist_html.go b/pkg/richtext/tasklist_html.go
new file mode 100644
index 000000000..bcb8f5c65
--- /dev/null
+++ b/pkg/richtext/tasklist_html.go
@@ -0,0 +1,156 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package richtext
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "github.com/JohannesKaufmann/dom"
+ "golang.org/x/net/html"
+ "golang.org/x/net/html/atom"
+)
+
+// parseHTMLFragment parses an HTML fragment in a context (so tables/lists parse).
+func parseHTMLFragment(in []byte) ([]*html.Node, error) {
+ context := &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}
+ nodes, err := html.ParseFragment(bytes.NewReader(in), context)
+ if err != nil {
+ return nil, fmt.Errorf("parsing converted html: %w", err)
+ }
+ return nodes, nil
+}
+
+func renderHTMLNodes(nodes []*html.Node) (string, error) {
+ var buf bytes.Buffer
+ for _, n := range nodes {
+ if err := html.Render(&buf, n); err != nil {
+ return "", fmt.Errorf("rendering converted html: %w", err)
+ }
+ }
+ return buf.String(), nil
+}
+
+// convertTaskListItems rewrites goldmark's GFM task-list output
+// (- text
) into the TipTap
+// text
+// shape the web editor and resetDescriptionChecklist (recurring-task reset) expect.
+func convertTaskListItems(n *html.Node) {
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ convertTaskListItems(c)
+ }
+
+ if n.Type != html.ElementNode || n.Data != "li" {
+ return
+ }
+
+ input := leadingCheckbox(n)
+ if input == nil {
+ return
+ }
+
+ _, checked := dom.GetAttribute(input, "checked")
+ dom.RemoveNode(input)
+
+ setAttribute(n, "data-type", "taskItem")
+ setAttribute(n, "data-checked", boolString(checked))
+ wrapLeadingInlineInParagraph(n)
+
+ if p := n.Parent; p != nil && p.Type == html.ElementNode && (p.Data == "ul" || p.Data == "ol") {
+ setAttribute(p, "data-type", "taskList")
+ }
+}
+
+// leadingCheckbox returns the at the start of li (after
+// skipping insignificant whitespace), or nil if li isn't a task item.
+func leadingCheckbox(li *html.Node) *html.Node {
+ for c := li.FirstChild; c != nil; c = c.NextSibling {
+ if isWhitespaceText(c) {
+ continue
+ }
+ if c.Type == html.ElementNode && c.Data == "input" && dom.GetAttributeOr(c, "type", "") == "checkbox" {
+ return c
+ }
+ return nil
+ }
+ return nil
+}
+
+// wrapLeadingInlineInParagraph moves li's leading inline content (everything up
+// to the first nested list) into a , matching TipTap's taskItem shape.
+func wrapLeadingInlineInParagraph(li *html.Node) {
+ var inline []*html.Node
+ for c := li.FirstChild; c != nil; c = c.NextSibling {
+ if c.Type == html.ElementNode && (c.Data == "ul" || c.Data == "ol") {
+ break
+ }
+ inline = append(inline, c)
+ }
+
+ allWhitespace := true
+ for _, c := range inline {
+ if !isWhitespaceText(c) {
+ allWhitespace = false
+ break
+ }
+ }
+ if len(inline) == 0 || allWhitespace {
+ return
+ }
+
+ p := &html.Node{Type: html.ElementNode, Data: "p", DataAtom: atom.P}
+ for _, c := range inline {
+ li.RemoveChild(c)
+ p.AppendChild(c)
+ }
+ li.InsertBefore(p, li.FirstChild)
+ trimEdgeWhitespace(p)
+}
+
+// trimEdgeWhitespace trims leading/trailing whitespace from the first and last
+// text nodes of n so the wrapped paragraph doesn't keep goldmark's " "
+// spacing or trailing newline.
+func trimEdgeWhitespace(n *html.Node) {
+ if first := n.FirstChild; first != nil && first.Type == html.TextNode {
+ first.Data = strings.TrimLeft(first.Data, " \t\n\r")
+ }
+ if last := n.LastChild; last != nil && last.Type == html.TextNode {
+ last.Data = strings.TrimRight(last.Data, " \t\n\r")
+ }
+}
+
+func setAttribute(n *html.Node, key, val string) {
+ for i, a := range n.Attr {
+ if a.Key == key {
+ n.Attr[i].Val = val
+ return
+ }
+ }
+ n.Attr = append(n.Attr, html.Attribute{Key: key, Val: val})
+}
+
+func boolString(b bool) string {
+ if b {
+ return "true"
+ }
+ return "false"
+}
+
+func isWhitespaceText(n *html.Node) bool {
+ return n.Type == html.TextNode && strings.TrimSpace(n.Data) == ""
+}