focalboard/server/services/notify/notifysubscriptions/diff2markdown.go
Doug Lauder 72bf464b22
Improved diff to markdown for card notifications (#2037)
* improved text diff for notifications

* fix panic selecting insert_at

* improvements wip

* simplified v1

* improved diff to markdown

* address review comments

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
2022-01-05 14:11:58 -07:00

181 lines
4.1 KiB
Go

package notifysubscriptions
import (
"strings"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
func generateMarkdownDiff(oldText string, newText string, logger *mlog.Logger) string {
oldTxtNorm := normalizeText(oldText)
newTxtNorm := normalizeText(newText)
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(oldTxtNorm, newTxtNorm, false)
diffs = dmp.DiffCleanupSemantic(diffs)
diffs = dmp.DiffCleanupEfficiency(diffs)
// check there is at least one insert or delete
var editFound bool
for _, d := range diffs {
if (d.Type == diffmatchpatch.DiffInsert || d.Type == diffmatchpatch.DiffDelete) && strings.TrimSpace(d.Text) != "" {
editFound = true
break
}
}
if !editFound {
logger.Debug("skipping notification for superficial diff")
return ""
}
cfg := markDownCfg{
insertOpen: "`",
insertClose: "`",
deleteOpen: "~~`",
deleteClose: "`~~",
}
markdown := generateMarkdown(diffs, cfg)
markdown = strings.ReplaceAll(markdown, "¶", "\n")
return markdown
}
const (
truncLenEquals = 60
truncLenInserts = 120
truncLenDeletes = 80
)
type markDownCfg struct {
insertOpen string
insertClose string
deleteOpen string
deleteClose string
}
func generateMarkdown(diffs []diffmatchpatch.Diff, cfg markDownCfg) string {
sb := &strings.Builder{}
var first, last bool
for i, diff := range diffs {
first = i == 0
last = i == len(diffs)-1
switch diff.Type {
case diffmatchpatch.DiffInsert:
sb.WriteString(cfg.insertOpen)
sb.WriteString(truncate(diff.Text, truncLenInserts, first, last))
sb.WriteString(cfg.insertClose)
case diffmatchpatch.DiffDelete:
sb.WriteString(cfg.deleteOpen)
sb.WriteString(truncate(diff.Text, truncLenDeletes, first, last))
sb.WriteString(cfg.deleteClose)
case diffmatchpatch.DiffEqual:
sb.WriteString(truncate(diff.Text, truncLenEquals, first, last))
}
}
return sb.String()
}
func truncate(s string, maxLen int, first bool, last bool) string {
if len(s) < maxLen {
return s
}
var result string
switch {
case first:
// truncate left
result = " ... " + rightWords(s, maxLen)
case last:
// truncate right
result = leftWords(s, maxLen) + " ... "
default:
// truncate in the middle
half := len(s) / 2
left := leftWords(s[:half], maxLen/2)
right := rightWords(s[half:], maxLen/2)
result = left + " ... " + right
}
return strings.ReplaceAll(result, "¶", "↩")
}
func normalizeText(s string) string {
s = strings.ReplaceAll(s, "\t", " ")
s = strings.ReplaceAll(s, " ", " ")
s = strings.ReplaceAll(s, "\n\n", "\n")
s = strings.ReplaceAll(s, "\n", "¶")
return s
}
// leftWords returns approximately maxLen characters from the left part of the source string by truncating on the right,
// with best effort to include whole words.
func leftWords(s string, maxLen int) string {
if len(s) < maxLen {
return s
}
fields := strings.Fields(s)
fields = words(fields, maxLen)
return strings.Join(fields, " ")
}
// rightWords returns approximately maxLen from the right part of the source string by truncating from the left,
// with best effort to include whole words.
func rightWords(s string, maxLen int) string {
if len(s) < maxLen {
return s
}
fields := strings.Fields(s)
// reverse the fields so that the right-most words end up at the beginning.
reverse(fields)
fields = words(fields, maxLen)
// reverse the fields again so that the original order is restored.
reverse(fields)
return strings.Join(fields, " ")
}
func reverse(ss []string) {
ssLen := len(ss)
for i := 0; i < ssLen/2; i++ {
ss[i], ss[ssLen-i-1] = ss[ssLen-i-1], ss[i]
}
}
// words returns a subslice containing approximately maxChars of characters. The last item may be truncated.
func words(words []string, maxChars int) []string {
var count int
result := make([]string, 0, len(words))
for i, w := range words {
wordLen := len(w)
if wordLen+count > maxChars {
switch {
case i == 0:
result = append(result, w[:maxChars])
case wordLen < 8:
result = append(result, w)
}
return result
}
count += wordLen
result = append(result, w)
}
return result
}