Files
nexarch/dms/internal/versioning/diff.go
T
patrickandClaude Sonnet 5 76704330eb VER-03: änderungshistorie & diff-berechnung
Myers-Zeilendiff (DiffText) und Metadaten-Vorher-Nachher (DiffMetadata) im
versioning-Paket, DiffRevisions als fertige Schnittstelle für VER-04.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:21:00 +02:00

201 lines
5.3 KiB
Go

package versioning
import (
"context"
"fmt"
"strconv"
"strings"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
"github.com/jackc/pgx/v5/pgxpool"
)
// DiffOp beschreibt eine einzelne Zeilen-Operation eines Textvergleichs.
type DiffOp string
const (
DiffEqual DiffOp = "equal"
DiffInsert DiffOp = "insert"
DiffDelete DiffOp = "delete"
)
// TextDiffLine ist eine Zeile des Ergebnisses von DiffText.
type TextDiffLine struct {
Op DiffOp
Text string
}
// DiffText vergleicht zwei Texte zeilenweise (Akzeptanzkriterium 1). Nutzt
// den Myers-Diff-Algorithmus (O(N*D), D = Anzahl Unterschiede) statt einer
// vollen O(N*M)-LCS-Matrix, damit auch große, weitgehend gleiche Dokumente
// (Pflichtprüfung 3) ohne quadratischen Speicherbedarf vergleichbar bleiben.
func DiffText(oldText, newText string) []TextDiffLine {
oldLines := splitLines(oldText)
newLines := splitLines(newText)
return myersDiff(oldLines, newLines)
}
func splitLines(s string) []string {
if s == "" {
return []string{}
}
return strings.Split(s, "\n")
}
// myersDiff implementiert Eugene Myers' "An O(ND) Difference Algorithm"
// über das Trace der Kürzeste-Editierskript-Suche und läuft es danach
// rückwärts ab, um die eigentliche Operationenfolge zu rekonstruieren.
func myersDiff(a, b []string) []TextDiffLine {
n, m := len(a), len(b)
max := n + m
if max == 0 {
return []TextDiffLine{}
}
offset := max
size := 2*max + 1
trace := make([][]int, 0, max+1)
v := make([]int, size)
found := false
var foundD int
for d := 0; d <= max && !found; d++ {
snapshot := make([]int, size)
copy(snapshot, v)
for k := -d; k <= d; k += 2 {
var x int
if k == -d || (k != d && v[offset+k-1] < v[offset+k+1]) {
x = v[offset+k+1]
} else {
x = v[offset+k-1] + 1
}
y := x - k
for x < n && y < m && a[x] == b[y] {
x++
y++
}
v[offset+k] = x
if x >= n && y >= m {
foundD = d
found = true
}
}
trace = append(trace, snapshot)
}
x, y := n, m
var ops []TextDiffLine
for d := foundD; d > 0; d-- {
v := trace[d]
k := x - y
var prevK int
if k == -d || (k != d && v[offset+k-1] < v[offset+k+1]) {
prevK = k + 1
} else {
prevK = k - 1
}
prevX := v[offset+prevK]
prevY := prevX - prevK
for x > prevX && y > prevY {
ops = append(ops, TextDiffLine{Op: DiffEqual, Text: a[x-1]})
x--
y--
}
if x == prevX {
ops = append(ops, TextDiffLine{Op: DiffInsert, Text: b[y-1]})
y--
} else {
ops = append(ops, TextDiffLine{Op: DiffDelete, Text: a[x-1]})
x--
}
}
for x > 0 && y > 0 {
ops = append(ops, TextDiffLine{Op: DiffEqual, Text: a[x-1]})
x--
y--
}
for i, j := 0, len(ops)-1; i < j; i, j = i+1, j-1 {
ops[i], ops[j] = ops[j], ops[i]
}
return ops
}
// MetadataDiff ist eine Vorher-Nachher-Änderung eines Revisionsfeldes
// (Akzeptanzkriterium 2).
type MetadataDiff struct {
Field string
Before string
After string
}
// DiffMetadata vergleicht die Revisions-Metadaten zweier Revisionen und
// liefert nur tatsächlich geänderte Felder. Verglichen werden die Felder,
// die je Revision individuell gesetzt werden (MIME-Typ, Größe, Kommentar,
// Ersteller) — Prüfsumme und Zeitstempel sind für JEDE Revision naturgemäß
// unterschiedlich und daher als "Änderung" nicht aussagekräftig, sie werden
// bewusst nicht mit aufgeführt.
func DiffMetadata(before, after Revision) []MetadataDiff {
var diffs []MetadataDiff
add := func(field, b, a string) {
if b != a {
diffs = append(diffs, MetadataDiff{Field: field, Before: b, After: a})
}
}
add("mime_type", before.MimeType, after.MimeType)
add("size_bytes", strconv.FormatInt(before.SizeBytes, 10), strconv.FormatInt(after.SizeBytes, 10))
add("comment", derefOrEmpty(before.Comment), derefOrEmpty(after.Comment))
add("created_by", before.CreatedBy, after.CreatedBy)
if diffs == nil {
diffs = []MetadataDiff{}
}
return diffs
}
func derefOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
// DiffRevisions lädt zwei Revisionen, entschlüsselt beide Inhalte und
// liefert sowohl den Zeilen-Textvergleich als auch den Metadaten-Vergleich
// (Akzeptanzkriterien 1+2 in einem Aufruf, für VER-04 als fertige
// Schnittstelle vorbereitet).
func DiffRevisions(
ctx context.Context, pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string,
documentID, revisionIDA, revisionIDB string,
) ([]TextDiffLine, []MetadataDiff, error) {
revisions, err := ListRevisions(ctx, pool, documentID)
if err != nil {
return nil, nil, err
}
revByID := make(map[string]Revision, len(revisions))
for _, r := range revisions {
revByID[r.ID] = r
}
revA, ok := revByID[revisionIDA]
if !ok {
return nil, nil, ErrRevisionNotFound
}
revB, ok := revByID[revisionIDB]
if !ok {
return nil, nil, ErrRevisionNotFound
}
contentA, _, err := GetRevisionContent(ctx, pool, storageSvc, cryptoSvc, tenantSlug, documentID, revisionIDA)
if err != nil {
return nil, nil, fmt.Errorf("versioning: revision a laden: %w", err)
}
contentB, _, err := GetRevisionContent(ctx, pool, storageSvc, cryptoSvc, tenantSlug, documentID, revisionIDB)
if err != nil {
return nil, nil, fmt.Errorf("versioning: revision b laden: %w", err)
}
textDiff := DiffText(string(contentA), string(contentB))
metadataDiff := DiffMetadata(revA, revB)
return textDiff, metadataDiff, nil
}