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>
165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
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>
|
||
);
|
||
};
|