import { sha256HexFromArrayBuffer } from '@/lib/client/sha256'; import type { BaseDocument, DocumentType } from '@/types/documents'; import type { ParsedPdfDocument } from '@/types/parsed-pdf'; import type { DocumentSettings } from '@/types/document-settings'; export type UploadSource = { id: string; name: string; type: DocumentType; size: number; lastModified: number; contentType: string; body: Blob | ArrayBuffer | Uint8Array; }; type UploadOptions = { signal?: AbortSignal; }; function toUploadBody(body: UploadSource['body']): BodyInit { if (body instanceof Blob) return body; if (body instanceof ArrayBuffer) return body; return body as unknown as BodyInit; } async function uploadDocumentSourceViaProxy(source: UploadSource, options?: UploadOptions): Promise { const res = await fetch(`/api/documents/blob/upload/fallback?id=${encodeURIComponent(source.id)}`, { method: 'PUT', headers: { 'Content-Type': source.contentType || 'application/octet-stream' }, body: toUploadBody(source.body), signal: options?.signal, }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || `Proxy upload failed (status ${res.status})`); } } function documentTypeForName(name: string): DocumentType { const lower = name.toLowerCase(); if (lower.endsWith('.pdf')) return 'pdf'; if (lower.endsWith('.epub')) return 'epub'; if (lower.endsWith('.docx')) return 'docx'; return 'html'; } export function mimeTypeForDoc(doc: Pick): string { if (doc.type === 'pdf') return 'application/pdf'; if (doc.type === 'epub') return 'application/epub+zip'; if (doc.type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const lower = doc.name.toLowerCase(); if (lower.endsWith('.md') || lower.endsWith('.markdown') || lower.endsWith('.mdown') || lower.endsWith('.mkd')) { return 'text/markdown'; } return 'text/plain'; } export async function listDocuments(options?: { ids?: string[]; signal?: AbortSignal }): Promise { const params = new URLSearchParams(); if (options?.ids?.length) { params.set('ids', options.ids.join(',')); } const res = await fetch(`/api/documents?${params.toString()}`, { signal: options?.signal }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to list documents'); } const data = (await res.json()) as { documents: BaseDocument[] }; return data.documents || []; } export async function getDocumentMetadata(id: string, options?: { signal?: AbortSignal }): Promise { const docs = await listDocuments({ ids: [id], signal: options?.signal }); return docs[0] ?? null; } export async function getParsedPdfDocument( id: string, options?: { signal?: AbortSignal; retryFailed?: boolean }, ): Promise<{ status: 'ready'; parsed: ParsedPdfDocument } | { status: 'pending' | 'running' | 'failed' }> { const query = options?.retryFailed ? '?retry=1' : ''; const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed${query}`, { signal: options?.signal, cache: 'no-store', }); if (res.status === 202) { const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null; const parseStatus = data?.parseStatus; if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed') { return { status: parseStatus }; } return { status: 'pending' }; } if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to load parsed PDF'); } const parsed = (await res.json()) as ParsedPdfDocument; return { status: 'ready', parsed }; } export async function forceReparsePdfDocument( id: string, options?: { signal?: AbortSignal }, ): Promise<'pending' | 'running'> { const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed`, { method: 'POST', signal: options?.signal, cache: 'no-store', }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to force PDF reparse'); } const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null; return data?.parseStatus === 'running' ? 'running' : 'pending'; } type DocumentSettingsResponse = { settings: DocumentSettings; clientUpdatedAtMs: number; hasStoredSettings?: boolean; }; export async function getDocumentSettings( id: string, options?: { signal?: AbortSignal }, ): Promise { const res = await fetch(`/api/documents/${encodeURIComponent(id)}/settings`, { signal: options?.signal, cache: 'no-store', }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to load document settings'); } return (await res.json()) as DocumentSettingsResponse; } export async function putDocumentSettings( id: string, settings: DocumentSettings, options?: { signal?: AbortSignal; clientUpdatedAtMs?: number }, ): Promise { const res = await fetch(`/api/documents/${encodeURIComponent(id)}/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings, clientUpdatedAtMs: options?.clientUpdatedAtMs ?? Date.now(), }), signal: options?.signal, }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to update document settings'); } return (await res.json()) as DocumentSettingsResponse & { applied: boolean }; } export async function uploadDocumentSources(sources: UploadSource[], options?: UploadOptions): Promise { if (sources.length === 0) return []; const presignRes = await fetch('/api/documents/blob/upload/presign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploads: sources.map((source) => ({ id: source.id, contentType: source.contentType, size: source.size, })), }), signal: options?.signal, }); if (!presignRes.ok) { const data = (await presignRes.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to prepare uploads'); } const presigned = (await presignRes.json()) as { uploads?: Array<{ id: string; url: string; headers?: Record }>; }; const byId = new Map((presigned.uploads || []).map((upload) => [upload.id, upload])); for (const source of sources) { const upload = byId.get(source.id); if (!upload?.url) { throw new Error(`Missing presigned upload for document ${source.id}`); } let putError: unknown = null; try { const putRes = await fetch(upload.url, { method: 'PUT', headers: new Headers(upload.headers || {}), body: toUploadBody(source.body), signal: options?.signal, }); // 412 means the content-hash object already exists (idempotent upload). if (putRes.ok || putRes.status === 412) { continue; } putError = new Error(`Direct upload failed with status ${putRes.status}`); } catch (error) { if (options?.signal?.aborted) throw error; putError = error; } try { await uploadDocumentSourceViaProxy(source, options); } catch (proxyError) { const directMessage = putError instanceof Error ? putError.message : 'unknown direct upload error'; const proxyMessage = proxyError instanceof Error ? proxyError.message : 'unknown proxy upload error'; throw new Error(`Failed to upload document ${source.name}: ${directMessage}; fallback failed: ${proxyMessage}`); } } const registerRes = await fetch('/api/documents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ documents: sources.map((source) => ({ id: source.id, name: source.name, type: source.type, size: source.size, lastModified: source.lastModified, })), }), signal: options?.signal, }); if (!registerRes.ok) { const data = (await registerRes.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to register uploaded documents'); } const data = (await registerRes.json()) as { stored: BaseDocument[] }; return data.stored || []; } export async function uploadDocuments(files: File[], options?: UploadOptions): Promise { if (files.length === 0) return []; const sources: UploadSource[] = []; for (const file of files) { const bytes = await file.arrayBuffer(); const id = await sha256HexFromArrayBuffer(bytes); const type = documentTypeForName(file.name); const name = file.name || `${id}.${type}`; const contentType = file.type || mimeTypeForDoc({ name, type }); sources.push({ id, name, type, size: file.size, lastModified: Number.isFinite(file.lastModified) ? file.lastModified : Date.now(), contentType, body: file, }); } return uploadDocumentSources(sources, options); } export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' | 'unclaimed'; signal?: AbortSignal }): Promise { const params = new URLSearchParams(); if (options?.ids?.length) { params.set('ids', options.ids.join(',')); } if (options?.scope) { params.set('scope', options.scope); } const url = params.toString() ? `/api/documents?${params.toString()}` : '/api/documents'; const res = await fetch(url, { method: 'DELETE', signal: options?.signal }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to delete documents'); } } export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise { const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(id)}`; const fetchFallback = async (): Promise => { const res = await fetch(fallbackUrl, { signal: options?.signal }); if (!res.ok) { const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || `Failed to download document (status ${res.status})`); } throw new Error(`Failed to download document (status ${res.status})`); } return res.arrayBuffer(); }; try { const directRes = await fetch(`/api/documents/blob/get/presign?id=${encodeURIComponent(id)}`, { signal: options?.signal, cache: 'no-store', }); if (!directRes.ok) { const contentType = directRes.headers.get('content-type') || ''; if (contentType.includes('application/json')) { const data = (await directRes.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || `Failed to download document (status ${directRes.status})`); } throw new Error(`Failed to download document (status ${directRes.status})`); } return directRes.arrayBuffer(); } catch (error) { if (options?.signal?.aborted) throw error; return fetchFallback(); } } export async function getDocumentContentSnippet( id: string, options?: { maxChars?: number; maxBytes?: number; signal?: AbortSignal }, ): Promise { const params = new URLSearchParams(); params.set('id', id); params.set('snippet', '1'); if (typeof options?.maxChars === 'number') params.set('maxChars', String(options.maxChars)); if (typeof options?.maxBytes === 'number') params.set('maxBytes', String(options.maxBytes)); const res = await fetch(`/api/documents/blob/preview/fallback?${params.toString()}`, { signal: options?.signal }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || `Failed to load content snippet (status ${res.status})`); } const data = (await res.json()) as { snippet?: string }; return data?.snippet || ''; } export type DocumentPreviewPending = { kind: 'pending'; status: 'queued' | 'processing' | 'failed'; retryAfterMs: number; fallbackUrl: string; presignUrl: string; directUrl?: string; }; export type DocumentPreviewReady = { kind: 'ready'; fallbackUrl: string; presignUrl: string; directUrl?: string; }; export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady; function documentPreviewEnsureUrl(id: string): string { return `/api/documents/blob/preview/ensure?id=${encodeURIComponent(id)}`; } export function documentPreviewPresignUrl(id: string): string { return `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`; } export function documentPreviewFallbackUrl(id: string): string { return `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; } export async function getDocumentPreviewStatus( id: string, options?: { signal?: AbortSignal }, ): Promise { const res = await fetch(documentPreviewEnsureUrl(id), { signal: options?.signal, cache: 'no-store', }); if (res.status === 202) { const data = (await res.json().catch(() => null)) as { status?: 'queued' | 'processing' | 'failed'; retryAfterMs?: number; fallbackUrl?: string; presignUrl?: string; directUrl?: string; } | null; return { kind: 'pending', status: data?.status ?? 'queued', retryAfterMs: Number.isFinite(data?.retryAfterMs) ? Number(data?.retryAfterMs) : 1500, fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id), presignUrl: data?.presignUrl || documentPreviewPresignUrl(id), directUrl: data?.directUrl, }; } if (res.ok) { const data = (await res.json().catch(() => null)) as { fallbackUrl?: string; presignUrl?: string; directUrl?: string; } | null; return { kind: 'ready', fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id), presignUrl: data?.presignUrl || documentPreviewPresignUrl(id), directUrl: data?.directUrl, }; } // Handle failed preview generation (500 with status: 'failed') const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { const data = (await res.json().catch(() => null)) as { status?: string; lastError?: string; error?: string; } | null; if (data?.status === 'failed') { return { kind: 'pending', status: 'failed', retryAfterMs: 0, fallbackUrl: documentPreviewFallbackUrl(id), presignUrl: documentPreviewPresignUrl(id), }; } throw new Error(data?.error || data?.lastError || `Failed to load preview status (status ${res.status})`); } throw new Error(`Failed to load preview status (status ${res.status})`); } export async function uploadDocxAsPdf(file: File, options?: { signal?: AbortSignal }): Promise { const form = new FormData(); form.append('file', file); const res = await fetch('/api/documents/docx-to-pdf/upload', { method: 'POST', body: form, signal: options?.signal, }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || 'Failed to convert DOCX'); } const data = (await res.json()) as { stored: BaseDocument }; if (!data?.stored) throw new Error('DOCX conversion succeeded but returned no document'); return data.stored; }