munin/internal/importer/parse.go
2026-10-19 10:13:56 +02:00

274 lines
8.2 KiB
Go

package importer
import (
"errors"
"fmt"
"path"
"strings"
"time"
"munin/internal/store"
)
// Entry ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben.
type Entry struct {
Path 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(filePath string, content []byte) (Entry, error) {
e := Entry{Path: filePath}
// 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(content), "\r\n", "\n")
text = strings.TrimPrefix(text, "\uFEFF")
head, body, err := splitFrontmatter(text)
if err != nil {
return e, err
}
for name, value := range head {
switch name {
case "slug":
e.Slug = value.text
case "title":
e.Title = value.text
case "tags":
e.Tags = value.list
if len(e.Tags) == 0 && value.text != "" {
// "tags: ops" ohne Klammern: ein einzelnes Schlagwort.
e.Tags = []string{value.text}
}
case "source":
if value.text == store.SourceHuman || value.text == store.SourceAgent {
e.Source = value.text
}
case "author":
e.Author = value.text
case "curated":
e.Curated = value.text == "true"
case "created_at":
e.CreatedAt = parseTimestamp(value.text)
case "updated_at":
e.UpdatedAt = parseTimestamp(value.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.
body = strings.TrimLeft(body, "\n")
if heading, rest, ok := firstHeading(body); ok {
switch {
case e.Title == "":
e.Title = heading
body = rest
case heading == e.Title:
body = rest
}
}
e.Content = strings.TrimSpace(body)
name := strings.TrimSuffix(path.Base(filePath), path.Ext(filePath))
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
}
// firstHeading erkennt eine Überschrift erster Ebene („# Titel“) in der
// ersten Zeile und gibt sie samt dem Rest des Textes zurück.
func firstHeading(text string) (heading, rest string, ok bool) {
line, rest, _ := strings.Cut(text, "\n")
if !strings.HasPrefix(line, "# ") {
return "", text, false
}
return strings.TrimSpace(line[2:]), rest, true
}
// parseTimestamp 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 parseTimestamp(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) ───────────────────────────────────────────────────────
// headValue ist ein Feld aus dem Kopf: entweder ein einzelner Text oder eine
// Liste.
type headValue struct {
text string
list []string
}
// splitFrontmatter 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 splitFrontmatter(text string) (map[string]headValue, string, error) {
if !strings.HasPrefix(text, "---\n") {
return nil, text, nil
}
lines := strings.Split(text, "\n")
head := make(map[string]headValue)
var openList string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt
for i := 1; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "---" {
return head, strings.Join(lines[i+1:], "\n"), nil
}
// Listenpunkt einer Blockliste, etwa " - ops" unter "tags:".
if indented := strings.TrimSpace(line); openList != "" && strings.HasPrefix(indented, "- ") {
w := head[openList]
w.list = append(w.list, singleValue(strings.TrimSpace(indented[2:])))
head[openList] = w
continue
}
openList = ""
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
name, value, ok := strings.Cut(line, ":")
if !ok {
continue // keine Zuweisung, etwas, das dieser kleine Leser nicht kennt
}
name = strings.TrimSpace(name)
value = strings.TrimSpace(value)
switch {
case value == "":
openList = name
head[name] = headValue{}
case strings.HasPrefix(value, "["):
head[name] = headValue{list: inlineList(value)}
default:
head[name] = headValue{text: singleValue(value)}
}
}
return nil, "", errors.New("der Kopf der Datei (zwischen den Zeilen \"---\") ist nicht abgeschlossen")
}
// singleValue entfernt Anführungszeichen und löst die Maskierungen auf, die
// der Export schreibt (siehe export.yamlWert).
func singleValue(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
}
// inlineList zerlegt „[a, "b, c", d]“ in seine Bestandteile. Kommas innerhalb
// von Anführungszeichen trennen nicht.
func inlineList(s string) []string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
var parts []string
var current strings.Builder
var inChars byte // 0 = außerhalb, sonst das öffnende Anführungszeichen
done := func() {
if t := strings.TrimSpace(current.String()); t != "" {
parts = append(parts, singleValue(t))
}
current.Reset()
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inChars == '"' && c == '\\' && i+1 < len(s):
// Maskiertes Zeichen: beide Bytes übernehmen, singleValue löst es auf.
current.WriteByte(c)
i++
current.WriteByte(s[i])
case inChars != 0 && c == inChars:
inChars = 0
current.WriteByte(c)
case inChars == 0 && (c == '"' || c == '\''):
inChars = c
current.WriteByte(c)
case inChars == 0 && c == ',':
done()
default:
current.WriteByte(c)
}
}
done()
return parts
}