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