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
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:
1 parent
c97765e08e
commit
64e077dd57
13 files changed
+446
-18
No files matched your search
@@ -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) |
|
| — | `/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/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` |
|
| 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"]`) |
|
| 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=`)
|
### 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/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`) |
|
| 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`) |
|
| 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
|
## Beispiele
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
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/<id>/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)
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -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. |
|
| `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).
|
* **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.
|
* **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.
|
* **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.
|
||||||
|
|
||||||
|
|||||||
@@ -630,6 +630,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Private Webhooks & Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Ohne Internet:</strong> 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.
|
<strong className="text-main">Ohne Internet:</strong> 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.
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
|||||||
import { passwordProblem } from '../../lib/password';
|
import { passwordProblem } from '../../lib/password';
|
||||||
import { UserAvatar } from '../../components/UserAvatar';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
import { BackupAdminCard } from './BackupAdminCard';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
|
||||||
interface AdminSettingsViewProps {
|
interface AdminSettingsViewProps {
|
||||||
@@ -135,6 +136,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
|
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
|
||||||
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
|
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
|
||||||
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
|
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
|
||||||
|
backupEnabled: Boolean(sys.backupEnabled),
|
||||||
|
backupTime: sys.backupTime || '03:00',
|
||||||
|
backupRetentionDays: sys.backupRetentionDays || 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -1093,6 +1097,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
|
||||||
|
|
||||||
{/* Alarm webhooks into the local network (SSRF protection) */}
|
{/* Alarm webhooks into the local network (SSRF protection) */}
|
||||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||||
<div className="pr-4">
|
<div className="pr-4">
|
||||||
|
|||||||
@@ -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<SystemSettings>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: automatic daily backups (whole database + one backup per user), list and manual run
|
||||||
|
export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSave }) => {
|
||||||
|
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="pr-4">
|
||||||
|
<p className="font-bold text-main flex items-center gap-1.5">
|
||||||
|
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Automatische Backups
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
|
Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter <code className="font-mono">backups/</code> im
|
||||||
|
Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={Boolean(settings.backupEnabled)}
|
||||||
|
onChange={(backupEnabled) => onSave({ backupEnabled })}
|
||||||
|
title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="text-[11px] text-muted">
|
||||||
|
Uhrzeit
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={settings.backupTime || '03:00'}
|
||||||
|
onChange={(e) => e.target.value && onSave({ backupTime: e.target.value })}
|
||||||
|
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-28"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-muted">
|
||||||
|
Aufbewahren (Tage)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={settings.backupRetentionDays || 3}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
if (n >= 1 && n <= 365) onSave({ backupRetentionDays: n });
|
||||||
|
}}
|
||||||
|
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-24"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runNow}
|
||||||
|
disabled={running}
|
||||||
|
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Archive className="w-3.5 h-3.5 text-accent" />}
|
||||||
|
{running ? 'Sichere…' : 'Jetzt sichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||||
|
|
||||||
|
<div className="border border-border divide-y divide-border bg-panel">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
files.map((f) => (
|
||||||
|
<div key={f.name} className="px-3 py-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-mono text-main truncate">{f.name}</p>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => download(f.name)}
|
||||||
|
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Zurückspielen eines Datenbank-Abzugs: Container stoppen, <code className="font-mono">mindsift.db</code> durch die entpackte Datei
|
||||||
|
ersetzen (<code className="font-mono">gunzip</code>), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den
|
||||||
|
Kontoeinstellungen wieder her.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-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';
|
import { api } from '../../lib/api';
|
||||||
|
|
||||||
interface ImportCardProps {
|
interface ImportCardProps {
|
||||||
@@ -18,6 +19,7 @@ const summaryParts = (s: ImportSummary) =>
|
|||||||
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
|
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
|
||||||
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
|
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.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[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
// One import path: drop or pick a file, see a preview of what would be created, then import.
|
// 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<ImportCardProps> = ({ source, title, descripti
|
|||||||
// MindSift data can replace the current state instead of being added next to it
|
// MindSift data can replace the current state instead of being added next to it
|
||||||
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
|
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
|
||||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||||
|
// MindSift data: which parts to take from the file
|
||||||
|
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||||
|
const sectionParam = source === 'mindsift' ? sections : undefined;
|
||||||
const restoring = source === 'mindsift' && mode === 'restore';
|
const restoring = source === 'mindsift' && mode === 'restore';
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
@@ -49,7 +54,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
|||||||
setFile(f);
|
setFile(f);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
setPreview(await api.importData(source, f, true));
|
setPreview(await api.importData(source, f, true, 'merge', sectionParam));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Datei konnte nicht gelesen werden');
|
setError(err.message || 'Datei konnte nicht gelesen werden');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,7 +67,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
|
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam));
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setConfirmRestore(false);
|
setConfirmRestore(false);
|
||||||
@@ -87,7 +92,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
|
['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
|
] as const
|
||||||
).map(([value, label, text]) => (
|
).map(([value, label, text]) => (
|
||||||
<button
|
<button
|
||||||
@@ -114,6 +119,24 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{source === 'mindsift' && (
|
||||||
|
<SectionPicker
|
||||||
|
value={sections}
|
||||||
|
onChange={(next) => {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -194,8 +217,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
|||||||
className="mt-0.5 w-4 h-4 shrink-0"
|
className="mt-0.5 w-4 h-4 shrink-0"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong className="text-status-err">Achtung:</strong> Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und
|
<strong className="text-status-err">Achtung:</strong> Deine bisherigen Daten in den gewählten Bereichen
|
||||||
Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
|
({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.
|
bleiben erhalten.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -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<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
|
||||||
|
<span className="text-[11px] text-muted mr-1">{label}:</span>
|
||||||
|
{DATA_SECTIONS.map((sec) => {
|
||||||
|
const on = value.includes(sec.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sec.id}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={on}
|
||||||
|
onClick={() => {
|
||||||
|
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
|
||||||
|
if (next.length > 0) onChange(next);
|
||||||
|
}}
|
||||||
|
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
|
||||||
|
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{on && <Check className="w-3 h-3 text-accent" />}
|
||||||
|
{sec.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -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<BackupFile[]>([]);
|
||||||
|
const [open, setOpen] = useState<string | null>(null); // backup being restored
|
||||||
|
const [mode, setMode] = useState<'merge' | 'restore'>('restore');
|
||||||
|
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<ImportSummary | null>(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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||||
|
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Server-Backups
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted text-[11px] mt-0.5">
|
||||||
|
Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-border divide-y divide-border bg-canvas">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
files.map((f) => (
|
||||||
|
<div key={f.name} className="p-3 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-main">{new Date(f.createdAt).toLocaleString('de-DE')}</p>
|
||||||
|
<p className="text-[11px] text-faint">{formatBytes(f.size)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => saveBlob(await api.downloadMyBackup(f.name), f.name)}
|
||||||
|
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startRestore(f.name)}
|
||||||
|
aria-expanded={open === f.name}
|
||||||
|
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open === f.name && (
|
||||||
|
<div className="p-3 rounded-card border border-border bg-panel space-y-2.5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Art der Wiederherstellung">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'],
|
||||||
|
['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label, text]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mode === value}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(value);
|
||||||
|
setConfirm(false);
|
||||||
|
}}
|
||||||
|
className={`p-2 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||||
|
mode === value ? (value === 'restore' ? 'border-status-err bg-status-err/10' : 'border-accent bg-accent/10') : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-xs font-semibold text-main">{label}</span>
|
||||||
|
<span className="block text-[11px] text-muted">{text}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<SectionPicker value={sections} onChange={setSections} />
|
||||||
|
{result ? (
|
||||||
|
<p className="text-[11px] text-status-ok flex items-center gap-1">
|
||||||
|
<Check className="w-3.5 h-3.5" /> Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '}
|
||||||
|
{result.inbox} Gedanken, {result.themes ?? 0} Themes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => (mode === 'restore' && !confirm ? setConfirm(true) : restore())}
|
||||||
|
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${
|
||||||
|
mode === 'restore' ? 'btn-action border-status-err text-status-err' : 'btn-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? 'Stelle wieder her…'
|
||||||
|
: mode === 'restore' && confirm
|
||||||
|
? `Wirklich ${sectionNames} ersetzen?`
|
||||||
|
: mode === 'restore'
|
||||||
|
? 'Ersetzen'
|
||||||
|
: 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, User, UserTheme } from '../../lib/types';
|
import { AuthStatus, User, UserTheme } from '../../lib/types';
|
||||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||||
|
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||||
|
import { saveBlob } from '../../lib/download';
|
||||||
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||||
import { passwordProblem } from '../../lib/password';
|
import { passwordProblem } from '../../lib/password';
|
||||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||||
@@ -363,16 +365,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
|
|
||||||
// --- Handlers: Export ---
|
// --- Handlers: Export ---
|
||||||
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
// 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 [exportingBackup, setExportingBackup] = useState(false);
|
||||||
const handleExportData = async () => {
|
const handleExportData = async () => {
|
||||||
@@ -1166,6 +1158,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
|
|
||||||
<ApiKeysPanel />
|
<ApiKeysPanel />
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-border">
|
||||||
|
<ServerBackupsCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
|||||||
+32
-1
@@ -4,6 +4,8 @@ import {
|
|||||||
DashboardStats,
|
DashboardStats,
|
||||||
Document,
|
Document,
|
||||||
TrashedDocument,
|
TrashedDocument,
|
||||||
|
BackupFile,
|
||||||
|
DataSection,
|
||||||
ApiKeyScope,
|
ApiKeyScope,
|
||||||
Backlink,
|
Backlink,
|
||||||
DocumentBlock,
|
DocumentBlock,
|
||||||
@@ -1155,7 +1157,8 @@ export const api = {
|
|||||||
source: 'mindsift' | 'obsidian',
|
source: 'mindsift' | 'obsidian',
|
||||||
file: File,
|
file: File,
|
||||||
preview: boolean,
|
preview: boolean,
|
||||||
mode: 'merge' | 'restore' = 'merge'
|
mode: 'merge' | 'restore' = 'merge',
|
||||||
|
sections?: DataSection[]
|
||||||
): Promise<ImportSummary> {
|
): Promise<ImportSummary> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -1163,6 +1166,7 @@ export const api = {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (preview) params.set('preview', '1');
|
if (preview) params.set('preview', '1');
|
||||||
if (mode === 'restore') params.set('mode', 'restore');
|
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}` : ''}`, {
|
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
@@ -1173,6 +1177,33 @@ export const api = {
|
|||||||
return data as ImportSummary;
|
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
|
// Danger zone: both need the current password
|
||||||
async purgeOwnData(password: string): Promise<void> {
|
async purgeOwnData(password: string): Promise<void> {
|
||||||
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
|
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -186,8 +186,29 @@ export interface SystemSettings {
|
|||||||
smtpSkipVerify?: boolean;
|
smtpSkipVerify?: boolean;
|
||||||
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
||||||
smtpConfiguredViaEnv?: boolean;
|
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 type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||||
|
|
||||||
export interface DocumentBlock {
|
export interface DocumentBlock {
|
||||||
@@ -464,6 +485,7 @@ export interface ImportSummary {
|
|||||||
lists: number;
|
lists: number;
|
||||||
listItems: number;
|
listItems: number;
|
||||||
inbox: number;
|
inbox: number;
|
||||||
|
themes?: number;
|
||||||
skipped: { path: string; reason: string }[];
|
skipped: { path: string; reason: string }[];
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user