From 1df32ece9289a0ad27937bb123c7e4a6c96bf917 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 06:43:50 +0100 Subject: [PATCH] feat(backup): download the full backup as ZIP GET /export?format=zip streams the JSON backup compressed (plain JSON stays the API default). The MindSift import recognises the backup ZIP next to the plain JSON and the Markdown archive. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 2 +- Doku/modules/workspace.md | 2 +- .../features/settings/UserSettingsView.tsx | 25 ++++---- frontend/src/lib/api.ts | 21 ++++--- internal/api/server.go | 16 +++-- internal/storage/import_markdown.go | 59 +++++++++++++++++++ internal/storage/import_markdown_test.go | 22 +++++++ 7 files changed, 122 insertions(+), 25 deletions(-) diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 30eb221..240b4a4 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -30,7 +30,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/stats` | Zähler fürs Dashboard | | GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` | -| GET | `/export` | Datenexport als JSON | +| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) | | GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/.md` | | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) | | DELETE | `/themes/{id}` | Eigenes Theme löschen | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 1bebd17..fd9a34d 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -68,7 +68,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mi | Weg | Endpunkt | Dateien | Ergebnis | |---|---|---|---| -| MindSift-Daten | `POST /import/mindsift` | JSON-Backup (`/export`) oder Markdown-Archiv (`/export/markdown`) | Notizen in ihre Ordner, Aufgabenlisten mit Unteraufgaben, Fälligkeit, Priorität und Notizen, Listen mit Menge, Kategorie und Erledigt-Status, beim JSON-Backup auch die Gedanken der Triage. Fremde, nur mit dem Exporteur geteilte Inhalte im JSON werden übersprungen. | +| MindSift-Daten | `POST /import/mindsift` | Backup-ZIP oder JSON (`/export`) oder Markdown-Archiv (`/export/markdown`), das Format wird erkannt | Notizen in ihre Ordner, Aufgabenlisten mit Unteraufgaben, Fälligkeit, Priorität und Notizen, Listen mit Menge, Kategorie und Erledigt-Status, beim JSON-Backup auch die Gedanken der Triage. Fremde, nur mit dem Exporteur geteilte Inhalte im JSON werden übersprungen. | | Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. | - **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an. diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 2409ef9..6a150fa 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -375,12 +375,16 @@ export const UserSettingsView: React.FC = ({ window.setTimeout(() => URL.revokeObjectURL(url), 10000); }; + const [exportingBackup, setExportingBackup] = useState(false); const handleExportData = async () => { - const data = await api.exportAll(); - const blob = new Blob([JSON.stringify(data, null, 2)], { - type: 'application/json;charset=utf-8', - }); - saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`); + setExportingBackup(true); + try { + saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`); + } catch (err: any) { + setVaultError(err.message || 'Backup fehlgeschlagen'); + } finally { + setExportingBackup(false); + } }; const [exportingVault, setExportingVault] = useState(false); @@ -1238,18 +1242,19 @@ export const UserSettingsView: React.FC = ({ {/* Full Data Backup */}
-

Vollständiger Daten-Export (JSON Backup)

+

Vollständiges Backup (.zip)

- Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable - JSON-Datei herunter. + Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über + „MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index af6da26..e7b6234 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -135,6 +135,14 @@ export const setCurrentSharingMode = (mode: SharingMode) => { }; export const isSharingDisabled = () => currentSharingMode === 'private'; +// Authenticated binary download (archives); no JSON parsing and no request timeout +async function downloadBlob(path: string): Promise { + const token = getToken(); + const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {}, cache: 'no-store' }); + if (!res.ok) throw new Error(`Download fehlgeschlagen (HTTP ${res.status})`); + return res.blob(); +} + export const api = { // --- AUTH, FIRST-BOOT SETUP & SETTINGS --- async getAuthStatus(): Promise { @@ -1093,15 +1101,14 @@ export const api = { return data as ImportSummary; }, + // Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …) + async exportBackupZip(): Promise { + return downloadBlob('/api/v1/export?format=zip'); + }, + // Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout async exportMarkdownVault(): Promise { - const token = getToken(); - const res = await fetch('/api/v1/export/markdown', { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - cache: 'no-store', - }); - if (!res.ok) throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`); - return res.blob(); + return downloadBlob('/api/v1/export/markdown'); }, }; diff --git a/internal/api/server.go b/internal/api/server.go index 739254f..8377176 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2159,12 +2159,7 @@ func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - if strings.HasSuffix(strings.ToLower(name), ".json") || (len(data) > 0 && data[0] == '{') { - plan, err := storage.PlanMindSiftJSONImport(data) - s.runImport(w, r, plan, err) - return - } - plan, err := storage.PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data))) + plan, err := storage.PlanMindSiftImport(data, name) s.runImport(w, r, plan, err) } @@ -2197,6 +2192,15 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + // ?format=zip: the same JSON compressed (backups grow quickly with notes); plain JSON stays the API default + if r.URL.Query().Get("format") == "zip" { + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02"))) + if err := storage.WriteBackupZip(exportData, w); err != nil { + slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err) + } + return + } w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json") writeJSON(w, http.StatusOK, exportData) } diff --git a/internal/storage/import_markdown.go b/internal/storage/import_markdown.go index d84c774..8c692aa 100644 --- a/internal/storage/import_markdown.go +++ b/internal/storage/import_markdown.go @@ -2,6 +2,7 @@ package storage import ( "archive/zip" + "bytes" "encoding/json" "errors" "fmt" @@ -10,6 +11,7 @@ import ( "regexp" "sort" "strings" + "time" "unicode/utf8" "github.com/sandrodach/mindsift/internal/domain" @@ -300,6 +302,63 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) { return plan, nil } +// BackupFileName is the JSON inside the backup ZIP. +const BackupFileName = "mindsift-backup.json" + +// WriteBackupZip streams the JSON backup compressed into a ZIP with a single file. +func WriteBackupZip(exp *domain.FullExport, w io.Writer) error { + zw := zip.NewWriter(w) + f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: parseISO(exp.ExportedAt, time.Now())}) + if err != nil { + return err + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(exp); err != nil { + return err + } + return zw.Close() +} + +// PlanMindSiftImport picks the format of MindSift's own data: plain JSON backup, the backup ZIP +// (one JSON inside) or the Markdown archive. +func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) { + if strings.HasSuffix(strings.ToLower(fileName), ".json") || (len(data) > 0 && data[0] == '{') { + return PlanMindSiftJSONImport(data) + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, errors.New("Die Datei ist weder ein MindSift-Backup (.json/.zip) noch ein MindSift-Markdown-Archiv") + } + var backup *zip.File + for _, f := range zr.File { + name := cleanArchivePath(f.Name) + if strings.EqualFold(path.Ext(name), ".md") { + backup = nil + break + } + if strings.EqualFold(path.Ext(name), ".json") && !hiddenPath(name) { + backup = f + } + } + if backup == nil { + return PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data))) + } + if backup.UncompressedSize64 > maxImportUnpackedBytes { + return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)") + } + rc, err := backup.Open() + if err != nil { + return nil, errors.New("Backup im Archiv ist nicht lesbar") + } + defer rc.Close() + raw, err := io.ReadAll(io.LimitReader(rc, maxImportUnpackedBytes+1)) + if err != nil || len(raw) > maxImportUnpackedBytes { + return nil, errors.New("Backup im Archiv ist nicht lesbar oder zu groß") + } + return PlanMindSiftJSONImport(raw) +} + // PlanMindSiftJSONImport reads the JSON backup ("Backup herunterladen"). Notes shared with the // exporting user belong to someone else and are skipped; discarded thoughts are not restored. func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) { diff --git a/internal/storage/import_markdown_test.go b/internal/storage/import_markdown_test.go index 4042f66..821a26a 100644 --- a/internal/storage/import_markdown_test.go +++ b/internal/storage/import_markdown_test.go @@ -197,3 +197,25 @@ func TestImportObsidianVault(t *testing.T) { t.Errorf("invalid archive must be rejected") } } + +func TestImportBackupZip(t *testing.T) { + store, anna, ben := newImportStore(t) + seedAnna(t, store, anna) + exp, _ := store.ExportUserData(anna.ID) + var buf bytes.Buffer + if err := WriteBackupZip(exp, &buf); err != nil { + t.Fatalf("WriteBackupZip: %v", err) + } + if buf.Len() == 0 { + t.Fatalf("empty archive") + } + plan, err := PlanMindSiftImport(buf.Bytes(), "mindsift-backup-2026-10-29.zip") + if err != nil { + t.Fatalf("plan: %v", err) + } + if _, err := store.ApplyImport(ben.ID, plan); err != nil { + t.Fatalf("apply: %v", err) + } + checkRestored(t, store, ben) +} +