From 8d10e053d4f424a87fb63627366ed1bbe5bc7e51 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 28 Jun 2026 00:01:07 +0200 Subject: [PATCH] fix(caldav): store markdown descriptions as HTML, skip spurious updates Incoming CalDAV descriptions are markdown; convert them back to canonical HTML (rebuilding mentions) before persisting. Skip the conversion when the markdown is unchanged from the stored HTML so a passthrough sync doesn't churn the value or bump the ETag. --- pkg/routes/caldav/description_test.go | 88 ++++++++++++++++++++++++ pkg/routes/caldav/listStorageProvider.go | 34 +++++++++ 2 files changed, 122 insertions(+) create mode 100644 pkg/routes/caldav/description_test.go diff --git a/pkg/routes/caldav/description_test.go b/pkg/routes/caldav/description_test.go new file mode 100644 index 000000000..f70288d35 --- /dev/null +++ b/pkg/routes/caldav/description_test.go @@ -0,0 +1,88 @@ +// 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 caldav + +import ( + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyDescriptionFromMarkdown(t *testing.T) { + t.Run("unchanged round trip keeps stored html verbatim", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + const stored = `

Hello world

` + vTask := &models.Task{Description: "Hello **world**"} + + require.NoError(t, applyDescriptionFromMarkdown(s, vTask, stored)) + assert.Equal(t, stored, vTask.Description) + }) + + t.Run("edited markdown is converted to html", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + const stored = `

Hello world

` + vTask := &models.Task{Description: "Hello **mars**"} + + require.NoError(t, applyDescriptionFromMarkdown(s, vTask, stored)) + assert.Equal(t, "

Hello mars

", vTask.Description) + }) + + t.Run("mention is rebuilt from markdown", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + vTask := &models.Task{Description: "ping @user1"} + + require.NoError(t, applyDescriptionFromMarkdown(s, vTask, "")) + assert.Contains(t, vTask.Description, `done

") + }) + + t.Run("emptying a description is honoured", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + vTask := &models.Task{Description: ""} + + require.NoError(t, applyDescriptionFromMarkdown(s, vTask, "

was here

")) + assert.Empty(t, vTask.Description) + }) +} diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index 60a151e2d..e59761328 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -28,6 +28,7 @@ import ( "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/richtext" user2 "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" "github.com/samedi/caldav-go/data" @@ -364,6 +365,14 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) ( return nil, errs.ForbiddenError } + // Inbound CalDAV descriptions are markdown; store them as canonical HTML. + if err := applyDescriptionFromMarkdown(s, vTask, ""); err != nil { + log.Errorf("[CALDAV] Failed to convert description in CreateResource: %v", err) + _ = s.Rollback() + events.CleanupPending(s) + return nil, err + } + // Create the task err = vTask.Create(s, vcls.user) if err != nil { @@ -408,6 +417,23 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) ( return &r, nil } +// applyDescriptionFromMarkdown converts a task's inbound CalDAV description +// (markdown) to canonical HTML, rebuilding @mentions. Unchanged markdown keeps the +// stored HTML verbatim, so a no-op read-modify-write doesn't churn it or move Updated. +func applyDescriptionFromMarkdown(s *xorm.Session, vTask *models.Task, storedHTML string) error { + if !richtext.Changed(storedHTML, vTask.Description) { + vTask.Description = storedHTML + return nil + } + + htmlDesc, err := richtext.MarkdownToHTMLWithMentions(s, vTask.Description) + if err != nil { + return err + } + vTask.Description = htmlDesc + return nil +} + // UpdateResource updates a resource func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (*data.Resource, error) { @@ -443,6 +469,14 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) ( return nil, errs.ForbiddenError } + // Inbound markdown → canonical HTML, kept verbatim when unchanged. + if err := applyDescriptionFromMarkdown(s, vTask, vcls.task.Description); err != nil { + log.Errorf("[CALDAV] Failed to convert description in UpdateResource: %v", err) + _ = s.Rollback() + events.CleanupPending(s) + return nil, err + } + // Update the task err = vTask.Update(s, vcls.user) if err != nil {