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