254 lines
8 KiB
Go
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))
|
|
}
|