309 lines
9.4 KiB
Go
309 lines
9.4 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.
|
|
type Exporter struct {
|
|
store *store.Store
|
|
root string
|
|
}
|
|
|
|
// New erstellt einen Exporter, der unter root schreibt.
|
|
func New(s *store.Store, root string) *Exporter {
|
|
return &Exporter{store: s, 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>/<projekt-slug>/.
|
|
//
|
|
// 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, project store.Project) (Stats, error) {
|
|
var stats Stats
|
|
|
|
// 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 hier außerhalb des Zielordners geschrieben wird.
|
|
if !store.ValidSlug(project.Slug) {
|
|
return stats, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug)
|
|
}
|
|
|
|
ziel := filepath.Join(e.root, project.Slug)
|
|
if err := os.MkdirAll(ziel, 0750); err != nil {
|
|
return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
|
|
}
|
|
|
|
eintraege, err := e.store.AllEntries(ctx, project.ID)
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
|
|
// Namen merken, um danach zu wissen, was übrig bleibt.
|
|
erwartet := make(map[string]bool, len(eintraege)+1)
|
|
erwartet[indexDateiname] = true
|
|
|
|
for _, eintrag := range eintraege {
|
|
if !store.ValidSlug(eintrag.Slug) {
|
|
return stats, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug)
|
|
}
|
|
|
|
name := eintrag.Slug + ".md"
|
|
erwartet[name] = true
|
|
|
|
if err := schreibeDatei(filepath.Join(ziel, name), rendere(eintrag)); err != nil {
|
|
return stats, err
|
|
}
|
|
stats.Written++
|
|
}
|
|
|
|
// Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist.
|
|
if err := schreibeDatei(filepath.Join(ziel, indexDateiname), rendereIndex(project, eintraege)); err != nil {
|
|
return stats, err
|
|
}
|
|
stats.Written++
|
|
|
|
entfernt, err := raeumeAuf(ziel, erwartet)
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
stats.Removed = entfernt
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// ExportAll exportiert alle Projekte eines Workspace.
|
|
func (e *Exporter) ExportAll(ctx context.Context, workspaceID string) (Stats, error) {
|
|
var gesamt Stats
|
|
|
|
projekte, err := e.store.ListProjects(ctx, workspaceID)
|
|
if err != nil {
|
|
return gesamt, err
|
|
}
|
|
|
|
for _, p := range projekte {
|
|
stats, err := e.ExportProject(ctx, 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
|
|
}
|
|
|
|
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
|
|
const indexDateiname = "_index.md"
|
|
|
|
// 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 + `"`
|
|
}
|