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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1601a6a2d5
commit
1df32ece92
7 files changed
+122
-25
No files matched your search
+1
-1
@@ -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 | `/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 | `/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 | `/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/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
| 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/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||||
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
||||||
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mi
|
|||||||
|
|
||||||
| Weg | Endpunkt | Dateien | Ergebnis |
|
| 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. |
|
| 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.
|
- **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.
|
||||||
|
|||||||
@@ -375,12 +375,16 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [exportingBackup, setExportingBackup] = useState(false);
|
||||||
const handleExportData = async () => {
|
const handleExportData = async () => {
|
||||||
const data = await api.exportAll();
|
setExportingBackup(true);
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
try {
|
||||||
type: 'application/json;charset=utf-8',
|
saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||||
});
|
} catch (err: any) {
|
||||||
saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`);
|
setVaultError(err.message || 'Backup fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setExportingBackup(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [exportingVault, setExportingVault] = useState(false);
|
const [exportingVault, setExportingVault] = useState(false);
|
||||||
@@ -1238,18 +1242,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
{/* Full Data Backup */}
|
{/* Full Data Backup */}
|
||||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-main">Vollständiger Daten-Export (JSON Backup)</h3>
|
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
|
||||||
<p className="text-muted text-[11px]">
|
<p className="text-muted text-[11px]">
|
||||||
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable
|
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
|
||||||
JSON-Datei herunter.
|
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleExportData}
|
onClick={handleExportData}
|
||||||
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
|
disabled={exportingBackup}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5 text-accent" />
|
<Download className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Backup herunterladen</span>
|
<span>{exportingBackup ? 'Wird erstellt…' : 'Backup herunterladen'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+14
-7
@@ -135,6 +135,14 @@ export const setCurrentSharingMode = (mode: SharingMode) => {
|
|||||||
};
|
};
|
||||||
export const isSharingDisabled = () => currentSharingMode === 'private';
|
export const isSharingDisabled = () => currentSharingMode === 'private';
|
||||||
|
|
||||||
|
// Authenticated binary download (archives); no JSON parsing and no request timeout
|
||||||
|
async function downloadBlob(path: string): Promise<Blob> {
|
||||||
|
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 = {
|
export const api = {
|
||||||
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
||||||
async getAuthStatus(): Promise<AuthStatus> {
|
async getAuthStatus(): Promise<AuthStatus> {
|
||||||
@@ -1093,15 +1101,14 @@ export const api = {
|
|||||||
return data as ImportSummary;
|
return data as ImportSummary;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …)
|
||||||
|
async exportBackupZip(): Promise<Blob> {
|
||||||
|
return downloadBlob('/api/v1/export?format=zip');
|
||||||
|
},
|
||||||
|
|
||||||
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
||||||
async exportMarkdownVault(): Promise<Blob> {
|
async exportMarkdownVault(): Promise<Blob> {
|
||||||
const token = getToken();
|
return downloadBlob('/api/v1/export/markdown');
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -2159,12 +2159,7 @@ func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(strings.ToLower(name), ".json") || (len(data) > 0 && data[0] == '{') {
|
plan, err := storage.PlanMindSiftImport(data, name)
|
||||||
plan, err := storage.PlanMindSiftJSONImport(data)
|
|
||||||
s.runImport(w, r, plan, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
plan, err := storage.PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data)))
|
|
||||||
s.runImport(w, r, plan, err)
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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")
|
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
|
||||||
writeJSON(w, http.StatusOK, exportData)
|
writeJSON(w, http.StatusOK, exportData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/domain"
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
@@ -300,6 +302,63 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
|
|||||||
return plan, nil
|
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
|
// 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.
|
// exporting user belong to someone else and are skipped; discarded thoughts are not restored.
|
||||||
func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
|
func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
|
||||||
|
|||||||
@@ -197,3 +197,25 @@ func TestImportObsidianVault(t *testing.T) {
|
|||||||
t.Errorf("invalid archive must be rejected")
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user