101 lines
2.9 KiB
Go
101 lines
2.9 KiB
Go
package export
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"munin/internal/store"
|
|
)
|
|
|
|
// Datei ist eine Datei des Exports, noch nicht geschrieben.
|
|
type Datei struct {
|
|
// Pfad ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter
|
|
// Windows — so verlangt es das ZIP-Format.
|
|
Pfad string
|
|
Inhalt string
|
|
|
|
// Geaendert ist der Zeitpunkt, den die Datei im ZIP trägt. Bei Einträgen
|
|
// ihre letzte Änderung, damit ein entpacktes Archiv in jedem Dateimanager
|
|
// die richtige Reihenfolge zeigt. Leer bedeutet: Zeitpunkt des Downloads.
|
|
Geaendert time.Time
|
|
}
|
|
|
|
// ProjektDateien stellt den Export eines einzelnen Projekts zusammen:
|
|
//
|
|
// <projekt>/
|
|
// _index.md
|
|
// <eintrag>.md
|
|
//
|
|
// Das ist genau das, was der Import eines Projekts erwartet (Unterordner
|
|
// erlaubt, Dateien mit "_" am Anfang werden übergangen).
|
|
func ProjektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) {
|
|
return projektDateien(ctx, s, project)
|
|
}
|
|
|
|
// WorkspaceDateien stellt den Export aller Projekte eines Workspace zusammen,
|
|
// im selben Aufbau wie ein Workspace-Ordner unter EXPORT_DIR:
|
|
//
|
|
// _workspace.md
|
|
// <projekt-a>/…
|
|
// <projekt-b>/…
|
|
func WorkspaceDateien(ctx context.Context, s *store.Store, ws store.Workspace) ([]Datei, error) {
|
|
dateien := []Datei{{Pfad: workspaceDateiname, Inhalt: rendereWorkspace(ws)}}
|
|
|
|
projekte, err := s.ListProjects(ctx, ws.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, p := range projekte {
|
|
projekt, err := projektDateien(ctx, s, p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Projekt %q: %w", p.Slug, err)
|
|
}
|
|
dateien = append(dateien, projekt...)
|
|
}
|
|
return dateien, nil
|
|
}
|
|
|
|
// SchreibeZIP packt die Dateien als ZIP-Archiv nach w.
|
|
//
|
|
// # Warum erst sammeln, dann packen?
|
|
//
|
|
// Beim Download schreibt w direkt in die Antwort an den Browser. Ist der
|
|
// erste Teil des Archivs einmal unterwegs, lässt sich ein Fehler nicht mehr
|
|
// als Fehlerseite melden — der Browser bekäme nur ein kaputtes ZIP. Deshalb
|
|
// liest der Aufrufer zuerst alles aus der Datenbank (ProjektDateien,
|
|
// WorkspaceDateien). Was danach noch schiefgehen kann, ist nur noch die
|
|
// Verbindung selbst.
|
|
func SchreibeZIP(w io.Writer, dateien []Datei) error {
|
|
jetzt := time.Now()
|
|
archiv := zip.NewWriter(w)
|
|
|
|
for _, d := range dateien {
|
|
geaendert := d.Geaendert
|
|
if geaendert.IsZero() {
|
|
geaendert = jetzt
|
|
}
|
|
|
|
// CreateHeader statt Create, um Komprimierung und Zeitstempel
|
|
// selbst festzulegen. Deflate ist das Verfahren, das jedes
|
|
// Entpackprogramm kennt.
|
|
f, err := archiv.CreateHeader(&zip.FileHeader{
|
|
Name: d.Pfad,
|
|
Method: zip.Deflate,
|
|
Modified: geaendert,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.WriteString(f, d.Inhalt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Close schreibt das Inhaltsverzeichnis ans Ende des Archivs. Ohne diesen
|
|
// Schritt ist das ZIP unbrauchbar, auch wenn alle Dateien darin stehen.
|
|
return archiv.Close()
|
|
}
|