munin/internal/export/zip.go
2026-10-19 10:13:56 +02:00

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()
}