// 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)) }