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 }