/** * useMultiFileProcessor — queue-and-batch hook for tools that accept N PDFs. * * Design notes: * * - Most PDF tool endpoints take a single `file=` field. So we don't try to * coerce the backend; we just loop client-side, one request per file, and * accumulate the result blobs. * * - Concurrency is bounded (default 3). PDF endpoints are CPU-bound on the * server (PyMuPDF rendering, image rasterisation). Three in flight lines * up nicely with a typical 4-core worker without head-of-line blocking. * * - Per-file status is tracked in a parallel array — UI components render * "queued / running / done / failed" badges off it. * * - Partial-failure retry: rerun only files whose status is "failed", or * with `run(opts, "transient")` only those that failed for a reason that * can pass (connection, time limit, rate limit, server fault). A file the * tool refused would fail the same way again. Successful blobs stay intact. * * - A file the caller's filter refuses is never dropped silently: addFiles * returns it and, unless told otherwise, says so in a toast that names the * file and the tool that takes it. * * - When N=1 the caller can opt to download the raw blob directly (no zip). * We expose the blobs as `results`; the helper `downloadAll()` does the * "N=1 → blob, N>1 → zip" branching so most call sites stay tiny. */ import { useCallback, useRef, useState } from "react"; import { uploadFile, downloadBlob, buildOutputFilename, chooseDownloadFilename, hasUserMessage, type UploadOptions } from "@/lib/api"; import { buildZip } from "@/lib/zip"; import { friendlyError } from "@/lib/utils"; import { emitToolRun, isTransientFailure, runOutcome, toolErrorKind, type ToolErrorKind } from "@/lib/toolRun"; import { reportRejectedFiles } from "@/lib/report-rejected-files"; export type FileStatus = "queued" | "running" | "done" | "failed"; export interface FileEntry { id: string; file: File; name: string; size: number; status: FileStatus; error?: string; /** Why the file failed, in the fixed categories of lib/toolRun.ts. */ errorKind?: ToolErrorKind; /** A failure trying again could fix: connection, time limit, rate limit * or a server fault. False when the tool refused the file itself. */ retryable?: boolean; /** Server-provided filename from Content-Disposition, if any. */ outName?: string; /** Set once the upload succeeds. */ blob?: Blob; /** Custom response headers we surface so tools like Highlight can read * per-file metadata (e.g. X-Highlight-Hits). */ headers?: Record; } export interface ProcessOptions { /** Backend endpoint. Will be prefixed with /api/. */ endpoint: string; /** Form-data params sent with every file. */ params?: Record; /** Output extension for the per-file download name (e.g. "pdf", "docx"). */ outputExt: string; /** Suffix added to the source filename stem (e.g. "compressed"). */ outputSuffix: string | null; /** Concurrency cap. Default 3. */ concurrency?: number; /** Per-upload options (timeout, retries) passed through to uploadFile. */ uploadOptions?: UploadOptions; /** Client-side processor — when set, files never leave the browser: * the worker calls this instead of POSTing to `endpoint`. */ localProcess?: (file: File) => Promise; } export interface LocalResult { blob: Blob; outName?: string; /** Set when the processor handled the file but could not do its job on it * (an image with no watermark to remove, say) and returned it unchanged. * The file still counts as done on the page; the run's usage signal * counts it as a failure of this kind, so such misses stay visible. */ unchanged?: ToolErrorKind; } export interface UseMultiFileProcessorResult { entries: FileEntry[]; /** True while any file is still queued or running. */ busy: boolean; /** True once all entries have a terminal status (done or failed). */ finished: boolean; /** Count of entries with status === "done". */ doneCount: number; /** Count of entries with status === "failed". */ failedCount: number; /** Count of failed entries that trying again could fix. */ retryableCount: number; /** Queue the files the filter accepts and return the ones it refused. * Refused files are reported in a toast unless `report` is false, for * callers that show them beside their own intake. */ addFiles: (files: FileList | File[], filter?: (f: File) => boolean, options?: { report?: boolean }) => File[]; removeFile: (id: string) => void; clearAll: () => void; /** Reorder by moving the entry at `from` to `to`. */ reorder: (from: number, to: number) => void; /** Start processing all queued + failed files; `true` retries only the * failed ones, `"transient"` only those that trying again could fix. * Resolves with the entries this run took, as it left them. Read the * outcome from that, not from `entries` or the counts: a callback made in * an earlier render still holds that render's values after the await. */ run: (opts: ProcessOptions, retryOnly?: boolean | "transient") => Promise; /** Trigger browser download. Zips if N>1, downloads single blob if N=1. */ downloadAll: (archiveBaseName: string) => void; /** Reset everything back to empty. */ reset: () => void; } let counter = 0; function makeEntry(file: File): FileEntry { return { id: `${Date.now().toString(36)}-${++counter}`, file, name: file.name, size: file.size, status: "queued", }; } export function useMultiFileProcessor(): UseMultiFileProcessorResult { const [entries, setEntries] = useState([]); // React defers state-updater callbacks, so code that "reads" state by // passing through an updater sees nothing until the next render — which // silently emptied run()'s work list. The ref mirror is written // synchronously on every mutation and is the source of truth for all // imperative reads; setEntries only feeds the render. const entriesRef = useRef([]); const mutate = useCallback((updater: (prev: FileEntry[]) => FileEntry[]) => { entriesRef.current = updater(entriesRef.current); setEntries(entriesRef.current); }, []); // The `inFlight` ref lets the caller call `run()` again without races — // we just refuse to start a second pass while one is going. const inFlight = useRef(false); const addFiles = useCallback((fl: FileList | File[], filter?: (f: File) => boolean, options?: { report?: boolean }) => { const arr = Array.from(fl); const accepted = filter ? arr.filter(filter) : arr; const rejected = filter ? arr.filter(file => !accepted.includes(file)) : []; if (rejected.length && options?.report !== false) reportRejectedFiles(rejected); if (accepted.length) mutate(prev => [...prev, ...accepted.map(makeEntry)]); return rejected; }, [mutate]); const removeFile = useCallback((id: string) => { mutate(prev => prev.filter(e => e.id !== id)); }, [mutate]); const clearAll = useCallback(() => mutate(() => []), [mutate]); const reorder = useCallback((from: number, to: number) => { mutate(prev => { if (from === to || from < 0 || to < 0 || from >= prev.length || to >= prev.length) return prev; const next = [...prev]; const [moved] = next.splice(from, 1); next.splice(to, 0, moved); return next; }); }, [mutate]); const reset = useCallback(() => { inFlight.current = false; mutate(() => []); }, [mutate]); const run = useCallback(async (opts: ProcessOptions, retryOnly: boolean | "transient" = false): Promise => { if (inFlight.current) return []; inFlight.current = true; // Snapshot from the ref — synchronous and stale-closure-free. const targetIds = entriesRef.current .filter(e => retryOnly === "transient" ? e.status === "failed" && e.retryable : retryOnly ? e.status === "failed" : (e.status === "queued" || e.status === "failed")) .map(e => e.id); // Mark them as queued (clears prior error states for retry path). mutate(prev => prev.map(e => targetIds.includes(e.id) ? { ...e, status: "queued", error: undefined, errorKind: undefined, retryable: undefined } : e)); // Tiny semaphore — N workers pull from a shared cursor. const concurrency = Math.max(1, opts.concurrency ?? 3); let cursor = 0; // The first failure names the run's error category in the usage signal. let firstFailure: unknown = null; // Files a local processor returned unchanged, with why (LocalResult.unchanged). const unchanged = new Map(); const ids = targetIds; // captured const worker = async () => { while (true) { const idx = cursor++; if (idx >= ids.length) return; const id = ids[idx]; // Grab the file from the ref (it might have been removed since) const entry = entriesRef.current.find(x => x.id === id); const file: File | null = entry?.file ?? null; if (!file) continue; mutate(prev => prev.map(x => x.id === id ? { ...x, status: "running" } : x)); try { if (opts.localProcess) { const out = await opts.localProcess(file as File); if (out.unchanged) unchanged.set(id, out.unchanged); const outName = out.outName || buildOutputFilename((file as File).name, opts.outputSuffix, opts.outputExt); mutate(prev => prev.map(x => x.id === id ? { ...x, status: "done", blob: out.blob, outName, headers: {} } : x, )); continue; } const res = await uploadFile(opts.endpoint, file as File, opts.params, opts.uploadOptions); const blob = await res.blob(); // Pull server-supplied filename if present; otherwise build one. const cd = res.headers.get("Content-Disposition") || ""; let serverName: string | null = null; const utf8 = cd.match(/filename\*=UTF-8''([^;]+)/i); if (utf8?.[1]) { try { serverName = decodeURIComponent(utf8[1].trim()); } catch { serverName = utf8[1].trim(); } } else { const ascii = cd.match(/filename=(["']?)([^"';]+)\1/i); if (ascii?.[2]) serverName = ascii[2].trim(); } // As every other download: a generic server name such as // "converted.docx" does not replace the file's own. const outName = chooseDownloadFilename( buildOutputFilename((file as File).name, opts.outputSuffix, opts.outputExt), serverName); // Capture all response headers — small cost, lets callers // read tool-specific metadata (e.g. X-Highlight-Hits) without // a second round trip. const headers: Record = {}; res.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; }); mutate(prev => prev.map(x => x.id === id ? { ...x, status: "done", blob, outName, headers } : x, )); } catch (e: unknown) { firstFailure ??= e; const raw = e instanceof Error ? e.message : "Failed"; const msg = hasUserMessage(e) ? raw : friendlyError(raw, "Processing failed"); const kind = toolErrorKind(e); // A cancelled file can simply run again. const retryable = kind === "cancelled" || isTransientFailure(e); mutate(prev => prev.map(x => x.id === id ? { ...x, status: "failed", error: msg, errorKind: kind === "cancelled" ? undefined : kind, retryable } : x, )); } } }; const workers: Promise[] = []; for (let i = 0; i < Math.min(concurrency, ids.length); i++) workers.push(worker()); await Promise.all(workers); // One usage signal per run, counting only the files this run touched. // A file returned unchanged counts as a failure there, never as a success. const touched = entriesRef.current.filter(e => ids.includes(e.id)); const done = touched.filter(e => e.status === "done").length; const failed = touched.filter(e => e.status === "failed").length; const missed = touched.filter(e => e.status === "done" && unchanged.has(e.id)); const outcome = runOutcome(done - missed.length, failed + missed.length); const missKind = firstFailure === null && missed.length ? unchanged.get(missed[0].id) : undefined; if (outcome) emitToolRun({ mode: "single", outcome, files: done + failed, ...(missKind ? { errorKind: missKind } : {}) }, firstFailure); inFlight.current = false; return touched; }, [mutate]); const downloadAll = useCallback((archiveBaseName: string) => { const done = entriesRef.current.filter(e => e.status === "done" && e.blob); if (done.length === 0) return; if (done.length === 1) { const e = done[0]; downloadBlob(e.blob!, e.outName || e.name); return; } // N>1 → zip them const buildAndDownload = async () => { const items = await Promise.all(done.map(async e => ({ name: e.outName || e.name, data: new Uint8Array(await e.blob!.arrayBuffer()), }))); const zipBlob = buildZip(items); downloadBlob(zipBlob, archiveBaseName.endsWith(".zip") ? archiveBaseName : `${archiveBaseName}.zip`); }; void buildAndDownload(); }, []); // Derive aggregate counts. Cheap to recompute every render. const doneCount = entries.filter(e => e.status === "done").length; const failedCount = entries.filter(e => e.status === "failed").length; const retryableCount = entries.filter(e => e.status === "failed" && e.retryable).length; const busy = entries.some(e => e.status === "queued" || e.status === "running") && inFlight.current; const finished = entries.length > 0 && entries.every(e => e.status === "done" || e.status === "failed"); return { entries, busy, finished, doneCount, failedCount, retryableCount, addFiles, removeFile, clearAll, reorder, run, downloadAll, reset, }; }