From 9015bad65c61aad8c661814aa1b26e0b1d2716d5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 28 Jun 2026 00:00:45 +0200 Subject: [PATCH] feat(richtext): add markdown-domain change detection Changed reports whether inbound markdown differs from stored HTML by comparing in the markdown domain, so callers can skip rewriting unchanged fields. --- pkg/richtext/changedetect.go | 64 +++++++++++++++++++ pkg/richtext/changedetect_test.go | 101 ++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 pkg/richtext/changedetect.go create mode 100644 pkg/richtext/changedetect_test.go 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: `
  • done

`, + 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

")) +}