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 }