From a728e507964f3d403855a31e4df59ed5468ddbd1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 28 Jun 2026 00:01:07 +0200 Subject: [PATCH] feat(caldav): serialize task descriptions as markdown CalDAV clients render DESCRIPTION as plain text, so convert the stored HTML to markdown when serializing VTODOs. On the near-impossible conversion error, log it and keep the stored value. --- pkg/caldav/caldav.go | 16 ++++++++++++++-- pkg/caldav/caldav_test.go | 40 ++++++++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/pkg/caldav/caldav.go b/pkg/caldav/caldav.go index d89f87636..f2c5dacab 100644 --- a/pkg/caldav/caldav.go +++ b/pkg/caldav/caldav.go @@ -21,7 +21,9 @@ import ( "strings" "time" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/richtext" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" ) @@ -179,8 +181,18 @@ DURATION:PT` + formatDuration(t.Duration) DTEND:` + makeCalDavTimeFromTimeStamp(t.End) } if t.Description != "" { - caldavtodos += ` -DESCRIPTION:` + escapeICalText(t.Description) + // CalDAV clients show plain text, so emit markdown. On the near-impossible + // conversion error, log it and keep the stored value (GetContent can't + // return an error) rather than drop the description. + description, err := richtext.HTMLToMarkdown(t.Description) + if err != nil { + log.Errorf("[CALDAV] Failed to convert description to markdown for task %q: %v", t.UID, err) + description = t.Description + } + if description != "" { + caldavtodos += ` +DESCRIPTION:` + escapeICalText(description) + } } if t.Completed.Unix() > 0 { caldavtodos += ` diff --git a/pkg/caldav/caldav_test.go b/pkg/caldav/caldav_test.go index 7ced0b5e4..76f2e755d 100644 --- a/pkg/caldav/caldav_test.go +++ b/pkg/caldav/caldav_test.go @@ -47,12 +47,11 @@ func TestParseTodos(t *testing.T) { }, todos: []*Todo{ { - Summary: "Todo #1", - Description: `Lorem Ipsum -Dolor sit amet`, - UID: "randommduid", - Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), - Color: "affffe", + Summary: "Todo #1", + Description: `

Lorem Ipsum

Dolor sit amet

`, + UID: "randommduid", + Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Color: "affffe", }, }, }, @@ -73,7 +72,7 @@ X-APPLE-CALENDAR-COLOR:#affffeFF X-OUTLOOK-COLOR:#affffeFF X-FUNAMBOL-COLOR:#affffeFF COLOR:#affffeFF -DESCRIPTION:Lorem Ipsum\nDolor sit amet +DESCRIPTION:Lorem Ipsum\n\nDolor sit amet LAST-MODIFIED:00010101T000000Z END:VTODO END:VCALENDAR`, @@ -438,6 +437,33 @@ END:VCALENDAR`, } } +func TestParseTodosRichTextDescription(t *testing.T) { + cfg := &Config{Name: "test", ProdID: "Vikunja"} + ts := time.Unix(1543626724, 0).In(config.GetTimeZone()) + + t.Run("rich html serializes as markdown", func(t *testing.T) { + out := ParseTodos(cfg, []*Todo{{ + Summary: "Todo", + UID: "uid", + Timestamp: ts, + Description: `

Hello bold and @User One

` + + ``, + }}) + // iCal escapes the markdown's newlines as "\n". + assert.Contains(t, out, `DESCRIPTION:Hello **bold** and @user1\n\n- [x] done`) + }) + + t.Run("empty html omits the description line", func(t *testing.T) { + out := ParseTodos(cfg, []*Todo{{Summary: "Todo", UID: "uid", Timestamp: ts, Description: "

"}}) + assert.NotContains(t, out, "DESCRIPTION:") + }) + + t.Run("plain text description is unaffected", func(t *testing.T) { + out := ParseTodos(cfg, []*Todo{{Summary: "Todo", UID: "uid", Timestamp: ts, Description: "just plain text"}}) + assert.Contains(t, out, "DESCRIPTION:just plain text") + }) +} + func TestGetCaldavColor(t *testing.T) { tests := []struct { name string