mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
7ba823004c
* feat(history): version rows carry their writer, line counts and create marker; a diff endpoint pairs each row with its own change (PLAN-2348 U2) * test(history): server legs, export/import leg; web type and API method for the version diff (PLAN-2348 U2) * docs(history): the version diff endpoint and row fields in CLAUDE.md; FormatDiffSummary names what it counts (PLAN-2348 U2) * fix(history): user_id already existed on item_versions (migration 012); a diff refuses a body the chain could not rebuild (PLAN-2348 U2, codex r1)
102 lines
3.4 KiB
Go
102 lines
3.4 KiB
Go
// Package diff provides compact text diffing for version storage.
|
|
// It uses the Myers diff algorithm via go-diff to create and apply patches.
|
|
package diff
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
difflib "github.com/sergi/go-diff/diffmatchpatch"
|
|
)
|
|
|
|
var dmp = difflib.New()
|
|
|
|
// CreatePatch computes a compact patch that transforms oldText into newText.
|
|
// The patch can be applied with ApplyPatch to recover newText from oldText.
|
|
func CreatePatch(oldText, newText string) string {
|
|
diffs := dmp.DiffMain(oldText, newText, true)
|
|
patches := dmp.PatchMake(oldText, diffs)
|
|
return dmp.PatchToText(patches)
|
|
}
|
|
|
|
// ApplyPatch applies a patch to baseText to produce the target text.
|
|
// Returns an error if the patch cannot be applied cleanly.
|
|
func ApplyPatch(baseText, patchText string) (string, error) {
|
|
if patchText == "" {
|
|
return baseText, nil
|
|
}
|
|
patches, err := dmp.PatchFromText(patchText)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse patch: %w", err)
|
|
}
|
|
result, applied := dmp.PatchApply(patches, baseText)
|
|
// Check that all hunks applied
|
|
for i, ok := range applied {
|
|
if !ok {
|
|
return "", fmt.Errorf("patch hunk %d failed to apply", i)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// IsDiffSmaller returns true if storing a diff would be meaningfully smaller
|
|
// than storing the full content. Returns false for near-complete rewrites
|
|
// where the diff is >= 80% of the full content size.
|
|
func IsDiffSmaller(patch, fullContent string) bool {
|
|
if len(fullContent) == 0 {
|
|
return false
|
|
}
|
|
return len(patch) < (len(fullContent) * 80 / 100)
|
|
}
|
|
|
|
// CreateReversePatch computes a patch that transforms newText back into oldText.
|
|
// Used for reverse-diff version storage: current doc has latest content,
|
|
// versions store patches to go backwards.
|
|
func CreateReversePatch(oldText, newText string) string {
|
|
return CreatePatch(newText, oldText)
|
|
}
|
|
|
|
// FormatDiffSummary returns a human-readable summary of changes. Its "lines"
|
|
// are character-diff CHUNKS (a one-character edit reads +1/-1); LineCounts is
|
|
// the line-level count the version rows store (PLAN-2348 U2).
|
|
func FormatDiffSummary(oldText, newText string) string {
|
|
oldLines := strings.Count(oldText, "\n")
|
|
newLines := strings.Count(newText, "\n")
|
|
added := 0
|
|
removed := 0
|
|
diffs := dmp.DiffMain(oldText, newText, false)
|
|
for _, d := range diffs {
|
|
switch d.Type {
|
|
case difflib.DiffInsert:
|
|
added += strings.Count(d.Text, "\n") + 1
|
|
case difflib.DiffDelete:
|
|
removed += strings.Count(d.Text, "\n") + 1
|
|
}
|
|
}
|
|
_ = oldLines
|
|
_ = newLines
|
|
return fmt.Sprintf("+%d/-%d lines", added, removed)
|
|
}
|
|
|
|
// LineCounts returns how many lines a line-level diff of oldText → newText
|
|
// adds and removes (PLAN-2348 U2). It counts the way the web DiffView renders
|
|
// jsdiff's diffLines: each changed run is split on newlines after dropping one
|
|
// trailing newline, so a run "a\nb\n" is two lines. Both sides are Myers
|
|
// line diffs; diffmatchpatch's speed-up heuristics can choose a different,
|
|
// equally valid split on unusual inputs, in which case a count can differ from
|
|
// the rendered diff by the lines those splits trade.
|
|
func LineCounts(oldText, newText string) (added, removed int) {
|
|
a, b, lines := dmp.DiffLinesToChars(oldText, newText)
|
|
diffs := dmp.DiffCharsToLines(dmp.DiffMain(a, b, false), lines)
|
|
for _, d := range diffs {
|
|
n := strings.Count(strings.TrimSuffix(d.Text, "\n"), "\n") + 1
|
|
switch d.Type {
|
|
case difflib.DiffInsert:
|
|
added += n
|
|
case difflib.DiffDelete:
|
|
removed += n
|
|
}
|
|
}
|
|
return added, removed
|
|
}
|