/* eslint-disable svelte/prefer-svelte-reactivity -- the Date instances inside lastEditedAt's $derived are scope-local to one computation; they're never stored on $state. */ import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types'; import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry'; import { BlockConflictResolvedError } from './blockConflictMerge'; type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number; }; export interface TranscriptionBlocksOptions { documentId: () => string; fetchImpl?: typeof fetch; } export interface TranscriptionBlocksController { readonly blocks: TranscriptionBlockData[]; readonly hasBlocks: boolean; readonly blockNumbers: Record; readonly lastEditedAt: string | null; readonly annotationReloadKey: number; load(): Promise; save(blockId: string, text: string, mentionedPersons: PersonMention[]): Promise; delete(blockId: string): Promise; reviewToggle(blockId: string): Promise; markAllReviewed(): Promise; createFromDraw(rect: DrawRect): Promise; toggleTrainingLabel(label: string, enrolled: boolean): Promise; deleteAnnotation(annotationId: string): Promise; findByAnnotationId(annotationId: string): TranscriptionBlockData | undefined; bumpAnnotationReloadKey(): void; } export function createTranscriptionBlocks( options: TranscriptionBlocksOptions ): TranscriptionBlocksController { const { documentId } = options; const fetchImpl = options.fetchImpl ?? fetch; let blocks = $state([]); let annotationReloadKey = $state(0); const blockNumbers = $derived( Object.fromEntries( [...blocks].sort((a, b) => a.sortOrder - b.sortOrder).map((b, i) => [b.annotationId, i + 1]) ) ); const hasBlocks = $derived(blocks.length > 0); const lastEditedAt = $derived.by(() => { if (blocks.length === 0) return null; const dates = blocks.filter((b) => b.updatedAt).map((b) => new Date(b.updatedAt!).getTime()); if (dates.length === 0) return null; return new Date(Math.max(...dates)).toISOString(); }); async function load(): Promise { const id = documentId(); if (!id) return; try { const res = await fetchImpl(`/api/documents/${id}/transcription-blocks`); if (res.ok) { blocks = (await res.json()) as TranscriptionBlockData[]; } } catch (e) { console.error('Failed to load transcription blocks:', e); } } async function save( blockId: string, text: string, mentionedPersons: PersonMention[] ): Promise { try { const updated = await saveBlockWithConflictRetry({ fetchImpl, documentId: documentId(), blockId, text, mentionedPersons }); blocks = blocks.map((b) => (b.id === blockId ? updated : b)); } catch (err) { if (err instanceof BlockConflictResolvedError && err.merged) { blocks = blocks.map((b) => (b.id === blockId ? err.merged! : b)); } throw err; } } async function deleteBlock(blockId: string): Promise { const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/${blockId}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Delete failed'); blocks = blocks.filter((b) => b.id !== blockId); annotationReloadKey++; } async function reviewToggle(blockId: string): Promise { const res = await fetchImpl( `/api/documents/${documentId()}/transcription-blocks/${blockId}/review`, { method: 'PUT' } ); if (!res.ok) return; const updated = (await res.json()) as TranscriptionBlockData; blocks = blocks.map((b) => (b.id === blockId ? updated : b)); } async function markAllReviewed(): Promise { const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, { method: 'PUT' }); if (!res.ok) return; const updated = (await res.json()) as { id: string; reviewed: boolean }[]; for (const b of updated) { const existing = blocks.find((x) => x.id === b.id); if (existing) existing.reviewed = b.reviewed; } } async function createFromDraw(rect: DrawRect): Promise { try { const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pageNumber: rect.pageNumber, x: rect.x, y: rect.y, width: rect.width, height: rect.height, text: '', label: null }) }); if (res.ok) { const created = (await res.json()) as TranscriptionBlockData; blocks = [...blocks, created]; return created; } return null; } catch (e) { console.error('Failed to create transcription block:', e); return null; } } async function toggleTrainingLabel(label: string, enrolled: boolean): Promise { const res = await fetchImpl(`/api/documents/${documentId()}/training-labels`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, enrolled }) }); if (!res.ok) throw new Error('Failed to update training label'); } async function deleteAnnotation(annotationId: string): Promise { const block = blocks.find((b) => b.annotationId === annotationId); if (block) { await deleteBlock(block.id); return; } const res = await fetchImpl(`/api/documents/${documentId()}/annotations/${annotationId}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Delete annotation failed'); annotationReloadKey++; } function findByAnnotationId(annotationId: string): TranscriptionBlockData | undefined { return blocks.find((b) => b.annotationId === annotationId); } function bumpAnnotationReloadKey(): void { annotationReloadKey++; } return { get blocks() { return blocks; }, get hasBlocks() { return hasBlocks; }, get blockNumbers() { return blockNumbers; }, get lastEditedAt() { return lastEditedAt; }, get annotationReloadKey() { return annotationReloadKey; }, load, save, delete: deleteBlock, reviewToggle, markAllReviewed, createFromDraw, toggleTrainingLabel, deleteAnnotation, findByAnnotationId, bumpAnnotationReloadKey }; }