import type { ConversionJobStatus, ConversionJobView, PageView } from '@dorfteich/shared'; import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ApiError, apiGet, apiUploadFile } from '../lib/api'; /** UI phase of one document import. `queued`/`converting` mirror the job while a * `.docx`/`.odt` conversion runs; `.md` jumps straight to `done` (#64). */ export type ImportPhase = 'uploading' | 'queued' | 'converting' | 'done' | 'failed'; export interface ImportTask { id: string; fileName: string; phase: ImportPhase; /** errors-namespace code when `phase` is `failed`. */ errorCode?: string; } const POLL_INTERVAL_MS = 1000; // A single conversion is capped at 60 s per pass (two passes) — poll well past // that so a job that ends in `converter_timeout` is still observed settling. const MAX_POLLS = 180; // Keep a finished import visible briefly, then clear it so the panel does not // accumulate (the user has usually navigated to the new page by then). const DONE_LINGER_MS = 4000; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); function phaseFor(status: ConversionJobStatus): ImportPhase { if (status === 'succeeded') return 'done'; if (status === 'failed') return 'failed'; if (status === 'running') return 'converting'; return 'queued'; } export interface UseImport { tasks: ImportTask[]; startImport: (files: File[]) => void; retry: (taskId: string) => void; dismiss: (taskId: string) => void; } /** * Drives document imports from the sidebar (#64): upload each file to * `POST /ponds/:id/import`, then — for a conversion job — poll `GET /jobs/:id` * until it succeeds or fails, showing the phase. On the first success of a batch * it navigates to the new page; every success refreshes the sidebar list, so * concurrent imports all appear. Failures stay in the panel with a retry. */ export function useImport(pondId: string, pondSlug: string): UseImport { const navigate = useNavigate(); const queryClient = useQueryClient(); const [tasks, setTasks] = useState([]); const files = useRef(new Map()); // Only the first completed import of a batch pulls the user to its page. const navigated = useRef(false); const patch = useCallback((id: string, next: Partial): void => { setTasks((prev) => prev.map((task) => (task.id === id ? { ...task, ...next } : task))); }, []); const onSucceeded = useCallback( async (id: string, pageId: string): Promise => { patch(id, { phase: 'done', errorCode: undefined }); // Refresh the sidebar page list so every imported page shows up. await queryClient.invalidateQueries({ queryKey: ['pages'] }); if (!navigated.current) { navigated.current = true; try { const page = await apiGet(`/pages/${pageId}`); navigate(`/p/${pondSlug}/${page.slug}`); } catch { // The page exists regardless; leaving the user where they are is fine. } } setTimeout(() => dismissRef.current(id), DONE_LINGER_MS); }, [navigate, patch, pondSlug, queryClient], ); const runOne = useCallback( async (id: string, file: File): Promise => { patch(id, { phase: 'uploading', errorCode: undefined }); try { let job = await apiUploadFile(`/ponds/${pondId}/import`, file); for ( let poll = 0; job.status !== 'succeeded' && job.status !== 'failed' && poll < MAX_POLLS; poll += 1 ) { patch(id, { phase: phaseFor(job.status) }); await delay(POLL_INTERVAL_MS); job = await apiGet(`/jobs/${job.id}`); } if (job.status === 'succeeded' && job.resultPageId) { await onSucceeded(id, job.resultPageId); } else { patch(id, { phase: 'failed', errorCode: job.errorCode ?? 'conversion_failed' }); } } catch (error) { patch(id, { phase: 'failed', errorCode: error instanceof ApiError ? error.body.code : 'network', }); } }, [onSucceeded, patch, pondId], ); const startImport = useCallback( (picked: File[]): void => { if (picked.length === 0) return; navigated.current = false; const started = picked.map((file) => { const id = crypto.randomUUID(); files.current.set(id, file); return { id, fileName: file.name, phase: 'uploading' as const }; }); setTasks((prev) => [...prev, ...started]); started.forEach((task) => void runOne(task.id, files.current.get(task.id)!)); }, [runOne], ); const retry = useCallback( (id: string): void => { const file = files.current.get(id); if (file) void runOne(id, file); }, [runOne], ); const dismiss = useCallback((id: string): void => { files.current.delete(id); setTasks((prev) => prev.filter((task) => task.id !== id)); }, []); // `onSucceeded` schedules a dismiss; keep a stable reference to the latest one. const dismissRef = useRef(dismiss); dismissRef.current = dismiss; return { tasks, startImport, retry, dismiss }; }