openreader/src/lib/client-documents.ts
Richard R 4a5f3060f2 refactor(documents): migrate from IndexedDB to server-backed storage with caching
- Replace client-side IndexedDB with server-first document storage
- Add document caching layer for offline capability and performance
- Implement document transfer during anonymous-to-authenticated account linking
- Add database indexes for improved query performance
- Update document contexts to use new caching system
- Refactor document upload and deletion APIs
- Add audiobook pruning for missing files
- Improve test isolation with namespace support
- Add unit tests for document cache and transfer functions
2026-02-08 11:15:57 -07:00

126 lines
4.6 KiB
TypeScript

import type { BaseDocument } from '@/types/documents';
export function mimeTypeForDoc(doc: Pick<BaseDocument, 'type' | 'name'>): string {
if (doc.type === 'pdf') return 'application/pdf';
if (doc.type === 'epub') return 'application/epub+zip';
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<BaseDocument[]> {
const params = new URLSearchParams();
params.set('list', 'true');
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<BaseDocument | null> {
const docs = await listDocuments({ ids: [id], signal: options?.signal });
return docs[0] ?? null;
}
export async function uploadDocuments(files: File[], options?: { signal?: AbortSignal }): Promise<BaseDocument[]> {
const form = new FormData();
for (const file of files) {
form.append('files', file);
}
const res = await fetch('/api/documents/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 upload documents');
}
const data = (await res.json()) as { stored: BaseDocument[] };
return data.stored || [];
}
export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' | 'unclaimed'; signal?: AbortSignal }): Promise<void> {
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<ArrayBuffer> {
const res = await fetch(`/api/documents/content?id=${encodeURIComponent(id)}`, { 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();
}
export async function getDocumentContentSnippet(
id: string,
options?: { maxChars?: number; maxBytes?: number; signal?: AbortSignal },
): Promise<string> {
const params = new URLSearchParams();
params.set('id', id);
params.set('format', 'snippet');
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/content?${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 async function uploadDocxAsPdf(file: File, options?: { signal?: AbortSignal }): Promise<BaseDocument> {
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;
}