munin/internal/importer/importer.go
Thomas Adamek cf35c3f5db Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)
- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:47 +02:00

254 lines
8.2 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"
)
// 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))
}