diff --git a/pkg/richtext/changedetect.go b/pkg/richtext/changedetect.go
new file mode 100644
index 000000000..b258dd42c
--- /dev/null
+++ b/pkg/richtext/changedetect.go
@@ -0,0 +1,64 @@
+// 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 "strings"
+
+// Changed reports whether inbound markdown differs semantically from stored
+// rich-text HTML, so callers can skip rewriting unchanged fields (avoids CalDAV
+// read-modify-write churning the HTML and bumping Updated). Both sides are
+// canonicalized to markdown before comparing: HTML→markdown isn't an identity, so
+// an HTML-domain compare would always report "changed". Errs to true.
+func Changed(storedHTML, incomingMarkdown string) bool {
+ stored, err := HTMLToMarkdown(storedHTML)
+ if err != nil {
+ return true
+ }
+
+ incoming, err := canonicalMarkdown(incomingMarkdown)
+ if err != nil {
+ return true
+ }
+
+ return normalizeMarkdown(stored) != normalizeMarkdown(incoming)
+}
+
+// HTMLIsEmpty treats "", "
" and whitespace-only markup as empty.
+func HTMLIsEmpty(htmlInput string) bool {
+ md, err := HTMLToMarkdown(htmlInput)
+ if err != nil {
+ return false
+ }
+ return md == ""
+}
+
+// canonicalMarkdown round-trips markdown through HTML so it matches the shape
+// HTMLToMarkdown yields from stored HTML. No session needed: a tag
+// and an inbound "@username" both reduce to "@username".
+func canonicalMarkdown(md string) (string, error) {
+ h, err := MarkdownToHTML(md)
+ if err != nil {
+ return "", err
+ }
+ return HTMLToMarkdown(h)
+}
+
+func normalizeMarkdown(md string) string {
+ md = strings.ReplaceAll(md, "\r\n", "\n")
+ md = strings.ReplaceAll(md, "\r", "\n")
+ return strings.TrimSpace(md)
+}
diff --git a/pkg/richtext/changedetect_test.go b/pkg/richtext/changedetect_test.go
new file mode 100644
index 000000000..10f388e18
--- /dev/null
+++ b/pkg/richtext/changedetect_test.go
@@ -0,0 +1,101 @@
+// 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"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestChanged(t *testing.T) {
+ tests := []struct {
+ name string
+ stored string
+ incoming string
+ want bool
+ }{
+ {
+ name: "markdown projection equals incoming",
+ stored: "Hello world
",
+ incoming: "Hello **world**",
+ want: false,
+ },
+ {
+ name: "genuinely edited",
+ stored: "Hello world
",
+ incoming: "Hello **mars**",
+ want: true,
+ },
+ {
+ name: "line ending only difference",
+ stored: "line one
line two
",
+ incoming: "line one\r\n\r\nline two",
+ want: false,
+ },
+ {
+ name: "trailing whitespace only difference",
+ stored: "same
",
+ incoming: "same\n\n ",
+ want: false,
+ },
+ {
+ name: "equivalent markdown flavors compare equal",
+ stored: "x
",
+ incoming: "_x_",
+ want: false,
+ },
+ {
+ name: "empty stored vs empty incoming",
+ stored: "",
+ incoming: "",
+ want: false,
+ },
+ {
+ name: "empty stored vs new content",
+ stored: "",
+ incoming: "now has text",
+ want: true,
+ },
+ {
+ name: "task list round trip unchanged",
+ stored: ``,
+ incoming: "- [x] done",
+ want: false,
+ },
+ {
+ name: "mention round trip unchanged",
+ stored: `cc @User One
`,
+ incoming: "cc @user1",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, Changed(tt.stored, tt.incoming))
+ })
+ }
+}
+
+func TestHTMLIsEmpty(t *testing.T) {
+ assert.True(t, HTMLIsEmpty(""))
+ assert.True(t, HTMLIsEmpty(""))
+ assert.True(t, HTMLIsEmpty(" "))
+ assert.True(t, HTMLIsEmpty("
"))
+ assert.False(t, HTMLIsEmpty("content
"))
+}