feat(richtext): add HTML to Markdown conversion

Converts rich-text HTML to GFM Markdown (standard + GFM extensions) including
the Vikunja/TipTap-specific nodes (mentions, task lists). Adds the
html-to-markdown/v2 dependency.
This commit is contained in:
kolaente 2026-06-28 00:00:29 +02:00
parent a2063a27a8
commit 3abe8d650a
6 changed files with 446 additions and 28 deletions

20
go.mod
View File

@ -21,6 +21,8 @@ go 1.25.7
require (
code.dny.dev/ssrf v0.2.0
dario.cat/mergo v1.0.2
github.com/JohannesKaufmann/dom v0.3.1
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/ThreeDotsLabs/watermill v1.5.1
github.com/adlio/trello v1.12.0
github.com/arran4/golang-ical v0.3.5
@ -77,15 +79,15 @@ require (
github.com/traefik/yaegi v0.16.1
github.com/ulule/limiter/v3 v3.11.2
github.com/wneessen/go-mail v0.7.2
github.com/yuin/goldmark v1.7.16
golang.org/x/crypto v0.48.0
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.51.0
golang.org/x/image v0.38.0
golang.org/x/net v0.50.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
golang.org/x/text v0.35.0
golang.org/x/sys v0.45.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
gopkg.in/d4l3k/messagediff.v1 v1.2.1
mvdan.cc/xurls/v2 v2.6.0
src.techknowlogick.com/xormigrate v1.7.1
@ -156,7 +158,7 @@ require (
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@ -200,9 +202,9 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

45
go.sum
View File

@ -15,6 +15,10 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
@ -392,8 +396,8 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
@ -492,6 +496,10 @@ github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeH
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@ -554,8 +562,8 @@ github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@ -602,8 +610,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 h1:yZNXmy+j/JpX19vZkVktWqAo7Gny4PBWYYK3zskGpx4=
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@ -614,8 +622,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -630,8 +638,8 @@ golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -667,15 +675,14 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -683,8 +690,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
@ -700,8 +707,8 @@ golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -0,0 +1,59 @@
// 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 <https://www.gnu.org/licenses/>.
// Package richtext converts Vikunja's canonical rich-text HTML to and from
// Markdown at the API/CalDAV boundaries. Storage stays HTML; only the wire
// representation changes.
package richtext
import (
"fmt"
"strings"
"github.com/JohannesKaufmann/html-to-markdown/v2/converter"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/base"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/commonmark"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/strikethrough"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/table"
)
// HTMLToMarkdown converts rich-text HTML to GFM Markdown. Trimmed, so an empty
// document ("<p></p>") yields "".
func HTMLToMarkdown(htmlInput string) (string, error) {
md, err := newHTMLToMarkdownConverter().ConvertString(htmlInput)
if err != nil {
return "", fmt.Errorf("converting html to markdown: %w", err)
}
return strings.TrimSpace(md), nil
}
// newHTMLToMarkdownConverter builds a GFM converter. Per call: the registered
// handlers aren't safe for concurrent reuse, and conversion is cheap.
func newHTMLToMarkdownConverter() *converter.Converter {
conv := converter.NewConverter(
converter.WithPlugins(
base.NewBasePlugin(),
commonmark.NewCommonmarkPlugin(),
table.NewTablePlugin(),
strikethrough.NewStrikethroughPlugin(),
),
)
registerTipTapRules(conv)
return conv
}

View File

@ -0,0 +1,111 @@
// 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 <https://www.gnu.org/licenses/>.
package richtext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTMLToMarkdown(t *testing.T) {
tests := []struct {
name string
html string
want string
}{
{
name: "heading",
html: "<h1>Title</h1>",
want: "# Title",
},
{
name: "bold and italic",
html: "<p><strong>bold</strong> and <em>italic</em></p>",
want: "**bold** and *italic*",
},
{
name: "link",
html: `<p>See <a href="https://vikunja.io">the site</a></p>`,
want: "See [the site](https://vikunja.io)",
},
{
name: "inline code",
html: "<p>run <code>mage build</code> first</p>",
want: "run `mage build` first",
},
{
name: "fenced code block keeps language",
html: `<pre><code class="language-go">fmt.Println("hi")</code></pre>`,
want: "```go\nfmt.Println(\"hi\")\n```",
},
{
name: "blockquote",
html: "<blockquote><p>quoted text</p></blockquote>",
want: "> quoted text",
},
{
name: "unordered list",
html: "<ul><li>one</li><li>two</li></ul>",
want: "- one\n- two",
},
{
name: "ordered list",
html: "<ol><li>one</li><li>two</li></ol>",
want: "1. one\n2. two",
},
{
name: "nested list",
html: "<ul><li>one<ul><li>nested</li></ul></li><li>two</li></ul>",
want: "- one\n \n - nested\n- two",
},
{
name: "gfm table",
html: "<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>",
want: "| a | b |\n|---|---|\n| 1 | 2 |",
},
{
name: "strikethrough",
html: "<p><del>gone</del></p>",
want: "~~gone~~",
},
{
name: "empty paragraph is empty string",
html: "<p></p>",
want: "",
},
{
name: "whitespace only is empty string",
html: "<p> </p>",
want: "",
},
{
name: "unknown element degrades without leaking tags",
html: "<p>hello <unknowntag>world</unknowntag></p>",
want: "hello world",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := HTMLToMarkdown(tt.html)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

153
pkg/richtext/tiptap.go Normal file
View File

@ -0,0 +1,153 @@
// 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 <https://www.gnu.org/licenses/>.
package richtext
import (
"github.com/JohannesKaufmann/dom"
"github.com/JohannesKaufmann/html-to-markdown/v2/converter"
"golang.org/x/net/html"
)
// registerTipTapRules teaches the HTML→Markdown converter about the two
// Vikunja-specific nodes that standard GFM doesn't model: TipTap mentions and
// TipTap task lists.
func registerTipTapRules(conv *converter.Converter) {
// Empty mention elements (the common stored form is <mention-user data-id data-label></mention-user>)
// would otherwise be treated as content-less by the whitespace collapser, eating the
// following space. Giving them a text child before collapse (PriorityLate) preserves it.
conv.Register.PreRenderer(ensureMentionContent, converter.PriorityEarly)
conv.Register.RendererFor("mention-user", converter.TagTypeInline, renderMentionUser, converter.PriorityEarly)
// Normalize TipTap task-list items to a single <input type="checkbox"> that
// renderTaskCheckbox turns into the GFM "[x]"/"[ ]" marker. We drive off the
// <li data-checked> attribute (the same source of truth resetDescriptionChecklist
// uses) rather than TipTap's <label><input> chrome, which may not always be present.
conv.Register.PreRenderer(normalizeTaskListItems, converter.PriorityEarly)
conv.Register.RendererFor("input", converter.TagTypeInline, renderTaskCheckbox, converter.PriorityEarly)
}
// renderMentionUser converts <mention-user data-id="username"> to "@username"
// (label and inner text dropped). Tags without data-id fall through to the
// default renderer, keeping their inner text.
func renderMentionUser(_ converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus {
username := dom.GetAttributeOr(n, "data-id", "")
if username == "" {
return converter.RenderTryNext
}
// Written directly to the writer so the username isn't markdown-escaped;
// the inbound side re-tokenizes "@username" verbatim. The writer is
// buffer-backed and never errors.
_, _ = w.WriteString("@" + username)
return converter.RenderSuccess
}
// ensureMentionContent gives every mention with a data-id a text child if it has
// none, so the whitespace collapser keeps it (and the surrounding spaces). The
// child is never rendered — renderMentionUser writes "@data-id" and stops.
func ensureMentionContent(_ converter.Context, doc *html.Node) {
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "mention-user" && n.FirstChild == nil {
if username := dom.GetAttributeOr(n, "data-id", ""); username != "" {
n.AppendChild(&html.Node{Type: html.TextNode, Data: "@" + username})
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
}
// renderTaskCheckbox emits the GFM task-list marker for the normalized checkbox
// input. The trailing space separates it from the item text ("- [x] text").
func renderTaskCheckbox(_ converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus {
if dom.GetAttributeOr(n, "type", "") != "checkbox" {
return converter.RenderTryNext
}
marker := "[ ] "
if _, checked := dom.GetAttribute(n, "checked"); checked {
marker = "[x] "
}
_, _ = w.WriteString(marker)
return converter.RenderSuccess
}
// normalizeTaskListItems rewrites every <li data-checked="…"> so its checkbox
// state is carried by a single leading <input type="checkbox">, removing
// TipTap's <label> chrome. This makes the marker independent of whether the
// stored HTML used the full TipTap form or the bare data-checked form.
func normalizeTaskListItems(_ converter.Context, doc *html.Node) {
var items []*html.Node
var collect func(*html.Node)
collect = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "li" {
if _, ok := dom.GetAttribute(n, "data-checked"); ok {
items = append(items, n)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
collect(c)
}
}
collect(doc)
for _, li := range items {
checked := dom.GetAttributeOr(li, "data-checked", "false") == "true"
// Drop the existing checkbox chrome (<label><input><span>) so we don't
// render a duplicate or stale marker.
for _, child := range dom.AllChildNodes(li) {
if child.Type == html.ElementNode && (child.Data == "label" || child.Data == "input") {
dom.RemoveNode(child)
}
}
input := &html.Node{
Type: html.ElementNode,
Data: "input",
Attr: []html.Attribute{{Key: "type", Val: "checkbox"}},
}
if checked {
input.Attr = append(input.Attr, html.Attribute{Key: "checked", Val: "checked"})
}
// Insert the marker inside the item's first paragraph so it stays inline
// with the text ("- [x] text"). TipTap wraps task text in <div><p>…</p></div>;
// inserting at the <li> level instead would put a block boundary between
// the marker and the text.
host := firstParagraph(li)
if host == nil {
host = li
}
host.InsertBefore(input, host.FirstChild)
}
}
func firstParagraph(n *html.Node) *html.Node {
for _, c := range dom.AllChildNodes(n) {
if c.Type == html.ElementNode && c.Data == "p" {
return c
}
if found := firstParagraph(c); found != nil {
return found
}
}
return nil
}

View File

@ -0,0 +1,86 @@
// 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 <https://www.gnu.org/licenses/>.
package richtext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTMLToMarkdown_TipTap(t *testing.T) {
tests := []struct {
name string
html string
want string
}{
{
name: "mention uses data-id and drops label",
html: `<p><mention-user data-id="actualuser" data-label="Different Label">@differentlabel</mention-user></p>`,
want: "@actualuser",
},
{
name: "empty mention keeps following space",
html: `<p><mention-user data-id="frederick" data-label="Frederick"></mention-user> hello</p>`,
want: "@frederick hello",
},
{
name: "mention next to punctuation stays intact",
html: `<p>cc <mention-user data-id="jane">@jane</mention-user>, please review</p>`,
want: "cc @jane, please review",
},
{
name: "multiple mentions in one block",
html: `<p>ping <mention-user data-id="user1">@user1</mention-user> and <mention-user data-id="user2">@user2</mention-user></p>`,
want: "ping @user1 and @user2",
},
{
name: "mention without data-id keeps inner text",
html: `<p><mention-user>@someuser</mention-user> hi</p>`,
want: "@someuser hi",
},
{
name: "tiptap task list checked and unchecked",
html: `<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>done item</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>todo item</p></div></li></ul>`,
want: "- [x] done item\n- [ ] todo item",
},
{
name: "task list bare data-checked form",
html: `<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>Item 1</p></li></ul>`,
want: "- [ ] Item 1",
},
{
name: "nested task list items",
html: `<ul data-type="taskList"><li data-checked="false" data-type="taskItem"><div><p>parent</p></div><ul data-type="taskList"><li data-checked="true" data-type="taskItem"><div><p>child</p></div></li></ul></li></ul>`,
want: "- [ ] parent\n \n - [x] child",
},
{
name: "mention inside task list item",
html: `<ul data-type="taskList"><li data-checked="false" data-type="taskItem"><div><p>ask <mention-user data-id="bob">@bob</mention-user></p></div></li></ul>`,
want: "- [ ] ask @bob",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := HTMLToMarkdown(tt.html)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}