2022-01-05 22:11:58 +01:00
|
|
|
package notifysubscriptions
|
|
|
|
|
|
|
|
import (
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"github.com/sergi/go-diff/diffmatchpatch"
|
|
|
|
|
|
|
|
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
|
|
|
)
|
|
|
|
|
2022-07-18 19:21:57 +02:00
|
|
|
func generateMarkdownDiff(oldText string, newText string, logger mlog.LoggerIFace) string {
|
2022-01-05 22:11:58 +01:00
|
|
|
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
|
|
|
|
}
|