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