munin/internal/importer/importer.go
2026-10-19 10:13:56 +02:00

254 lines
8 KiB
Go

// Package importer liest Markdown-Dateien in ein Projekt ein, als Gegenstück
// zum Export (siehe AUSBAUSTUFEN.md 3.2).
//
// # Wozu?
//
// - Umzug auf einen anderen Server
// - Wiederherstellung aus der Markdown-Sicherung
// - Erstbefüllung aus vorhandener Dokumentation eines Teams
//
// # Was passiert bei einem Zusammenstoß?
//
// Gibt es einen Bezeichner schon im Projekt, entscheidet der Modus:
//
// - Überspringen (Standard): Der vorhandene Eintrag bleibt, wie er ist. So
// lässt sich derselbe Import gefahrlos wiederholen, und nichts, was ein
// Team seit der Sicherung geschrieben hat, geht verloren.
// - Überschreiben: Die Datei wird zur neuen Fassung. Die bisherige wandert
// wie bei jeder Änderung in den Verlauf und lässt sich von dort
// zurückholen. Überschreiben ist also nie endgültig.
//
// Ein „Abbrechen bei Zusammenstoß“ gibt es nicht. Dafür ist die Vorschau da:
// Sie zeigt vorher Zeile für Zeile, was passieren würde, ohne etwas zu
// schreiben.
//
// Ist der Inhalt einer Datei gleich dem vorhandenen Eintrag, wird in beiden
// Modi nichts geschrieben. Ein wiederholter Import erzeugt so keine leeren
// Fassungen im Verlauf.
package importer
import (
"context"
"fmt"
"slices"
"munin/internal/store"
)
// Mode legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist.
type Mode string
const (
ModeSkip = Mode("skip")
ModeOverwrite = Mode("overwrite")
)
// Action ist das Ergebnis für eine einzelne Datei.
type Action string
const (
ActionCreated = Action("neu")
ActionOverwritten = Action("überschrieben")
ActionUnchanged = Action("unverändert")
ActionSkipped = Action("übersprungen")
ActionFailed = Action("Fehler")
)
// Options steuern einen Import.
type Options struct {
Mode Mode
// Preview: nur ausrechnen, was passieren würde, nichts schreiben.
Preview bool
// Author steht an Einträgen, deren Datei keinen Autor nennt, etwa bei
// vorhandener Dokumentation. Üblich ist der Name des importierenden
// Zugangs.
Author string
}
// Row ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt.
type Row struct {
Path string
Slug string
Title string
Action Action
Reason string // Erläuterung, etwa bei Fehler oder Übersprungen
}
// Report fasst einen Import zusammen.
type Report struct {
Rows []Row
Created int
Overwritten int
Unchanged int
Skipped int
Failed int
// Written sind die tatsächlich geschriebenen Einträge. Der Aufrufer
// braucht sie, um den Suchindex nachzuziehen. Bei einer Vorschau leer.
Written []store.Entry
}
// Summary ist eine Zeile für Meldung und Protokoll.
func (b Report) Summary() string {
return fmt.Sprintf("%d neu, %d überschrieben, %d unverändert, %d übersprungen, %d Fehler",
b.Created, b.Overwritten, b.Unchanged, b.Skipped, b.Failed)
}
// Import liest die Dateien in das Projekt ein, oder rechnet bei
// opt.Preview nur aus, was passieren würde.
//
// Ein Fehler in einer einzelnen Datei bricht den Import nicht ab. Er steht im
// Report, und die übrigen Dateien laufen weiter. Nur ein Fehler der
// Datenbank selbst beendet den Import. Was bis dahin geschrieben war, bleibt
// geschrieben: Jeder Eintrag ist für sich vollständig oder gar nicht da (eine
// Transaktion je Eintrag), aber der Import als Ganzes ist keine Transaktion.
// Ihn danach im Modus „Überspringen“ zu wiederholen, macht den Rest.
func Import(ctx context.Context, s *store.Store, projectID string, files []File, opt Options) (Report, error) {
var b Report
if opt.Mode != ModeSkip && opt.Mode != ModeOverwrite {
return b, fmt.Errorf("unbekannter Modus %q", opt.Mode)
}
// Den Bestand einmal vorab laden statt je Datei nachzufragen: Bei einigen
// tausend Dateien sind das zwei Abfragen statt einiger tausend.
existing, err := s.AllEntries(ctx, projectID)
if err != nil {
return b, err
}
inventory := make(map[string]store.Entry, len(existing))
for _, e := range existing {
inventory[e.Slug] = e
}
// Einträge im Papierkorb belegen ihren Bezeichner weiter (siehe
// store.PutEntry). Sie müssen eigens erkannt werden: Sonst holte ein
// Import im Modus „Überspringen“ gelöschte Einträge stillschweigend
// zurück, weil sie für AllEntries nicht existieren.
deleted, err := s.ListTrash(ctx, projectID, "")
if err != nil {
return b, err
}
inTrash := make(map[string]bool, len(deleted))
for _, e := range deleted {
inTrash[e.Slug] = true
}
// Welche Datei hat welchen Bezeichner schon belegt? Zwei Dateien mit
// demselben Bezeichner in einem Archiv (etwa "ops/deploy.md" und
// "alt/deploy.md") würden sich sonst gegenseitig überschreiben.
usedBy := make(map[string]string)
for _, d := range files {
e, err := Parse(d.Path, d.Content)
line := Row{Path: d.Path, Slug: e.Slug, Title: e.Title}
switch {
case err != nil:
line.Action, line.Reason = ActionFailed, err.Error()
case usedBy[e.Slug] != "":
line.Action = ActionFailed
line.Reason = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, usedBy[e.Slug])
default:
usedBy[e.Slug] = d.Path
old, exists := inventory[e.Slug]
switch {
case exists && sameContent(old, e):
line.Action = ActionUnchanged
case (exists || inTrash[e.Slug]) && opt.Mode == ModeSkip:
line.Action = ActionSkipped
line.Reason = "Bezeichner schon vorhanden"
if inTrash[e.Slug] {
line.Reason = "Bezeichner gehört einem Eintrag im Papierkorb"
}
case exists || inTrash[e.Slug]:
line.Action = ActionOverwritten
line.Reason = "bisheriger Stand steht im Verlauf"
if inTrash[e.Slug] {
line.Reason = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf"
}
default:
line.Action = ActionCreated
}
if !opt.Preview && (line.Action == ActionCreated || line.Action == ActionOverwritten) {
written, err := s.PutEntry(ctx, writeRequest(projectID, e, opt.Author, line.Action == ActionCreated))
if err != nil {
// Ungültige Angaben (etwa ein zu langer Titel) meldet
// PutEntry vor jedem Schreibzugriff. Sie betreffen nur
// diese Datei. Ein Fehler der Datenbank dagegen würde
// auch jede weitere Datei treffen: Dann lieber aufhören.
if ctx.Err() != nil {
return b, ctx.Err()
}
line.Action, line.Reason = ActionFailed, err.Error()
} else {
b.Written = append(b.Written, written)
}
}
}
b.Rows = append(b.Rows, line)
switch line.Action {
case ActionCreated:
b.Created++
case ActionOverwritten:
b.Overwritten++
case ActionUnchanged:
b.Unchanged++
case ActionSkipped:
b.Skipped++
case ActionFailed:
b.Failed++
}
}
return b, nil
}
// writeRequest macht aus einer ausgewerteten Datei einen Schreibvorgang.
//
// Der Autor hängt davon ab, was passiert. Beim Anlegen bleibt der aus der
// Datei, denn eine Wiederherstellung soll zeigen, wer den Eintrag
// ursprünglich geschrieben hat. Beim Überschreiben steht der importierende
// Zugang da: Diese Fassung hat er erzeugt, und so steht es auch im Verlauf.
func writeRequest(projectID string, e Entry, author string, isNew bool) store.EntryWrite {
w := store.EntryWrite{
ProjectID: projectID,
Slug: e.Slug,
Title: e.Title,
Content: e.Content,
Tags: e.Tags,
Source: e.Source,
Author: e.Author,
Curated: e.Curated,
// Wirken nur beim Anlegen, siehe store.EntryWrite.
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if w.Source == "" {
w.Source = store.SourceHuman
}
if w.Author == "" || !isNew {
w.Author = author
}
return w
}
// sameContent sagt, ob eine Datei nichts am vorhandenen Eintrag ändern
// würde. Verglichen wird, was ein Mensch als Inhalt ansieht: Titel, Text,
// Schlagworte, Prüfvermerk. Autor und Zeitstempel zählen nicht. Sonst wäre
// nach einem Export und Re-Import nie etwas „unverändert“.
func sameContent(old store.Entry, newer Entry) bool {
return old.Title == newer.Title &&
old.Content == newer.Content &&
old.Curated == newer.Curated &&
slices.Equal(old.Tags, store.NormalizeTags(newer.Tags))
}