munin/internal/importer/parse.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

274 lines
8.2 KiB
Go

package importer
import (
"errors"
"fmt"
"path"
"strings"
"time"
"munin/internal/store"
)
// Eintrag ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben.
type Eintrag struct {
Pfad string // Pfad innerhalb des Archivs, für die Anzeige
Slug string
Title string
Content string
Tags []string
Source string // leer = nicht angegeben, der Importer setzt dann "human"
Author string // leer = nicht angegeben, der Importer setzt den importierenden Zugang
Curated bool
// Zeitstempel aus dem Kopf der Datei. Leer, wenn keiner angegeben oder
// lesbar war; dann gilt beim Anlegen „jetzt“.
CreatedAt time.Time
UpdatedAt time.Time
}
// Parse liest eine Markdown-Datei.
//
// # Zwei Arten von Dateien
//
// 1. Dateien aus dem Munin-Export (internal/export). Sie tragen im Kopf alle
// Felder: slug, title, tags, source, author, curated, created_at,
// updated_at. Daraus entsteht der Eintrag so, wie er exportiert wurde.
//
// 2. Beliebige Markdown-Dateien, etwa vorhandene Dokumentation eines Teams.
// Sie haben meist keinen Kopf oder einen mit anderen Feldern. Dann gilt:
// Bezeichner aus dem Dateinamen, Titel aus der ersten Überschrift „# …“
// oder, falls es keine gibt, aus dem Dateinamen.
//
// Felder, die Munin nicht kennt, werden ignoriert. So scheitert der Import
// nicht an einem Kopf aus Hugo, Obsidian oder Jekyll.
func Parse(pfad string, inhalt []byte) (Eintrag, error) {
e := Eintrag{Pfad: pfad}
// Windows-Zeilenenden und ein Byte-Order-Mark am Anfang würden sonst den
// Kopf unkenntlich machen. Beides kommt bei Dateien aus Editoren unter
// Windows häufig vor.
text := strings.ReplaceAll(string(inhalt), "\r\n", "\n")
text = strings.TrimPrefix(text, "\uFEFF")
kopf, rumpf, err := trenneKopf(text)
if err != nil {
return e, err
}
for name, wert := range kopf {
switch name {
case "slug":
e.Slug = wert.text
case "title":
e.Title = wert.text
case "tags":
e.Tags = wert.liste
if len(e.Tags) == 0 && wert.text != "" {
// "tags: ops" ohne Klammern: ein einzelnes Schlagwort.
e.Tags = []string{wert.text}
}
case "source":
if wert.text == store.SourceHuman || wert.text == store.SourceAgent {
e.Source = wert.text
}
case "author":
e.Author = wert.text
case "curated":
e.Curated = wert.text == "true"
case "created_at":
e.CreatedAt = leseZeit(wert.text)
case "updated_at":
e.UpdatedAt = leseZeit(wert.text)
}
}
// Der Export schreibt den Titel zusätzlich als Überschrift über den
// Inhalt (siehe export.rendere). Die gehört nicht zum Inhalt und muss
// wieder weg, sonst stünde sie nach jedem Export und Import einmal mehr
// da. Eine Überschrift, die NICHT dem Titel entspricht, bleibt stehen:
// Dann ist sie Teil des Textes.
rumpf = strings.TrimLeft(rumpf, "\n")
if ueberschrift, rest, ok := ersteUeberschrift(rumpf); ok {
switch {
case e.Title == "":
e.Title = ueberschrift
rumpf = rest
case ueberschrift == e.Title:
rumpf = rest
}
}
e.Content = strings.TrimSpace(rumpf)
name := strings.TrimSuffix(path.Base(pfad), path.Ext(pfad))
if e.Title == "" {
e.Title = name
}
if e.Slug == "" {
e.Slug = store.Slugify(name)
}
if !store.ValidSlug(e.Slug) {
return e, fmt.Errorf("ungültiger Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", e.Slug)
}
if e.Content == "" {
return e, errors.New("die Datei hat keinen Inhalt")
}
return e, nil
}
// ersteUeberschrift erkennt eine Überschrift erster Ebene („# Titel“) in der
// ersten Zeile und gibt sie samt dem Rest des Textes zurück.
func ersteUeberschrift(text string) (ueberschrift, rest string, ok bool) {
zeile, rest, _ := strings.Cut(text, "\n")
if !strings.HasPrefix(zeile, "# ") {
return "", text, false
}
return strings.TrimSpace(zeile[2:]), rest, true
}
// leseZeit versteht die Schreibweise des Exports (RFC 3339) und ein bloßes
// Datum. Alles andere ergibt den Nullzeitpunkt, also „jetzt“ beim Anlegen.
// Ein unlesbares Datum ist kein Grund, eine ganze Datei abzulehnen.
func leseZeit(s string) time.Time {
for _, format := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(format, s); err == nil {
return t
}
}
return time.Time{}
}
// ── Kopf (Frontmatter) ───────────────────────────────────────────────────────
// kopfWert ist ein Feld aus dem Kopf: entweder ein einzelner Text oder eine
// Liste.
type kopfWert struct {
text string
liste []string
}
// trenneKopf trennt einen Kopf zwischen zwei Zeilen „---“ vom Rest der Datei.
// Ohne Kopf ist die ganze Datei der Rumpf.
//
// # Warum kein YAML-Paket?
//
// YAML ist groß und voller Überraschungen: „no“ wird zu false, „1.10“ zu 1.1.
// Gebraucht wird nur ein kleiner Teil davon: der, den der Export schreibt,
// plus die üblichen Formen von Schlagwortlisten aus anderen Werkzeugen. Dafür
// reichen ein paar Zeilen, die man vollständig lesen kann, und das Programm
// bekommt keine zusätzliche Abhängigkeit.
//
// Verstanden wird:
//
// name: "Text mit \"Maskierung\"" (so schreibt es der Export)
// name: 'Text'
// name: Text ohne Anführungszeichen
// tags: ["a", "b"] oder tags: [a, b]
// tags:
// - a
// - b
func trenneKopf(text string) (map[string]kopfWert, string, error) {
if !strings.HasPrefix(text, "---\n") {
return nil, text, nil
}
zeilen := strings.Split(text, "\n")
kopf := make(map[string]kopfWert)
var offeneListe string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt
for i := 1; i < len(zeilen); i++ {
zeile := zeilen[i]
if strings.TrimSpace(zeile) == "---" {
return kopf, strings.Join(zeilen[i+1:], "\n"), nil
}
// Listenpunkt einer Blockliste, etwa " - ops" unter "tags:".
if eingerueckt := strings.TrimSpace(zeile); offeneListe != "" && strings.HasPrefix(eingerueckt, "- ") {
w := kopf[offeneListe]
w.liste = append(w.liste, einzelwert(strings.TrimSpace(eingerueckt[2:])))
kopf[offeneListe] = w
continue
}
offeneListe = ""
if strings.TrimSpace(zeile) == "" || strings.HasPrefix(strings.TrimSpace(zeile), "#") {
continue
}
name, wert, ok := strings.Cut(zeile, ":")
if !ok {
continue // keine Zuweisung, etwas, das dieser kleine Leser nicht kennt
}
name = strings.TrimSpace(name)
wert = strings.TrimSpace(wert)
switch {
case wert == "":
offeneListe = name
kopf[name] = kopfWert{}
case strings.HasPrefix(wert, "["):
kopf[name] = kopfWert{liste: inlineListe(wert)}
default:
kopf[name] = kopfWert{text: einzelwert(wert)}
}
}
return nil, "", errors.New("der Kopf der Datei (zwischen den Zeilen \"---\") ist nicht abgeschlossen")
}
// einzelwert entfernt Anführungszeichen und löst die Maskierungen auf, die
// der Export schreibt (siehe export.yamlWert).
func einzelwert(s string) string {
switch {
case len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"':
return strings.NewReplacer(`\\`, `\`, `\"`, `"`, `\n`, "\n", `\t`, "\t").Replace(s[1 : len(s)-1])
case len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'':
// In YAML steht '' innerhalb einfacher Anführungszeichen für ein '.
return strings.ReplaceAll(s[1:len(s)-1], "''", "'")
}
return s
}
// inlineListe zerlegt „[a, "b, c", d]“ in seine Bestandteile. Kommas innerhalb
// von Anführungszeichen trennen nicht.
func inlineListe(s string) []string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
var teile []string
var aktuell strings.Builder
var inZeichen byte // 0 = außerhalb, sonst das öffnende Anführungszeichen
fertig := func() {
if t := strings.TrimSpace(aktuell.String()); t != "" {
teile = append(teile, einzelwert(t))
}
aktuell.Reset()
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inZeichen == '"' && c == '\\' && i+1 < len(s):
// Maskiertes Zeichen: beide Bytes übernehmen, einzelwert löst es auf.
aktuell.WriteByte(c)
i++
aktuell.WriteByte(s[i])
case inZeichen != 0 && c == inZeichen:
inZeichen = 0
aktuell.WriteByte(c)
case inZeichen == 0 && (c == '"' || c == '\''):
inZeichen = c
aktuell.WriteByte(c)
case inZeichen == 0 && c == ',':
fertig()
default:
aktuell.WriteByte(c)
}
}
fertig()
return teile
}