// 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" ) // Modus legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist. type Modus string const ( ModusUeberspringen = Modus("skip") ModusUeberschreiben = Modus("overwrite") ) // Aktion ist das Ergebnis für eine einzelne Datei. type Aktion string const ( AktionNeu = Aktion("neu") AktionUeberschrieben = Aktion("überschrieben") AktionUnveraendert = Aktion("unverändert") AktionUebersprungen = Aktion("übersprungen") AktionFehler = Aktion("Fehler") ) // Optionen steuern einen Import. type Optionen struct { Modus Modus // Vorschau: nur ausrechnen, was passieren würde, nichts schreiben. Vorschau bool // Autor steht an Einträgen, deren Datei keinen Autor nennt, etwa bei // vorhandener Dokumentation. Üblich ist der Name des importierenden // Zugangs. Autor string } // Zeile ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt. type Zeile struct { Pfad string Slug string Title string Aktion Aktion Grund string // Erläuterung, etwa bei Fehler oder Übersprungen } // Bericht fasst einen Import zusammen. type Bericht struct { Zeilen []Zeile Neu int Ueberschrieben int Unveraendert int Uebersprungen int Fehler int // Geschrieben sind die tatsächlich geschriebenen Einträge. Der Aufrufer // braucht sie, um den Suchindex nachzuziehen. Bei einer Vorschau leer. Geschrieben []store.Entry } // Zusammenfassung ist eine Zeile für Meldung und Protokoll. func (b Bericht) Zusammenfassung() string { return fmt.Sprintf("%d neu, %d überschrieben, %d unverändert, %d übersprungen, %d Fehler", b.Neu, b.Ueberschrieben, b.Unveraendert, b.Uebersprungen, b.Fehler) } // Importiere liest die Dateien in das Projekt ein, oder rechnet bei // opt.Vorschau nur aus, was passieren würde. // // Ein Fehler in einer einzelnen Datei bricht den Import nicht ab. Er steht im // Bericht, 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 Importiere(ctx context.Context, s *store.Store, projectID string, dateien []Datei, opt Optionen) (Bericht, error) { var b Bericht if opt.Modus != ModusUeberspringen && opt.Modus != ModusUeberschreiben { return b, fmt.Errorf("unbekannter Modus %q", opt.Modus) } // Den Bestand einmal vorab laden statt je Datei nachzufragen: Bei einigen // tausend Dateien sind das zwei Abfragen statt einiger tausend. vorhanden, err := s.AllEntries(ctx, projectID) if err != nil { return b, err } bestand := make(map[string]store.Entry, len(vorhanden)) for _, e := range vorhanden { bestand[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. geloescht, err := s.ListTrash(ctx, projectID, "") if err != nil { return b, err } imPapierkorb := make(map[string]bool, len(geloescht)) for _, e := range geloescht { imPapierkorb[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. belegtDurch := make(map[string]string) for _, d := range dateien { e, err := Parse(d.Pfad, d.Inhalt) zeile := Zeile{Pfad: d.Pfad, Slug: e.Slug, Title: e.Title} switch { case err != nil: zeile.Aktion, zeile.Grund = AktionFehler, err.Error() case belegtDurch[e.Slug] != "": zeile.Aktion = AktionFehler zeile.Grund = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, belegtDurch[e.Slug]) default: belegtDurch[e.Slug] = d.Pfad alt, gibtEs := bestand[e.Slug] switch { case gibtEs && gleicherInhalt(alt, e): zeile.Aktion = AktionUnveraendert case (gibtEs || imPapierkorb[e.Slug]) && opt.Modus == ModusUeberspringen: zeile.Aktion = AktionUebersprungen zeile.Grund = "Bezeichner schon vorhanden" if imPapierkorb[e.Slug] { zeile.Grund = "Bezeichner gehört einem Eintrag im Papierkorb" } case gibtEs || imPapierkorb[e.Slug]: zeile.Aktion = AktionUeberschrieben zeile.Grund = "bisheriger Stand steht im Verlauf" if imPapierkorb[e.Slug] { zeile.Grund = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf" } default: zeile.Aktion = AktionNeu } if !opt.Vorschau && (zeile.Aktion == AktionNeu || zeile.Aktion == AktionUeberschrieben) { geschrieben, err := s.PutEntry(ctx, schreibauftrag(projectID, e, opt.Autor, zeile.Aktion == AktionNeu)) 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() } zeile.Aktion, zeile.Grund = AktionFehler, err.Error() } else { b.Geschrieben = append(b.Geschrieben, geschrieben) } } } b.Zeilen = append(b.Zeilen, zeile) switch zeile.Aktion { case AktionNeu: b.Neu++ case AktionUeberschrieben: b.Ueberschrieben++ case AktionUnveraendert: b.Unveraendert++ case AktionUebersprungen: b.Uebersprungen++ case AktionFehler: b.Fehler++ } } return b, nil } // schreibauftrag 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 schreibauftrag(projectID string, e Eintrag, autor string, neu 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 == "" || !neu { w.Author = autor } return w } // gleicherInhalt 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 gleicherInhalt(alt store.Entry, neu Eintrag) bool { return alt.Title == neu.Title && alt.Content == neu.Content && alt.Curated == neu.Curated && slices.Equal(alt.Tags, store.NormalizeTags(neu.Tags)) }