feat(import): restore MindSift data and import Obsidian vaults
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s

- Two separate paths: POST /import/mindsift (JSON backup or the Markdown
  archive) and POST /import/obsidian (vault ZIP into a folder named after it)
- Everything is planned and validated first; ?preview=1 returns what would
  be created and what is skipped (with reason), nothing is written
- MindSift data round-trips: folders, note structure, task lists with
  subtasks/due dates/priorities/notes, lists with quantity/category/done,
  triage thoughts from the JSON backup
- Markdown to outliner: nested lists, heading sections, callouts, code,
  paragraphs; front matter tags become hashtags; .obsidian/attachments skipped
- Limits against zip bombs and path tricks (100 MB upload, 10 MB per note,
  500 MB unpacked, 20k files, no .. escapes)
- Settings: two import cards with drag & drop, preview, confirm, result;
  touch-sized buttons; mobile download/upload verified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 06:10:21 +01:00
1 parent d8a16c6775
commit 1601a6a2d5
9 files changed
+1314 -1

No files matched your search

@@ -0,0 +1,164 @@
import React, { useRef, useState } from 'react';
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
import { ImportSummary } from '../../lib/types';
import { api } from '../../lib/api';
interface ImportCardProps {
source: 'mindsift' | 'obsidian';
title: string;
description: React.ReactNode;
accept: string;
}
const summaryParts = (s: ImportSummary) =>
[
s.folders && `${s.folders} Ordner`,
s.notes && `${s.notes} ${s.notes === 1 ? 'Notiz' : 'Notizen'}`,
s.taskLists && `${s.taskLists} Aufgabenliste${s.taskLists === 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.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
].filter(Boolean) as string[];
// One import path: drop or pick a file, see a preview of what would be created, then import.
// Touch targets are large enough for phones; the file picker works on mobile browsers.
export const ImportCard: React.FC<ImportCardProps> = ({ source, title, description, accept }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ImportSummary | null>(null);
const [result, setResult] = useState<ImportSummary | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const reset = () => {
setFile(null);
setPreview(null);
setError(null);
};
const pick = async (f?: File) => {
if (!f) return;
reset();
setResult(null);
setFile(f);
setBusy(true);
try {
setPreview(await api.importData(source, f, true));
} catch (err: any) {
setError(err.message || 'Datei konnte nicht gelesen werden');
} finally {
setBusy(false);
}
};
const runImport = async () => {
if (!file) return;
setBusy(true);
setError(null);
try {
setResult(await api.importData(source, file, false));
setFile(null);
setPreview(null);
} catch (err: any) {
setError(err.message || 'Import fehlgeschlagen');
} finally {
setBusy(false);
}
};
const shown = result || preview;
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div>
<h4 className="font-bold text-main">{title}</h4>
<p className="text-muted text-[11px] mt-0.5">{description}</p>
</div>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
pick(e.dataTransfer.files?.[0]);
}}
className={`p-4 border border-dashed rounded-card flex flex-col sm:flex-row items-center justify-center gap-3 text-center transition-colors ${
dragOver ? 'border-accent bg-accent/10' : 'border-border'
}`}
>
<span className="text-[11px] text-muted hidden sm:inline">Datei hierher ziehen oder</span>
<button
type="button"
disabled={busy}
onClick={() => inputRef.current?.click()}
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 disabled:opacity-50 w-full sm:w-auto"
>
<Upload className="w-3.5 h-3.5 text-accent" />
<span>Datei auswählen</span>
</button>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
pick(e.target.files?.[0]);
e.target.value = '';
}}
/>
</div>
{busy && <p className="text-[11px] text-muted">{preview ? 'Importiere…' : 'Lese Datei…'}</p>}
{error && (
<p className="text-[11px] text-status-err flex items-start gap-1.5">
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
<span>{error}</span>
</p>
)}
{shown && !busy && (
<div
className={`p-3 rounded-card border text-xs space-y-2 ${
result ? 'border-status-ok/40 bg-status-ok/10' : 'border-border bg-panel'
}`}
>
<p className="font-semibold text-main flex items-center gap-1.5">
{result ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <FileArchive className="w-3.5 h-3.5 text-accent" />}
<span>{result ? 'Import abgeschlossen' : `Vorschau: ${file?.name}`}</span>
</p>
<p className="text-muted">
{result ? 'Angelegt: ' : 'Wird angelegt: '}
{summaryParts(shown).join(', ') || 'nichts'}.
</p>
{shown.skipped.length > 0 && (
<details className="text-[11px] text-muted">
<summary className="cursor-pointer">{shown.skipped.length} übersprungen</summary>
<ul className="mt-1 space-y-0.5 max-h-40 overflow-y-auto">
{shown.skipped.map((s, i) => (
<li key={i}>
<span className="font-mono text-main">{s.path}</span> – {s.reason}
</li>
))}
</ul>
</details>
)}
{!result && (
<div className="flex flex-col sm:flex-row gap-2 pt-1">
<button type="button" onClick={runImport} className="btn-primary h-11 sm:h-8 px-4 text-xs font-semibold">
Jetzt importieren
</button>
<button type="button" onClick={reset} className="btn-action h-11 sm:h-8 px-3 text-xs">
Abbrechen
</button>
</div>
)}
</div>
)}
</div>
);
};
@@ -27,6 +27,7 @@ import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
interface UserSettingsViewProps {
currentUser: User;
@@ -1273,6 +1274,42 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
</button>
</div>
{/* Import: two separate paths, never guessed from the file */}
<div className="pt-4 border-t border-border space-y-3">
<div>
<h3 className="font-bold text-main">Daten importieren</h3>
<p className="text-muted text-[11px]">
Ein Import legt alles neu an und ändert nichts Bestehendes. Vor dem Import siehst du, was angelegt wird.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<ImportCard
source="mindsift"
title="MindSift-Daten"
accept=".json,.zip,application/json,application/zip"
description={
<>
Eigenes Backup zurückspielen: JSON-Backup (<code className="font-mono">.json</code>) oder Markdown-Archiv
(<code className="font-mono">.zip</code>). Notizen kommen in ihre Ordner, Aufgabenlisten mit Unteraufgaben,
Fälligkeiten und Prioritäten sowie Listen werden wiederhergestellt.
</>
}
/>
<ImportCard
source="obsidian"
title="Obsidian-Vault"
accept=".zip,application/zip"
description={
<>
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
#Hashtags. Bilder und andere Anhänge werden übersprungen.
</>
}
/>
</div>
</div>
</div>
)}
</div>
+17
View File
@@ -19,6 +19,7 @@ import {
SystemSettings,
SharingMode,
UserTheme,
ImportSummary,
Team,
TaskList,
TaskListShare,
@@ -1076,6 +1077,22 @@ export const api = {
return request<any>('/api/v1/export');
},
// Import of MindSift data (JSON backup or Markdown archive) or an Obsidian vault (ZIP).
// preview = true only reports what would be created.
async importData(source: 'mindsift' | 'obsidian', file: File, preview: boolean): Promise<ImportSummary> {
const token = getToken();
const form = new FormData();
form.append('file', file);
const res = await fetch(`/api/v1/import/${source}${preview ? '?preview=1' : ''}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Import fehlgeschlagen (HTTP ${res.status})`);
return data as ImportSummary;
},
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
async exportMarkdownVault(): Promise<Blob> {
const token = getToken();
+13
View File
@@ -417,6 +417,19 @@ export interface SharedList {
createdAt: string;
}
// Result of an import preview (applied = false) or of the import itself
export interface ImportSummary {
folders: number;
notes: number;
taskLists: number;
tasks: number;
lists: number;
listItems: number;
inbox: number;
skipped: { path: string; reason: string }[];
applied: boolean;
}
// Custom CSS theme of the current user (Benutzereinstellungen > Darstellung)
export interface UserTheme {
id: string;