munin/internal/export/export.go

396 lines
13 KiB
Go

// Package export schreibt den Inhalt eines Projekts als Markdown-Dateien auf die
// Platte.
//
// # Wozu?
//
// Die Wahrheit steht in der Datenbank — das ist richtig so, weil Einträge
// Datensätze sind und keine Dokumente (siehe internal/store). Eine einzelne
// SQLite-Datei ist aber ein schlechter Ort für eine Sicherung, aus der man
// später auch ohne dieses Programm noch etwas lesen möchte:
//
// - Sie lässt sich nicht sinnvoll versionieren. Ein Git-Diff zweier
// Binärdateien sagt nichts.
// - Sie braucht ein Werkzeug zum Öffnen.
// - Wer nur schnell etwas nachschlagen will, kann nicht grep verwenden.
//
// Der Export löst das, ohne die Datenbank als führendes System aufzugeben: Ein
// Ordner voller Markdown-Dateien, der sich in ein Git-Repository legen lässt.
// Jede Datei trägt alle Felder im Kopf, sodass sich daraus später auch wieder
// importieren ließe.
//
// # Was der Export NICHT ist
//
// Er ist keine zweite Wahrheit. Änderungen an den exportierten Dateien wirken
// nicht zurück — wer sie bearbeitet, verliert seine Arbeit beim nächsten Lauf.
package export
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"munin/internal/store"
)
// Exporter schreibt Projekte in ein Zielverzeichnis.
//
// # Aufbau des Zielverzeichnisses
//
// <root>/
// <workspace-id>/
// _workspace.md Name des Workspace, damit man den Ordner zuordnen kann
// <projekt>/
// _index.md
// <eintrag>.md
//
// Die Ebene je Workspace ist nötig, weil Projekt-Bezeichner nur innerhalb eines
// Workspace eindeutig sind. Ohne sie schrieben zwei Abteilungen mit einem
// Projekt "plattform" in denselben Ordner — und das Aufräumen verwaister
// Dateien löschte bei jedem Lauf die Dateien der jeweils anderen.
//
// Benannt wird der Ordner nach der ID, nicht nach dem Namen: Ein Workspace
// lässt sich umbenennen, und dann entstünde ein neuer Ordner neben dem alten.
//
// Der Exporter kennt keine Datenbank. Welche Workspace-Datei gerade exportiert
// wird, gibt der Aufrufer bei jedem Aufruf mit — so kann ein einzelner
// Exporter alle Workspaces bedienen, statt beim Start an einen gebunden zu
// sein.
type Exporter struct {
root string
}
// New erstellt einen Exporter, der unter root schreibt.
func New(root string) *Exporter {
return &Exporter{root: root}
}
// Stats fasst zusammen, was ein Lauf bewirkt hat.
type Stats struct {
Written int // geschriebene Dateien
Removed int // entfernte Dateien gelöschter Einträge
}
// ExportProject schreibt alle Einträge eines Projekts nach
// <root>/<workspace-id>/<projekt-slug>/. s muss die Workspace-Datei sein, zu
// der das Projekt gehört.
//
// Der Ablauf ist bewusst schlicht: alles schreiben, danach aufräumen, was nicht
// mehr dazugehört. Ein Abgleich wäre sparsamer, aber deutlich fehleranfälliger —
// und der Export läuft selten genug, dass das nicht ins Gewicht fällt.
func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project store.Project) (Stats, error) {
var stats Stats
// Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim
// Projekt-Bezeichner (siehe projektDateien). IDs aus store.NewID bestehen
// nur aus Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese
// Prüfung.
if !store.ValidSlug(project.WorkspaceID) {
return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID)
}
dateien, err := projektDateien(ctx, s, project)
if err != nil {
return stats, err
}
ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug)
if err := os.MkdirAll(ziel, 0750); err != nil {
return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
}
// Namen merken, um danach zu wissen, was übrig bleibt.
erwartet := make(map[string]bool, len(dateien))
for _, d := range dateien {
// Im Ordner des Projekts zählt nur der Dateiname, ohne den
// Projektordner davor.
name := filepath.Base(d.Pfad)
erwartet[name] = true
if err := schreibeDatei(filepath.Join(ziel, name), d.Inhalt); err != nil {
return stats, err
}
stats.Written++
}
entfernt, err := raeumeAuf(ziel, erwartet)
if err != nil {
return stats, err
}
stats.Removed = entfernt
return stats, nil
}
// ExportWorkspace exportiert alle Projekte eines Workspace. s muss die
// Workspace-Datei von ws sein.
func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store.Workspace) (Stats, error) {
var gesamt Stats
if !store.ValidSlug(ws.ID) {
return gesamt, fmt.Errorf("ungültige Workspace-ID %q", ws.ID)
}
ordner := filepath.Join(e.root, ws.ID)
if err := os.MkdirAll(ordner, 0750); err != nil {
return gesamt, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
}
// Ohne diese Datei wäre der Ordner nur eine ID — niemand wüsste beim
// Wiederherstellen, welche Abteilung darin steckt.
if err := schreibeDatei(filepath.Join(ordner, workspaceDateiname), rendereWorkspace(ws)); err != nil {
return gesamt, err
}
gesamt.Written++
projekte, err := s.ListProjects(ctx, ws.ID)
if err != nil {
return gesamt, err
}
for _, p := range projekte {
stats, err := e.ExportProject(ctx, s, p)
if err != nil {
return gesamt, fmt.Errorf("Projekt %q: %w", p.Slug, err)
}
gesamt.Written += stats.Written
gesamt.Removed += stats.Removed
}
return gesamt, nil
}
// projektDateien stellt alle Dateien eines Projekts zusammen, ohne etwas zu
// schreiben: je Eintrag eine Datei und die Übersicht _index.md, alle unter
// "<projekt-slug>/".
//
// Export auf die Platte und Download als ZIP (siehe zip.go) bauen beide
// hierauf auf. So legt nur eine Stelle fest, wie ein Export aussieht — und
// ein heruntergeladenes ZIP passt genauso in den Import wie ein Ordner aus
// EXPORT_DIR.
func projektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) {
// Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe
// store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier
// noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts
// garantiert — ein späterer Fehler an anderer Stelle soll nicht dazu führen,
// dass außerhalb des Zielordners geschrieben wird.
if !store.ValidSlug(project.Slug) {
return nil, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug)
}
eintraege, err := s.AllEntries(ctx, project.ID)
if err != nil {
return nil, err
}
dateien := make([]Datei, 0, len(eintraege)+1)
for _, eintrag := range eintraege {
if !store.ValidSlug(eintrag.Slug) {
return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug)
}
dateien = append(dateien, Datei{
Pfad: project.Slug + "/" + eintrag.Slug + ".md",
Inhalt: rendere(eintrag),
Geaendert: eintrag.UpdatedAt,
})
}
// Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist.
dateien = append(dateien, Datei{
Pfad: project.Slug + "/" + indexDateiname,
Inhalt: rendereIndex(project, eintraege),
})
return dateien, nil
}
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
const indexDateiname = "_index.md"
// workspaceDateiname nennt den Workspace, zu dem ein ID-Ordner gehört.
const workspaceDateiname = "_workspace.md"
// rendereWorkspace baut den Inhalt von _workspace.md. Wie beim Index ohne
// Zeitstempel des Laufs, damit ein unveränderter Workspace keinen Diff erzeugt.
func rendereWorkspace(ws store.Workspace) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "id", ws.ID)
schreibeFeld(&b, "name", ws.Name)
b.WriteString("---\n\n")
b.WriteString("# Workspace " + ws.Name + "\n")
return b.String()
}
// schreibeDatei legt eine Datei an — nur, wenn sich der Inhalt geändert hat.
//
// # Warum der Vergleich?
//
// Der Export ist als Git-Sicherung gedacht. Würde jede Datei bei jedem Lauf neu
// geschrieben, änderte sich zwar der Inhalt nicht, wohl aber die Änderungszeit —
// und Werkzeuge, die darauf schauen, meldeten ständig Bewegung, wo keine ist.
// Git selbst stört das nicht, aber Sicherungsprogramme und Dateiüberwachungen schon.
func schreibeDatei(pfad, inhalt string) error {
if vorhanden, err := os.ReadFile(pfad); err == nil && string(vorhanden) == inhalt {
return nil
}
if err := os.WriteFile(pfad, []byte(inhalt), 0640); err != nil {
return fmt.Errorf("%s konnte nicht geschrieben werden: %w", pfad, err)
}
return nil
}
// raeumeAuf entfernt Markdown-Dateien, die zu keinem Eintrag mehr gehören.
//
// Es werden ausschließlich .md-Dateien angefasst und keine Unterordner — falls
// jemand eigene Notizen daneben legt, bleiben sie unberührt.
func raeumeAuf(ordner string, erwartet map[string]bool) (int, error) {
eintraege, err := os.ReadDir(ordner)
if err != nil {
return 0, fmt.Errorf("Exportordner konnte nicht gelesen werden: %w", err)
}
var entfernt int
for _, e := range eintraege {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || erwartet[e.Name()] {
continue
}
if err := os.Remove(filepath.Join(ordner, e.Name())); err != nil {
return entfernt, fmt.Errorf("%s konnte nicht entfernt werden: %w", e.Name(), err)
}
entfernt++
}
return entfernt, nil
}
// rendere baut den Dateiinhalt eines Eintrags: YAML-Kopf plus Markdown.
//
// Der Kopf enthält alle Felder, damit sich der Bestand daraus auch wieder
// einlesen ließe — nicht nur Titel und Text.
func rendere(e store.Entry) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "slug", e.Slug)
schreibeFeld(&b, "title", e.Title)
if len(e.Tags) > 0 {
b.WriteString("tags: [")
for i, t := range e.Tags {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(yamlWert(t))
}
b.WriteString("]\n")
}
schreibeFeld(&b, "source", e.Source)
if e.Author != "" {
schreibeFeld(&b, "author", e.Author)
}
b.WriteString("curated: " + strconv.FormatBool(e.Curated) + "\n")
b.WriteString("version: " + strconv.Itoa(e.Version) + "\n")
schreibeFeld(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339))
schreibeFeld(&b, "updated_at", e.UpdatedAt.UTC().Format(time.RFC3339))
b.WriteString("---\n\n")
b.WriteString("# " + e.Title + "\n\n")
b.WriteString(strings.TrimRight(e.Content, "\n"))
b.WriteString("\n")
return b.String()
}
// rendereIndex baut die Übersicht eines Projekts, nach Schlagwort gruppiert.
//
// Bewusst OHNE Zeitstempel im Kopf: Er würde sich bei jedem Lauf ändern und
// damit auch dann einen Git-Diff erzeugen, wenn sich inhaltlich nichts getan
// hat. Wann exportiert wurde, sagen der Git-Commit und die Änderungszeit der
// Datei ohnehin — nur eben nur dann, wenn es etwas zu sagen gab.
func rendereIndex(project store.Project, eintraege []store.Entry) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "title", project.Name+" — Übersicht")
b.WriteString("---\n\n")
b.WriteString("# " + project.Name + "\n\n")
if project.Description != "" {
b.WriteString(project.Description + "\n\n")
}
b.WriteString(fmt.Sprintf("%d Einträge. Automatisch erzeugt — Änderungen hier wirken nicht zurück.\n\n",
len(eintraege)))
// Nach erstem Schlagwort gruppieren; Einträge ohne Schlagwort ans Ende.
const ohne = "Ohne Schlagwort"
gruppen := make(map[string][]store.Entry)
for _, e := range eintraege {
kategorie := ohne
if len(e.Tags) > 0 {
kategorie = e.Tags[0]
}
gruppen[kategorie] = append(gruppen[kategorie], e)
}
kategorien := make([]string, 0, len(gruppen))
for k := range gruppen {
kategorien = append(kategorien, k)
}
sort.Slice(kategorien, func(i, j int) bool {
if kategorien[i] == ohne {
return false
}
if kategorien[j] == ohne {
return true
}
return kategorien[i] < kategorien[j]
})
for _, kategorie := range kategorien {
b.WriteString("## " + kategorie + "\n\n")
liste := gruppen[kategorie]
sort.Slice(liste, func(i, j int) bool { return liste[i].Title < liste[j].Title })
for _, e := range liste {
b.WriteString(fmt.Sprintf("- [%s](%s.md)", e.Title, e.Slug))
if e.Curated {
b.WriteString(" ✓")
}
if e.Source == store.SourceAgent {
b.WriteString(" _(Agent)_")
}
b.WriteString("\n")
}
b.WriteString("\n")
}
return b.String()
}
// schreibeFeld hängt ein YAML-Feld an.
func schreibeFeld(b *strings.Builder, name, wert string) {
b.WriteString(name + ": " + yamlWert(wert) + "\n")
}
// yamlWert bringt einen Wert in eine sichere YAML-Schreibweise.
//
// Immer in doppelte Anführungszeichen und die enthaltenen maskieren: Damit ist
// egal, ob der Wert einen Doppelpunkt, ein Rautezeichen oder einen Zeilenumbruch
// enthält. Ohne diese Vorsicht würde ein Titel wie "Fehler: Zugriff verweigert"
// die Datei unlesbar machen.
func yamlWert(s string) string {
ersetzt := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`,
"\r", ``,
"\t", `\t`,
).Replace(s)
return `"` + ersetzt + `"`
}