diff --git a/Doku/api/rest.md b/Doku/api/rest.md index c7a311a..a47a6f1 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -41,6 +41,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) | | DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) | | DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` | +| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen | +| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) | ### Globale Suche (`GET /api/v1/search?q=`) @@ -189,6 +191,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant | GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben | | GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) | | POST | `/admin/smtp/test` | Testmail (`toEmail`) | +| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) | +| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen | ## Beispiele diff --git a/Doku/architecture.md b/Doku/architecture.md index 19160f7..955b5f1 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -106,6 +106,14 @@ Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: D Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`). +## Hintergrundjob: Backups + +[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt): +- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip. +- `backups/users//mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto. +- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus. +- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes). + ## Offline (PWA) - **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt. diff --git a/README.md b/README.md index 53400eb..7d913b2 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. | * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). -* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer. +* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`. * **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen. * **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf. diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 9f8de97..fe47577 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -630,6 +630,9 @@ export const HelpView: React.FC = ({ onBack }) => {
  • Eigenes Theme: Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als .css-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
  • +
  • + Server-Backups: Hat die Administration automatische Backups eingeschaltet, findest du unter Kontoeinstellungen → Private Webhooks & Export die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben. +
  • Ohne Internet: Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
  • diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index 85cedc0..65de29f 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -28,6 +28,7 @@ import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; import { UserAvatar } from '../../components/UserAvatar'; import { ToggleSwitch } from '../../components/ToggleSwitch'; +import { BackupAdminCard } from './BackupAdminCard'; import { CountBadge } from '../../components/CountBadge'; interface AdminSettingsViewProps { @@ -135,6 +136,9 @@ export const AdminSettingsView: React.FC = ({ smtpSkipVerify: Boolean(sys.smtpSkipVerify), allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks), smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv), + backupEnabled: Boolean(sys.backupEnabled), + backupTime: sys.backupTime || '03:00', + backupRetentionDays: sys.backupRetentionDays || 3, }); } } catch (err: any) { @@ -1093,6 +1097,8 @@ export const AdminSettingsView: React.FC = ({ )} + handleSaveSystemSettings(patch)} /> + {/* Alarm webhooks into the local network (SSRF protection) */}
    diff --git a/frontend/src/features/settings/BackupAdminCard.tsx b/frontend/src/features/settings/BackupAdminCard.tsx new file mode 100644 index 0000000..95e74a4 --- /dev/null +++ b/frontend/src/features/settings/BackupAdminCard.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { Archive, Download, Loader2 } from 'lucide-react'; +import { BackupFile, SystemSettings } from '../../lib/types'; +import { api } from '../../lib/api'; +import { ToggleSwitch } from '../../components/ToggleSwitch'; +import { formatBytes, saveBlob } from '../../lib/download'; + +interface BackupAdminCardProps { + settings: SystemSettings; + onSave: (patch: Partial) => void; +} + +// Admin: automatic daily backups (whole database + one backup per user), list and manual run +export const BackupAdminCard: React.FC = ({ settings, onSave }) => { + const [files, setFiles] = useState([]); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.adminListBackups().then(setFiles).catch(() => {}); + }, []); + + const runNow = async () => { + setRunning(true); + setError(null); + try { + setFiles(await api.adminRunBackup()); + } catch (err: any) { + setError(err.message || 'Backup fehlgeschlagen'); + api.adminListBackups().then(setFiles).catch(() => {}); + } finally { + setRunning(false); + } + }; + + const download = async (name: string) => { + try { + saveBlob(await api.adminDownloadBackup(name), name); + } catch (err: any) { + setError(err.message || 'Download fehlgeschlagen'); + } + }; + + return ( +
    +
    +
    +

    + + Automatische Backups +

    +

    + Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter backups/ im + Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern. +

    +
    + onSave({ backupEnabled })} + title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'} + /> +
    + +
    + + + +
    + {error &&

    {error}

    } + +
    + {files.length === 0 ? ( +

    Noch keine Server-Backups vorhanden.

    + ) : ( + files.map((f) => ( +
    +
    +

    {f.name}

    +

    + {new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)} +

    +
    + +
    + )) + )} +
    +

    + Zurückspielen eines Datenbank-Abzugs: Container stoppen, mindsift.db durch die entpackte Datei + ersetzen (gunzip), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den + Kontoeinstellungen wieder her. +

    +
    + ); +}; diff --git a/frontend/src/features/settings/ImportCard.tsx b/frontend/src/features/settings/ImportCard.tsx index ce5d111..0292d2d 100644 --- a/frontend/src/features/settings/ImportCard.tsx +++ b/frontend/src/features/settings/ImportCard.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState } from 'react'; import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react'; -import { ImportSummary } from '../../lib/types'; +import { DATA_SECTIONS, DataSection, ImportSummary } from '../../lib/types'; +import { SectionPicker } from './SectionPicker'; import { api } from '../../lib/api'; interface ImportCardProps { @@ -18,6 +19,7 @@ const summaryParts = (s: ImportSummary) => s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`, s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`, s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`, + s.themes && `${s.themes} Theme${s.themes === 1 ? '' : 's'}`, ].filter(Boolean) as string[]; // One import path: drop or pick a file, see a preview of what would be created, then import. @@ -33,6 +35,9 @@ export const ImportCard: React.FC = ({ source, title, descripti // MindSift data can replace the current state instead of being added next to it const [mode, setMode] = useState<'merge' | 'restore'>('merge'); const [confirmRestore, setConfirmRestore] = useState(false); + // MindSift data: which parts to take from the file + const [sections, setSections] = useState(DATA_SECTIONS.map((s) => s.id)); + const sectionParam = source === 'mindsift' ? sections : undefined; const restoring = source === 'mindsift' && mode === 'restore'; const reset = () => { @@ -49,7 +54,7 @@ export const ImportCard: React.FC = ({ source, title, descripti setFile(f); setBusy(true); try { - setPreview(await api.importData(source, f, true)); + setPreview(await api.importData(source, f, true, 'merge', sectionParam)); } catch (err: any) { setError(err.message || 'Datei konnte nicht gelesen werden'); } finally { @@ -62,7 +67,7 @@ export const ImportCard: React.FC = ({ source, title, descripti setBusy(true); setError(null); try { - setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge')); + setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam)); setFile(null); setPreview(null); setConfirmRestore(false); @@ -87,7 +92,7 @@ export const ImportCard: React.FC = ({ source, title, descripti {( [ ['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'], - ['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'], + ['restore', 'Wiederherstellen', 'Gewählte Bereiche vorher leeren.'], ] as const ).map(([value, label, text]) => (
    )} + {source === 'mindsift' && ( + { + setSections(next); + // The preview depends on the sections, so a picked file is read again + if (file) { + setBusy(true); + api + .importData(source, file, true, 'merge', next) + .then(setPreview) + .catch((err) => setError(err.message)) + .finally(() => setBusy(false)); + } + }} + /> + )} +
    { e.preventDefault(); @@ -194,8 +217,9 @@ export const ImportCard: React.FC = ({ source, title, descripti className="mt-0.5 w-4 h-4 shrink-0" /> - Achtung: Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und - Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer + Achtung: Deine bisherigen Daten in den gewählten Bereichen + ({DATA_SECTIONS.filter((x) => sections.includes(x.id)).map((x) => x.label).join(', ')}) werden vor dem Import + unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer bleiben erhalten. diff --git a/frontend/src/features/settings/SectionPicker.tsx b/frontend/src/features/settings/SectionPicker.tsx new file mode 100644 index 0000000..ddd7dee --- /dev/null +++ b/frontend/src/features/settings/SectionPicker.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Check } from 'lucide-react'; +import { DATA_SECTIONS, DataSection } from '../../lib/types'; + +interface SectionPickerProps { + value: DataSection[]; + onChange: (sections: DataSection[]) => void; + label?: string; +} + +// Which parts of the own data a restore or import covers; at least one stays selected +export const SectionPicker: React.FC = ({ value, onChange, label = 'Bereiche' }) => ( +
    + {label}: + {DATA_SECTIONS.map((sec) => { + const on = value.includes(sec.id); + return ( + + ); + })} +
    +); diff --git a/frontend/src/features/settings/ServerBackupsCard.tsx b/frontend/src/features/settings/ServerBackupsCard.tsx new file mode 100644 index 0000000..69ec603 --- /dev/null +++ b/frontend/src/features/settings/ServerBackupsCard.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react'; +import { Archive, Check, Download, RotateCcw } from 'lucide-react'; +import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types'; +import { api } from '../../lib/api'; +import { formatBytes, saveBlob } from '../../lib/download'; +import { SectionPicker } from './SectionPicker'; + +// The user's own automatic backups on the server: download or restore (all or some sections) +export const ServerBackupsCard: React.FC = () => { + const [files, setFiles] = useState([]); + const [open, setOpen] = useState(null); // backup being restored + const [mode, setMode] = useState<'merge' | 'restore'>('restore'); + const [sections, setSections] = useState(DATA_SECTIONS.map((s) => s.id)); + const [confirm, setConfirm] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + api.listMyBackups().then(setFiles).catch(() => {}); + }, []); + + const startRestore = (name: string) => { + setOpen(open === name ? null : name); + setConfirm(false); + setError(null); + setResult(null); + }; + + const restore = async () => { + if (!open) return; + setBusy(true); + setError(null); + try { + setResult(await api.restoreMyBackup(open, mode, sections)); + setConfirm(false); + } catch (err: any) { + setError(err.message || 'Wiederherstellen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id)) + .map((s) => s.label) + .join(', '); + + return ( +
    +
    +

    + + Server-Backups +

    +

    + Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat). +

    +
    +
    + {files.length === 0 ? ( +

    Noch keine Server-Backups vorhanden.

    + ) : ( + files.map((f) => ( +
    +
    +
    +

    {new Date(f.createdAt).toLocaleString('de-DE')}

    +

    {formatBytes(f.size)}

    +
    +
    + + +
    +
    + + {open === f.name && ( +
    +
    + {( + [ + ['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'], + ['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'], + ] as const + ).map(([value, label, text]) => ( + + ))} +
    + + {result ? ( +

    + Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '} + {result.inbox} Gedanken, {result.themes ?? 0} Themes. +

    + ) : ( + + )} + {error &&

    {error}

    } +
    + )} +
    + )) + )} +
    +
    + ); +}; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index a7d6812..8b80852 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -19,6 +19,8 @@ import { } from 'lucide-react'; import { AuthStatus, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; +import { ServerBackupsCard } from './ServerBackupsCard'; +import { saveBlob } from '../../lib/download'; import { api, clearToken, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme'; @@ -363,16 +365,6 @@ export const UserSettingsView: React.FC = ({ // --- Handlers: Export --- // Works on mobile browsers too: the link must be in the document and the URL alive until the click ran - const saveBlob = (blob: Blob, filename: string) => { - 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); - }; const [exportingBackup, setExportingBackup] = useState(false); const handleExportData = async () => { @@ -1166,6 +1158,10 @@ export const UserSettingsView: React.FC = ({ +
    + +
    + {/* Full Data Backup */}
    diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2a50a1a..25deb97 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { 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 { + return ensureArray(await request('/api/v1/admin/backups')); + }, + + async adminRunBackup(): Promise { + return ensureArray(await request('/api/v1/admin/backups', { method: 'POST' })); + }, + + async adminDownloadBackup(name: string): Promise { + return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`); + }, + + async listMyBackups(): Promise { + return ensureArray(await request('/api/v1/backups')); + }, + + async downloadMyBackup(name: string): Promise { + return downloadBlob(`/api/v1/backups/${encodeURIComponent(name)}`); + }, + + async restoreMyBackup(name: string, mode: 'merge' | 'restore', sections: DataSection[], preview = false): Promise { + const params = new URLSearchParams({ mode, sections: sections.join(',') }); + if (preview) params.set('preview', '1'); + return request(`/api/v1/backups/${encodeURIComponent(name)}/restore?${params}`, { method: 'POST' }); + }, + // Danger zone: both need the current password async purgeOwnData(password: string): Promise { await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) }); diff --git a/frontend/src/lib/download.ts b/frontend/src/lib/download.ts new file mode 100644 index 0000000..9dc3b57 --- /dev/null +++ b/frontend/src/lib/download.ts @@ -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`; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 37e74c7..f1b08c3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; }