"use client"; /** Settings → Data: export (ZIP / JSON), import dropzone with progress polling, encrypted backups list + run/verify. */ import * as React from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Archive, Database, Download, FileJson, FileUp, HardDrive, Play, RefreshCw, ShieldCheck, Upload } from "lucide-react"; import { api } from "@/lib/client/api"; import { cn, fmtDate, timeAgo } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Badge, Spinner, Tip } from "@/components/ui/primitives"; import { toast } from "@/components/ui/toast"; const ACCEPT = ".md,.markdown,.txt,.zip,.docx,.pdf,.csv,.bib,.json"; const fmtBytes = (n: number | null | undefined) => (n == null ? "–" : n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(2)} GB`); type Progress = { jobId?: string; status: "queued" | "running" | "done" | "error"; progress: number; message?: string; pageId?: string | null; error?: string; stats?: { pages: number; databases: number; rows: number; attachments: number; references: number; skipped: string[] }; filename?: string; kind?: string }; type BackupJob = { id: string; kind: string; status: string; path: string | null; sizeBytes: number | null; sha256: string | null; offsite: boolean | null; error: string | null; startedAt: string; finishedAt: string | null }; type BackupsResp = { jobs: BackupJob[]; lastOk: BackupJob | null; config: { dir: string | null; offsite: string | null; keepDays: number; schedule: string; passphraseSet: boolean } }; function Section({ icon, title, hint, children, action }: { icon: React.ReactNode; title: string; hint?: string; children: React.ReactNode; action?: React.ReactNode }) { return (
{icon}

{title}

{hint &&

{hint}

}
{action}
{children}
); } function ExportBlock() { const items = [ { href: "/api/export?format=zip", icon: , label: "Workspace ZIP", desc: "Markdown per page (folders mirror the tree), CSV per database, attachments/, references.bib, workspace.json" }, { href: "/api/export?format=json", icon: , label: "JSON backup", desc: "Every content table, re-importable here (merge by id)" }, { href: "/api/export?format=bib", icon: , label: "references.bib", desc: "All references as BibTeX" }, ]; return (
{items.map((it) => ( {it.icon} {it.label}{it.desc} ))}

Per page: use the page menu → Export (Markdown, PDF print view) or /api/export?format=md|pdf|zip|csv&pageId=….

); } function ImportBlock() { const qc = useQueryClient(); const inputRef = React.useRef(null); const [drag, setDrag] = React.useState(false); const [busy, setBusy] = React.useState(null); const [prog, setProg] = React.useState(null); const pollRef = React.useRef(null); React.useEffect(() => () => { if (pollRef.current) window.clearInterval(pollRef.current); }, []); const finish = (p: Progress) => { setProg(p); setBusy(null); qc.invalidateQueries({ queryKey: ["tree"] }); if (p.status === "done") toast("Import finished", { tone: "success", description: p.stats ? `${p.stats.pages} pages, ${p.stats.databases} databases, ${p.stats.rows} rows, ${p.stats.attachments} files, ${p.stats.references} refs` : undefined }); else toast("Import failed", { tone: "danger", description: p.error }); }; const poll = (jobId: string) => { if (pollRef.current) window.clearInterval(pollRef.current); pollRef.current = window.setInterval(async () => { try { const p = await api(`/api/import?jobId=${jobId}`); setProg(p); if (p.status === "done" || p.status === "error") { window.clearInterval(pollRef.current!); pollRef.current = null; finish(p); } } catch (e) { window.clearInterval(pollRef.current!); pollRef.current = null; finish({ status: "error", progress: 0, error: (e as Error).message }); } }, 1500); }; const upload = async (files: FileList | File[]) => { const list = Array.from(files); if (!list.length) return; for (const file of list) { setBusy(file.name); setProg({ status: "queued", progress: 0, message: "Uploading…", filename: file.name }); const fd = new FormData(); fd.append("file", file); try { const r = await api("/api/import", { method: "POST", body: fd }); if (r.jobId) { setProg({ jobId: r.jobId, status: "queued", progress: 0.01, message: "Queued for the worker", filename: file.name, kind: r.kind }); poll(r.jobId); await new Promise((res) => { const t = window.setInterval(() => { if (!pollRef.current) { window.clearInterval(t); res(); } }, 500); }); } else finish({ status: "done", progress: 1, pageId: r.pageId, stats: r.stats, filename: file.name, kind: r.kind }); } catch (e) { finish({ status: "error", progress: 0, error: (e as Error).message, filename: file.name }); } } }; return (
{ if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }} onClick={() => inputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={(e) => { e.preventDefault(); setDrag(false); if (!busy) void upload(e.dataTransfer.files); }} className={cn("flex min-h-[112px] cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed p-4 text-center transition-colors", drag ? "border-accent bg-accent-soft" : "border-line bg-bg hover:bg-bg-sunk", busy && "pointer-events-none opacity-70")}> { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} /> {busy ? : }
{busy ? `Importing ${busy}…` : "Drop files here or click to choose"}
Markdown · Notion export ZIP · Markdown ZIP · DOCX · PDF · CSV → database · BibTeX → references · JSON backup
{prog && (
{prog.filename ?? "Import"}{prog.status}
{(prog.status === "queued" || prog.status === "running") &&
} {prog.message && prog.status !== "done" &&
{prog.message}
} {prog.status === "error" &&
{prog.error}
} {prog.status === "done" &&
{prog.stats && {prog.stats.pages} pages · {prog.stats.databases} databases · {prog.stats.rows} rows · {prog.stats.attachments} files · {prog.stats.references} refs}{prog.pageId && Open imported page →}{prog.stats?.skipped?.length ? {prog.stats.skipped.length} note(s): {prog.stats.skipped.slice(0, 2).join("; ")} : null}
}
)}
); } function BackupsBlock() { const qc = useQueryClient(); const q = useQuery({ queryKey: ["backups"], queryFn: () => api("/api/backups"), refetchInterval: (query) => (query.state.data?.jobs.some((j) => j.status === "running" || j.status === "queued") ? 4000 : false) }); const [running, setRunning] = React.useState(false); const [verifying, setVerifying] = React.useState(null); const run = async () => { setRunning(true); try { await api("/api/backups", { method: "POST", json: { action: "run" } }); toast("Backup queued", { description: "The worker runs scripts/backup.sh; this list refreshes automatically." }); qc.invalidateQueries({ queryKey: ["backups"] }); } catch (e) { toast("Could not start backup", { tone: "danger", description: (e as Error).message }); } finally { setRunning(false); } }; const verify = async (id: string) => { setVerifying(id); try { const r = await api("/api/backups", { method: "POST", json: { action: "verify", id } }); toast(r.ok ? "Archive verified" : "Verification failed", { tone: r.ok ? "success" : "danger", description: r.ok ? `${r.path} · ${fmtBytes(r.size)} · sha256 matches` : r.reason ?? (r.exists ? "sha256 or size mismatch" : "archive missing") }); } catch (e) { toast("Verify failed", { tone: "danger", description: (e as Error).message }); } finally { setVerifying(null); } }; const d = q.data; const jobs = d?.jobs ?? []; const tone = (s: string) => (s === "ok" ? "success" : s === "failed" ? "danger" : s === "running" || s === "queued" ? "accent" : "neutral") as "success" | "danger" | "accent" | "neutral"; return (
Last successful
{d?.lastOk ? `${timeAgo(d.lastOk.finishedAt ?? d.lastOk.startedAt)} · ${fmtBytes(d.lastOk.sizeBytes)}` : q.isLoading ? "…" : "never"}
Schedule
{d?.config.schedule ?? "…"}
keep {d?.config.keepDays ?? 30} days
Off-site copy
{d?.config.offsite ?? "not configured"}
{d && !d.config.passphraseSet &&
BACKUP_PASSPHRASE missing
}
{q.isError ?
{(q.error as Error).message}
: jobs.length === 0 ?
{q.isLoading ? : "No backup yet — run one now."}
: (
    {jobs.map((j) => (
  • {fmtDate(j.startedAt, { dateStyle: "medium", timeStyle: "short" })} {j.status} {fmtBytes(j.sizeBytes)} {j.offsite ? "off-site" : "local only"} {j.error ? {j.error} : j.path?.split("/").pop()} {j.status === "ok" && j.path && }
  • ))}
)}
pg_dump + uploads → AES-256 (pbkdf2) → sha256 → rsync. Restore test: scripts/restore.sh --latest --verify-only (see docs/operations.md).
); } export function DataSection() { return (
} title="Export" hint="Your data in open formats — Markdown, CSV, BibTeX, JSON.">
} title="Import" hint="Small files are imported immediately; large ones run in the background worker with progress.">
} title="Backups" hint="Nightly encrypted backup of the database and uploads, with an off-site copy.">
); }