2026-10-23 04:43:56 +03:00
|
|
|
"use client";
|
2026-10-25 17:18:44 +03:00
|
|
|
import { useI18n } from "@/components/i18n";
|
2026-10-23 04:43:56 +03:00
|
|
|
|
|
|
|
|
import { useRef, useState } from "react";
|
|
|
|
|
|
|
|
|
|
// Transport-only types: never import the server's extraction/agent module here.
|
|
|
|
|
export interface NotesImportResult {
|
|
|
|
|
requestIndex: number;
|
|
|
|
|
filePath: string;
|
|
|
|
|
ok: boolean;
|
|
|
|
|
outcome: "completed" | "failed" | "partial" | "aborted" | "busy";
|
|
|
|
|
stats?: { nodes: number; chains?: number; droppedChains?: number };
|
|
|
|
|
error?: string;
|
|
|
|
|
code?: string;
|
|
|
|
|
missing?: string[];
|
|
|
|
|
provider?: string;
|
|
|
|
|
slots?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Validate the entire response before applying any row: a malformed index
|
|
|
|
|
* must never attribute another file's success to this file (same names included). */
|
|
|
|
|
export function parseNotesImportResults(body: unknown, files: readonly File[]): NotesImportResult[] {
|
|
|
|
|
const invalid = () => new Error("Invalid import response. Please retry the file.");
|
|
|
|
|
if (!body || typeof body !== "object" || !("results" in body) || !Array.isArray(body.results) || body.results.length !== files.length) throw invalid();
|
|
|
|
|
const seen = new Set<number>();
|
|
|
|
|
for (const raw of body.results) {
|
|
|
|
|
if (!raw || typeof raw !== "object") throw invalid();
|
|
|
|
|
const r = raw as NotesImportResult;
|
|
|
|
|
if (!Number.isInteger(r.requestIndex) || r.requestIndex < 0 || r.requestIndex >= files.length || seen.has(r.requestIndex) || r.filePath !== files[r.requestIndex]!.name) throw invalid();
|
|
|
|
|
seen.add(r.requestIndex);
|
|
|
|
|
if (!["completed", "failed", "partial", "aborted", "busy"].includes(r.outcome) || typeof r.ok !== "boolean" || r.ok !== (r.outcome === "completed")) throw invalid();
|
|
|
|
|
if (r.stats !== undefined && (!r.stats || typeof r.stats !== "object" || !Number.isFinite(r.stats.nodes) || r.stats.nodes < 0 || (r.stats.chains !== undefined && (!Number.isFinite(r.stats.chains) || r.stats.chains < 0)))) throw invalid();
|
|
|
|
|
if (r.ok && !r.stats) throw invalid();
|
|
|
|
|
if (!r.ok && typeof r.error !== "string") throw invalid();
|
|
|
|
|
for (const field of ["error", "code", "provider"] as const) if (r[field] !== undefined && typeof r[field] !== "string") throw invalid();
|
|
|
|
|
for (const field of ["missing", "slots"] as const) if (r[field] !== undefined && (!Array.isArray(r[field]) || !r[field]!.every((v) => typeof v === "string"))) throw invalid();
|
|
|
|
|
}
|
|
|
|
|
return body.results as NotesImportResult[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function postNotesImport(files: readonly File[]): Promise<NotesImportResult[]> {
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
for (const file of files) form.append("files", file);
|
|
|
|
|
const response = await fetch("/api/graph/import", { method: "POST", body: form });
|
|
|
|
|
let body: unknown;
|
|
|
|
|
try { body = await response.json(); }
|
|
|
|
|
catch { throw new Error("Invalid import response. Please retry the file."); }
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const detail = body && typeof body === "object" && "error" in body && typeof body.error === "string" ? `: ${body.error}` : "";
|
|
|
|
|
throw new Error(`Import request failed (HTTP ${response.status})${detail}. Please retry the file.`);
|
|
|
|
|
}
|
|
|
|
|
return parseNotesImportResults(body, files);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 06:14:33 +03:00
|
|
|
/** Only error text survives a request; selected Files stay request-local. */
|
2026-10-23 04:43:56 +03:00
|
|
|
export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () => void } = {}) {
|
2026-10-25 17:18:44 +03:00
|
|
|
const { t } = useI18n();
|
|
|
|
|
const [failures, setFailures] = useState<Array<{ file: string; message: string; detail?: string }>>([]);
|
|
|
|
|
const errors = failures.map((failure) => `${failure.file}: ${t(failure.message)}${failure.detail ?? ""}`);
|
2026-10-23 04:43:56 +03:00
|
|
|
const [importing, setImporting] = useState(false);
|
|
|
|
|
const pending = useRef(false);
|
2026-10-23 06:14:33 +03:00
|
|
|
const submit = async (files: File[]) => {
|
2026-10-23 04:43:56 +03:00
|
|
|
if (pending.current || files.length === 0) return;
|
|
|
|
|
pending.current = true;
|
|
|
|
|
setImporting(true);
|
2026-10-25 17:18:44 +03:00
|
|
|
setFailures([]);
|
2026-10-23 04:43:56 +03:00
|
|
|
try {
|
|
|
|
|
callbacks.onStart?.();
|
|
|
|
|
const results = await postNotesImport(files);
|
2026-10-25 17:18:44 +03:00
|
|
|
setFailures(results
|
2026-10-23 06:14:33 +03:00
|
|
|
.filter((result) => !result.ok)
|
|
|
|
|
.sort((a, b) => a.requestIndex - b.requestIndex)
|
2026-10-25 17:18:44 +03:00
|
|
|
.map((r) => ({ file: r.filePath, message: r.error ?? "", detail: r.code ? `\n${r.code}${r.provider ? ` — ${r.provider}` : ""}${r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}${r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}` : "" })));
|
2026-10-23 04:43:56 +03:00
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
2026-10-25 17:18:44 +03:00
|
|
|
setFailures(files.map((file) => ({ file: file.name, message })));
|
2026-10-23 04:43:56 +03:00
|
|
|
} finally {
|
|
|
|
|
pending.current = false;
|
|
|
|
|
setImporting(false);
|
|
|
|
|
callbacks.onFinish?.();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
return {
|
2026-10-23 06:14:33 +03:00
|
|
|
errors, importing,
|
2026-10-23 04:43:56 +03:00
|
|
|
onImportFiles: (files: FileList | null) => { if (files) void submit(Array.from(files)); },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 06:14:33 +03:00
|
|
|
export function NotesImportErrors({ errors }: { errors: readonly string[] }) {
|
|
|
|
|
return <>{errors.map((error, i) => (
|
|
|
|
|
<p key={i} role="alert" className="max-w-full whitespace-pre-wrap break-words text-xs text-red-400">{error}</p>
|
|
|
|
|
))}</>;
|
2026-10-23 04:43:56 +03:00
|
|
|
}
|