feat(import): restore MindSift data and import Obsidian vaults
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
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:
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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user