import { m } from '$lib/paraglide/messages.js'; import { getErrorMessage } from '$lib/shared/errors'; import { translateOcrProgress } from '$lib/ocr/translateOcrProgress'; export interface OcrJobOptions { documentId: () => string; fetchImpl?: typeof fetch; onJobFinished?: (status: 'DONE' | 'FAILED') => void | Promise; /** Polling interval in ms — defaults to 2000. Tests pass a small value. */ pollIntervalMs?: number; /** Reset delay in ms after DONE/FAILED before clearing UI state. Defaults to 1000. */ resetDelayMs?: number; } export interface OcrJobController { readonly running: boolean; readonly progressMessage: string; readonly errorMessage: string; readonly skippedPages: number; triggerOcr(scriptType: string, useExistingAnnotations: boolean): Promise; checkStatus(): Promise; destroy(): void; } const DEFAULT_POLL_INTERVAL_MS = 2000; const DEFAULT_RESET_DELAY_MS = 1000; export function createOcrJob(options: OcrJobOptions): OcrJobController { const { documentId, onJobFinished } = options; const fetchImpl = options.fetchImpl ?? fetch; const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; const resetDelayMs = options.resetDelayMs ?? DEFAULT_RESET_DELAY_MS; let running = $state(false); let progressMessage = $state(''); let errorMessage = $state(''); let skippedPages = $state(0); let pollTimer: ReturnType | null = null; function clearPolling(): void { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } function startPolling(jobId: string): void { clearPolling(); pollTimer = setInterval(() => { void pollOnce(jobId); }, pollIntervalMs); } async function pollOnce(jobId: string): Promise { try { const res = await fetchImpl(`/api/ocr/jobs/${jobId}`); if (!res.ok) return; const job = (await res.json()) as { status: string; progressMessage?: string }; const progress = translateOcrProgress(job.progressMessage ?? ''); progressMessage = progress.message; if (progress.skippedPages !== undefined) { skippedPages = progress.skippedPages; } if (job.status === 'DONE' || job.status === 'FAILED') { clearPolling(); const finalStatus = job.status as 'DONE' | 'FAILED'; setTimeout(() => { running = false; progressMessage = ''; skippedPages = 0; }, resetDelayMs); if (finalStatus === 'FAILED') { errorMessage = m.ocr_status_error(); } await onJobFinished?.(finalStatus); } } catch { // polling is best-effort } } async function triggerOcr(scriptType: string, useExistingAnnotations: boolean): Promise { running = true; errorMessage = ''; try { const res = await fetchImpl(`/api/documents/${documentId()}/ocr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scriptType, useExistingAnnotations }) }); if (res.ok) { const data = (await res.json()) as { jobId: string }; startPolling(data.jobId); } else { running = false; const body = await res.json().catch(() => null); const code = (body as { code?: string } | null)?.code; errorMessage = code ? getErrorMessage(code) : m.ocr_status_error(); } } catch { running = false; errorMessage = m.ocr_status_error(); } } async function checkStatus(): Promise { const id = documentId(); if (!id) return; try { const res = await fetchImpl(`/api/documents/${id}/ocr-status`); if (!res.ok) return; const status = (await res.json()) as { status: string; jobId: string | null }; if ((status.status === 'PENDING' || status.status === 'RUNNING') && status.jobId) { running = true; startPolling(status.jobId); } } catch { // best-effort } } function destroy(): void { clearPolling(); } return { get running() { return running; }, get progressMessage() { return progressMessage; }, get errorMessage() { return errorMessage; }, get skippedPages() { return skippedPages; }, triggerOcr, checkStatus, destroy }; }