101 lines
2.8 KiB
Go
101 lines
2.8 KiB
Go
package export
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"munin/internal/store"
|
|
)
|
|
|
|
// File ist eine Datei des Exports, noch nicht geschrieben.
|
|
type File struct {
|
|
// Path ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter
|
|
// Windows — so verlangt es das ZIP-Format.
|
|
Path string
|
|
Content string
|
|
|
|
// Modified 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.
|
|
Modified time.Time
|
|
}
|
|
|
|
// ProjectFiles 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 ProjectFiles(ctx context.Context, s *store.Store, project store.Project) ([]File, error) {
|
|
return projectFiles(ctx, s, project)
|
|
}
|
|
|
|
// WorkspaceFiles 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 WorkspaceFiles(ctx context.Context, s *store.Store, ws store.Workspace) ([]File, error) {
|
|
files := []File{{Path: workspaceFilename, Content: renderWorkspace(ws)}}
|
|
|
|
projects, err := s.ListProjects(ctx, ws.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, p := range projects {
|
|
project, err := projectFiles(ctx, s, p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Projekt %q: %w", p.Slug, err)
|
|
}
|
|
files = append(files, project...)
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// WriteZIP 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 (ProjectFiles,
|
|
// WorkspaceFiles). Was danach noch schiefgehen kann, ist nur noch die
|
|
// Verbindung selbst.
|
|
func WriteZIP(w io.Writer, files []File) error {
|
|
now := time.Now()
|
|
archive := zip.NewWriter(w)
|
|
|
|
for _, d := range files {
|
|
changed := d.Modified
|
|
if changed.IsZero() {
|
|
changed = now
|
|
}
|
|
|
|
// CreateHeader statt Create, um Komprimierung und Zeitstempel
|
|
// selbst festzulegen. Deflate ist das Verfahren, das jedes
|
|
// Entpackprogramm kennt.
|
|
f, err := archive.CreateHeader(&zip.FileHeader{
|
|
Name: d.Path,
|
|
Method: zip.Deflate,
|
|
Modified: changed,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.WriteString(f, d.Content); 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 archive.Close()
|
|
}
|