feat(backup): admin and account UI for server backups, import by section
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s

Admin settings: switch, time, retention, list with download and a
"Jetzt sichern" button. Account settings: own server backups with
download and restore (replace or add, for chosen sections, replace needs
a second tap). The MindSift import gets the same section picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 16:11:49 +01:00
1 parent c97765e08e
commit 64e077dd57
13 files changed
+446 -18

No files matched your search

+32 -1
View File
@@ -4,6 +4,8 @@ import {
DashboardStats,
Document,
TrashedDocument,
BackupFile,
DataSection,
ApiKeyScope,
Backlink,
DocumentBlock,
@@ -1155,7 +1157,8 @@ export const api = {
source: 'mindsift' | 'obsidian',
file: File,
preview: boolean,
mode: 'merge' | 'restore' = 'merge'
mode: 'merge' | 'restore' = 'merge',
sections?: DataSection[]
): Promise<ImportSummary> {
const token = getToken();
const form = new FormData();
@@ -1163,6 +1166,7 @@ export const api = {
const params = new URLSearchParams();
if (preview) params.set('preview', '1');
if (mode === 'restore') params.set('mode', 'restore');
if (sections && sections.length > 0) params.set('sections', sections.join(','));
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -1173,6 +1177,33 @@ export const api = {
return data as ImportSummary;
},
// --- Automatic server backups ---
async adminListBackups(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups'));
},
async adminRunBackup(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
},
async adminDownloadBackup(name: string): Promise<Blob> {
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
},
async listMyBackups(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/backups'));
},
async downloadMyBackup(name: string): Promise<Blob> {
return downloadBlob(`/api/v1/backups/${encodeURIComponent(name)}`);
},
async restoreMyBackup(name: string, mode: 'merge' | 'restore', sections: DataSection[], preview = false): Promise<ImportSummary> {
const params = new URLSearchParams({ mode, sections: sections.join(',') });
if (preview) params.set('preview', '1');
return request<ImportSummary>(`/api/v1/backups/${encodeURIComponent(name)}/restore?${params}`, { method: 'POST' });
},
// Danger zone: both need the current password
async purgeOwnData(password: string): Promise<void> {
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
+15
View File
@@ -0,0 +1,15 @@
// Saves a downloaded file. Works on mobile browsers too: the link must be in the document and the
// object URL alive until the click ran.
export function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
}
export const formatBytes = (n: number): string =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
+22
View File
@@ -186,8 +186,29 @@ export interface SystemSettings {
smtpSkipVerify?: boolean;
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
smtpConfiguredViaEnv?: boolean;
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
backupEnabled?: boolean;
backupTime?: string;
backupRetentionDays?: number;
}
/** One file of a server backup listing. */
export interface BackupFile {
name: string;
size: number;
createdAt: string;
}
/** Parts of the own data a backup can be restored for. */
export type DataSection = 'notes' | 'tasks' | 'lists' | 'inbox' | 'themes';
export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
{ id: 'notes', label: 'Notizen' },
{ id: 'tasks', label: 'Aufgaben' },
{ id: 'lists', label: 'Listen' },
{ id: 'inbox', label: 'Inbox' },
{ id: 'themes', label: 'Themes' },
];
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
export interface DocumentBlock {
@@ -464,6 +485,7 @@ export interface ImportSummary {
lists: number;
listItems: number;
inbox: number;
themes?: number;
skipped: { path: string; reason: string }[];
applied: boolean;
}