// 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" "sync" "time" "munin/internal/store" ) // Exporter schreibt Projekte in ein Zielverzeichnis. // // # Aufbau des Zielverzeichnisses // // / // / // _workspace.md Name des Workspace, damit man den Ordner zuordnen kann // / // _index.md // .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 // letzter ist der zuletzt abgeschlossene Lauf über alle Workspaces — // für die Systemseite. Der Mutex schützt ihn, weil der tägliche Lauf in // einer eigenen Goroutine schreibt, während die Seite liest. mu sync.Mutex last Run } // Interval bestimmt, wie oft der Markdown-Export automatisch läuft (siehe // main.go). Die Systemseite meldet einen Export als überfällig, wenn der // letzte Lauf deutlich länger zurückliegt. const Interval = 24 * time.Hour // Run beschreibt einen Export über alle Workspaces. type Run struct { End time.Time // Nullzeitpunkt = seit dem Start noch keiner fertig Duration time.Duration Stats Stats Err string // leer = vollständig } // RecordRun hält das Ergebnis eines Laufs über alle Workspaces fest. Den // Knopf "Exportieren" für einen einzelnen Workspace merkt sich der Exporter // nicht: Er sagt nichts darüber, ob die übrigen gesichert sind. func (e *Exporter) RecordRun(l Run) { e.mu.Lock() defer e.mu.Unlock() e.last = l } // LastRun liefert den zuletzt gemerkten Lauf (Nullwert, wenn noch keiner // fertig ist). func (e *Exporter) LastRun() Run { e.mu.Lock() defer e.mu.Unlock() return e.last } // 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 // ///. 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 projectFiles). 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) } files, err := projectFiles(ctx, s, project) if err != nil { return stats, err } target := filepath.Join(e.root, project.WorkspaceID, project.Slug) if err := os.MkdirAll(target, 0750); err != nil { return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) } // Namen merken, um danach zu wissen, was übrig bleibt. want := make(map[string]bool, len(files)) for _, d := range files { // Im Ordner des Projekts zählt nur der Dateiname, ohne den // Projektordner davor. name := filepath.Base(d.Path) want[name] = true if err := writeFile(filepath.Join(target, name), d.Content); err != nil { return stats, err } stats.Written++ } removed, err := cleanup(target, want) if err != nil { return stats, err } stats.Removed = removed 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 total Stats if !store.ValidSlug(ws.ID) { return total, fmt.Errorf("ungültige Workspace-ID %q", ws.ID) } dir := filepath.Join(e.root, ws.ID) if err := os.MkdirAll(dir, 0750); err != nil { return total, 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 := writeFile(filepath.Join(dir, workspaceFilename), renderWorkspace(ws)); err != nil { return total, err } total.Written++ projects, err := s.ListProjects(ctx, ws.ID) if err != nil { return total, err } for _, p := range projects { stats, err := e.ExportProject(ctx, s, p) if err != nil { return total, fmt.Errorf("Projekt %q: %w", p.Slug, err) } total.Written += stats.Written total.Removed += stats.Removed } return total, nil } // projectFiles stellt alle Dateien eines Projekts zusammen, ohne etwas zu // schreiben: je Eintrag eine Datei und die Übersicht _index.md, alle unter // "/". // // 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 projectFiles(ctx context.Context, s *store.Store, project store.Project) ([]File, 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) } entries, err := s.AllEntries(ctx, project.ID) if err != nil { return nil, err } files := make([]File, 0, len(entries)+1) for _, entry := range entries { if !store.ValidSlug(entry.Slug) { return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", entry.Slug, project.Slug) } files = append(files, File{ Path: project.Slug + "/" + entry.Slug + ".md", Content: render(entry), Modified: entry.UpdatedAt, }) } // Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist. files = append(files, File{ Path: project.Slug + "/" + indexFilename, Content: renderIndex(project, entries), }) return files, nil } // indexFilename ist die Übersicht, die je Projekt mit erzeugt wird. const indexFilename = "_index.md" // workspaceFilename nennt den Workspace, zu dem ein ID-Ordner gehört. const workspaceFilename = "_workspace.md" // renderWorkspace baut den Inhalt von _workspace.md. Wie beim Index ohne // Zeitstempel des Laufs, damit ein unveränderter Workspace keinen Diff erzeugt. func renderWorkspace(ws store.Workspace) string { var b strings.Builder b.WriteString("---\n") writeField(&b, "id", ws.ID) writeField(&b, "name", ws.Name) b.WriteString("---\n\n") b.WriteString("# Workspace " + ws.Name + "\n") return b.String() } // writeFile 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 writeFile(filePath, content string) error { if existing, err := os.ReadFile(filePath); err == nil && string(existing) == content { return nil } if err := os.WriteFile(filePath, []byte(content), 0640); err != nil { return fmt.Errorf("%s konnte nicht geschrieben werden: %w", filePath, err) } return nil } // cleanup 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 cleanup(dir string, want map[string]bool) (int, error) { entries, err := os.ReadDir(dir) if err != nil { return 0, fmt.Errorf("Exportordner konnte nicht gelesen werden: %w", err) } var removed int for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || want[e.Name()] { continue } if err := os.Remove(filepath.Join(dir, e.Name())); err != nil { return removed, fmt.Errorf("%s konnte nicht entfernt werden: %w", e.Name(), err) } removed++ } return removed, nil } // render 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 render(e store.Entry) string { var b strings.Builder b.WriteString("---\n") writeField(&b, "slug", e.Slug) writeField(&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(yamlValue(t)) } b.WriteString("]\n") } writeField(&b, "source", e.Source) if e.Author != "" { writeField(&b, "author", e.Author) } b.WriteString("curated: " + strconv.FormatBool(e.Curated) + "\n") b.WriteString("version: " + strconv.Itoa(e.Version) + "\n") writeField(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339)) writeField(&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() } // renderIndex 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 renderIndex(project store.Project, entries []store.Entry) string { var b strings.Builder b.WriteString("---\n") writeField(&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(entries))) // Nach erstem Schlagwort gruppieren; Einträge ohne Schlagwort ans Ende. const without = "Ohne Schlagwort" groups := make(map[string][]store.Entry) for _, e := range entries { category := without if len(e.Tags) > 0 { category = e.Tags[0] } groups[category] = append(groups[category], e) } categories := make([]string, 0, len(groups)) for k := range groups { categories = append(categories, k) } sort.Slice(categories, func(i, j int) bool { if categories[i] == without { return false } if categories[j] == without { return true } return categories[i] < categories[j] }) for _, category := range categories { b.WriteString("## " + category + "\n\n") list := groups[category] sort.Slice(list, func(i, j int) bool { return list[i].Title < list[j].Title }) for _, e := range list { 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() } // writeField hängt ein YAML-Feld an. func writeField(b *strings.Builder, name, value string) { b.WriteString(name + ": " + yamlValue(value) + "\n") } // yamlValue 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 yamlValue(s string) string { replaced := strings.NewReplacer( `\`, `\\`, `"`, `\"`, "\n", `\n`, "\r", ``, "\t", `\t`, ).Replace(s) return `"` + replaced + `"` }