diff --git a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.test.ts b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.test.ts new file mode 100644 index 00000000..a7670791 --- /dev/null +++ b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.test.ts @@ -0,0 +1,461 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createTranscriptionBlocks } from './useTranscriptionBlocks.svelte'; +import type { TranscriptionBlockData } from '$lib/shared/types'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const baseBlock = (overrides: Partial = {}): TranscriptionBlockData => + ({ + id: 'b-1', + annotationId: 'ann-1', + text: 'Hello', + sortOrder: 1, + reviewed: false, + mentionedPersons: [], + updatedAt: '2026-01-01T00:00:00Z', + ...overrides + }) as TranscriptionBlockData; + +function makeFetch(handlers: Record Response | Promise>) { + return vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + for (const [match, fn] of Object.entries(handlers)) { + if (u.includes(match) && (match.includes(':') || true)) { + return fn(); + } + } + const key = `${method} ${u}`; + for (const [match, fn] of Object.entries(handlers)) { + if (key.includes(match)) return fn(); + } + return new Response('not found', { status: 404 }); + }); +} + +describe('createTranscriptionBlocks — initial state', () => { + it('starts with no blocks, no derived metadata', () => { + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1' }); + expect(ctrl.blocks).toEqual([]); + expect(ctrl.hasBlocks).toBe(false); + expect(ctrl.blockNumbers).toEqual({}); + expect(ctrl.lastEditedAt).toBeNull(); + expect(ctrl.annotationReloadKey).toBe(0); + }); +}); + +describe('createTranscriptionBlocks.load', () => { + it('fetches and stores blocks on success', async () => { + const fetchImpl = makeFetch({ + '/api/documents/doc-1/transcription-blocks': () => + new Response( + JSON.stringify([baseBlock({ id: 'b1' }), baseBlock({ id: 'b2', sortOrder: 2 })]), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + + expect(ctrl.blocks).toHaveLength(2); + expect(ctrl.hasBlocks).toBe(true); + }); + + it('is a no-op when documentId is empty', async () => { + const fetchImpl = vi.fn(); + const ctrl = createTranscriptionBlocks({ documentId: () => '', fetchImpl }); + await ctrl.load(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('keeps blocks empty on non-OK response', async () => { + const fetchImpl = makeFetch({ + 'transcription-blocks': () => new Response('boom', { status: 500 }) + }); + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + expect(ctrl.blocks).toEqual([]); + }); + + it('swallows network errors during load', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network'); + }); + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await expect(ctrl.load()).resolves.toBeUndefined(); + expect(ctrl.blocks).toEqual([]); + }); +}); + +describe('createTranscriptionBlocks — derived state', () => { + it('computes blockNumbers in sortOrder', async () => { + const fetchImpl = makeFetch({ + 'transcription-blocks': () => + new Response( + JSON.stringify([ + baseBlock({ id: 'b3', annotationId: 'a3', sortOrder: 3 }), + baseBlock({ id: 'b1', annotationId: 'a1', sortOrder: 1 }), + baseBlock({ id: 'b2', annotationId: 'a2', sortOrder: 2 }) + ]), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + + expect(ctrl.blockNumbers).toEqual({ a1: 1, a2: 2, a3: 3 }); + }); + + it('lastEditedAt picks the most recent updatedAt', async () => { + const fetchImpl = makeFetch({ + 'transcription-blocks': () => + new Response( + JSON.stringify([ + baseBlock({ id: 'b1', updatedAt: '2026-04-15T10:00:00Z' }), + baseBlock({ id: 'b2', updatedAt: '2026-04-20T10:00:00Z' }), + baseBlock({ id: 'b3', updatedAt: '2026-04-10T10:00:00Z' }) + ]), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + + expect(ctrl.lastEditedAt).toBe(new Date('2026-04-20T10:00:00Z').toISOString()); + }); + + it('lastEditedAt is null when no block has updatedAt', async () => { + const fetchImpl = makeFetch({ + 'transcription-blocks': () => + new Response(JSON.stringify([baseBlock({ id: 'b1', updatedAt: undefined })]), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + + expect(ctrl.lastEditedAt).toBeNull(); + }); +}); + +describe('createTranscriptionBlocks.delete', () => { + it('removes the block locally and bumps annotationReloadKey on success', async () => { + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + if (u.includes('/transcription-blocks/b-1') && method === 'DELETE') { + return new Response(null, { status: 204 }); + } + if (u.endsWith('/transcription-blocks')) { + return new Response(JSON.stringify([baseBlock({ id: 'b-1' }), baseBlock({ id: 'b-2' })]), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response('', { status: 404 }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + expect(ctrl.blocks).toHaveLength(2); + const keyBefore = ctrl.annotationReloadKey; + + await ctrl.delete('b-1'); + + expect(ctrl.blocks).toHaveLength(1); + expect(ctrl.blocks[0].id).toBe('b-2'); + expect(ctrl.annotationReloadKey).toBe(keyBefore + 1); + }); + + it('throws on non-OK delete response', async () => { + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + if (method === 'DELETE') return new Response('boom', { status: 500 }); + return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await expect(ctrl.delete('b-1')).rejects.toThrow(); + }); +}); + +describe('createTranscriptionBlocks.reviewToggle', () => { + it('updates the block after a successful PUT', async () => { + let updated = false; + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + if (u.includes('/review') && method === 'PUT') { + updated = true; + return new Response(JSON.stringify(baseBlock({ id: 'b-1', reviewed: true })), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + await ctrl.reviewToggle('b-1'); + + expect(updated).toBe(true); + expect(ctrl.blocks[0].reviewed).toBe(true); + }); + + it('is a no-op when PUT returns non-OK', async () => { + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + if (method === 'PUT') return new Response('', { status: 500 }); + return new Response(JSON.stringify([baseBlock({ reviewed: false })]), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + await ctrl.reviewToggle('b-1'); + expect(ctrl.blocks[0].reviewed).toBe(false); + }); +}); + +describe('createTranscriptionBlocks.markAllReviewed', () => { + it('updates each matching block', async () => { + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + if (u.includes('/review-all') && method === 'PUT') { + return new Response( + JSON.stringify([ + { id: 'b-1', reviewed: true }, + { id: 'b-2', reviewed: true } + ]), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response( + JSON.stringify([ + baseBlock({ id: 'b-1', reviewed: false }), + baseBlock({ id: 'b-2', reviewed: false }) + ]), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + await ctrl.markAllReviewed(); + + expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true); + }); + + it('is a no-op when PUT returns non-OK', async () => { + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + if (u.includes('/review-all') && method === 'PUT') { + return new Response('', { status: 500 }); + } + return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + await ctrl.markAllReviewed(); + expect(ctrl.blocks[0].reviewed).toBe(false); + }); +}); + +describe('createTranscriptionBlocks.createFromDraw', () => { + it('appends a created block on 200', async () => { + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + if (u.endsWith('/transcription-blocks') && method === 'POST') { + return new Response(JSON.stringify(baseBlock({ id: 'b-new', annotationId: 'ann-new' })), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + const created = await ctrl.createFromDraw({ + x: 0.1, + y: 0.1, + width: 0.1, + height: 0.1, + pageNumber: 1 + }); + + expect(created?.id).toBe('b-new'); + expect(ctrl.blocks.find((b) => b.id === 'b-new')).toBeDefined(); + }); + + it('returns null and does not append on non-OK response', async () => { + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + if (method === 'POST') return new Response('boom', { status: 500 }); + return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + const created = await ctrl.createFromDraw({ + x: 0, + y: 0, + width: 0.1, + height: 0.1, + pageNumber: 1 + }); + + expect(created).toBeNull(); + expect(ctrl.blocks).toHaveLength(0); + }); + + it('returns null on network error', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network'); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + const created = await ctrl.createFromDraw({ + x: 0, + y: 0, + width: 0.1, + height: 0.1, + pageNumber: 1 + }); + + expect(created).toBeNull(); + }); +}); + +describe('createTranscriptionBlocks.toggleTrainingLabel', () => { + it('PATCHes the training-labels endpoint', async () => { + const fetchImpl = vi.fn(async () => new Response('', { status: 200 })); + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.toggleTrainingLabel('KURRENT_RECOGNITION', true); + expect(fetchImpl).toHaveBeenCalledWith( + '/api/documents/doc-1/training-labels', + expect.objectContaining({ method: 'PATCH' }) + ); + }); + + it('throws on non-OK response', async () => { + const fetchImpl = vi.fn(async () => new Response('boom', { status: 500 })); + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await expect(ctrl.toggleTrainingLabel('X', true)).rejects.toThrow(); + }); +}); + +describe('createTranscriptionBlocks.deleteAnnotation', () => { + it('deletes the linked block when one exists', async () => { + let blockDeleted = false; + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + if (u.includes('/transcription-blocks/b-1') && method === 'DELETE') { + blockDeleted = true; + return new Response(null, { status: 204 }); + } + if (u.endsWith('/transcription-blocks')) { + return new Response(JSON.stringify([baseBlock({ id: 'b-1', annotationId: 'ann-1' })]), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response('', { status: 200 }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + await ctrl.deleteAnnotation('ann-1'); + + expect(blockDeleted).toBe(true); + expect(ctrl.blocks).toHaveLength(0); + }); + + it('deletes the bare annotation when no block is linked', async () => { + let annotationDeleted = false; + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + if (u.includes('/annotations/ann-orphan') && method === 'DELETE') { + annotationDeleted = true; + return new Response(null, { status: 204 }); + } + return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + const keyBefore = ctrl.annotationReloadKey; + await ctrl.deleteAnnotation('ann-orphan'); + + expect(annotationDeleted).toBe(true); + expect(ctrl.annotationReloadKey).toBe(keyBefore + 1); + }); + + it('throws when the bare-annotation DELETE fails', async () => { + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = url.toString(); + const method = init?.method ?? 'GET'; + if (u.includes('/annotations/') && method === 'DELETE') { + return new Response('boom', { status: 500 }); + } + return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + await expect(ctrl.deleteAnnotation('ann-orphan')).rejects.toThrow(); + }); +}); + +describe('createTranscriptionBlocks.findByAnnotationId', () => { + it('returns the block whose annotationId matches', async () => { + const fetchImpl = makeFetch({ + 'transcription-blocks': () => + new Response( + JSON.stringify([ + baseBlock({ id: 'b1', annotationId: 'ann-a' }), + baseBlock({ id: 'b2', annotationId: 'ann-b' }) + ]), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + }); + + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); + await ctrl.load(); + + expect(ctrl.findByAnnotationId('ann-b')?.id).toBe('b2'); + expect(ctrl.findByAnnotationId('ann-missing')).toBeUndefined(); + }); +}); + +describe('createTranscriptionBlocks.bumpAnnotationReloadKey', () => { + it('increments annotationReloadKey by 1', () => { + const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1' }); + expect(ctrl.annotationReloadKey).toBe(0); + ctrl.bumpAnnotationReloadKey(); + expect(ctrl.annotationReloadKey).toBe(1); + ctrl.bumpAnnotationReloadKey(); + expect(ctrl.annotationReloadKey).toBe(2); + }); +}); diff --git a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts new file mode 100644 index 00000000..3a601f1a --- /dev/null +++ b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts @@ -0,0 +1,214 @@ +/* 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 + }; +} diff --git a/frontend/src/lib/ocr/useOcrJob.svelte.test.ts b/frontend/src/lib/ocr/useOcrJob.svelte.test.ts index 3ca65d68..3df1e4a6 100644 --- a/frontend/src/lib/ocr/useOcrJob.svelte.test.ts +++ b/frontend/src/lib/ocr/useOcrJob.svelte.test.ts @@ -261,7 +261,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => { pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); - await wait(60); + await wait(150); expect(job.progressMessage).not.toBe(''); job.destroy(); @@ -287,7 +287,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => { pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); - await wait(60); + await wait(150); expect(job.skippedPages).toBeGreaterThanOrEqual(0); job.destroy(); @@ -317,7 +317,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => { resetDelayMs: 10 }); await job.triggerOcr('KURRENT', false); - await wait(80); + await wait(200); expect(onJobFinished).toHaveBeenCalledWith('DONE'); job.destroy(); @@ -347,7 +347,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => { resetDelayMs: 10 }); await job.triggerOcr('KURRENT', false); - await wait(80); + await wait(200); expect(onJobFinished).toHaveBeenCalledWith('FAILED'); expect(job.errorMessage).toBeTruthy(); @@ -372,7 +372,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => { pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); - await wait(60); + await wait(150); expect(job.running).toBe(true); job.destroy(); @@ -399,7 +399,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => { pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); - await wait(60); + await wait(150); expect(job.running).toBe(true); job.destroy(); @@ -437,7 +437,7 @@ describe('createOcrJob.destroy', () => { job.destroy(); const callsAtDestroy = fetchImpl.mock.calls.length; - await wait(80); + await wait(200); // No additional fetch calls after destroy expect(fetchImpl.mock.calls.length).toBe(callsAtDestroy); }); diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 119735ac..7d1e5ad7 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -8,8 +8,8 @@ import DocumentViewer from '$lib/document/DocumentViewer.svelte'; import TranscriptionEditView from '$lib/document/transcription/TranscriptionEditView.svelte'; import TranscriptionReadView from '$lib/document/transcription/TranscriptionReadView.svelte'; import TranscriptionPanelHeader from '$lib/document/transcription/TranscriptionPanelHeader.svelte'; -import type { TranscriptionBlockData } from '$lib/shared/types'; import { createOcrJob } from '$lib/ocr/useOcrJob.svelte'; +import { createTranscriptionBlocks } from '$lib/document/transcription/useTranscriptionBlocks.svelte'; import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte'; import { scrollToCommentFromQuery } from '$lib/shared/utils/deepLinkScroll'; import { getConfirmService } from '$lib/shared/services/confirm.svelte.js'; @@ -53,131 +53,25 @@ const prefersReducedMotion = $derived( // ── Transcription blocks ───────────────────────────────────────────────────── -let transcriptionBlocks = $state([]); -let annotationReloadKey = $state(0); - -const blockNumbers = $derived( - Object.fromEntries( - [...transcriptionBlocks] - .sort((a, b) => a.sortOrder - b.sortOrder) - .map((b, i) => [b.annotationId, i + 1]) - ) -); - -const hasBlocks = $derived(transcriptionBlocks.length > 0); - -const lastEditedAt = $derived.by(() => { - if (transcriptionBlocks.length === 0) return null; - const dates = transcriptionBlocks - .filter((b) => b.updatedAt) - .map((b) => new Date(b.updatedAt!).getTime()); - if (dates.length === 0) return null; - return new Date(Math.max(...dates)).toISOString(); +const transcription = createTranscriptionBlocks({ + documentId: () => doc?.id ?? '' }); -async function loadTranscriptionBlocks() { - if (!doc?.id) return; - try { - const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`); - if (res.ok) { - transcriptionBlocks = await res.json(); - } - } catch (e) { - console.error('Failed to load transcription blocks:', e); - } -} - -async function saveBlock( - blockId: string, - text: string, - mentionedPersons: import('$lib/shared/types').PersonMention[] -) { - const { saveBlockWithConflictRetry } = - await import('$lib/document/transcription/saveBlockWithConflictRetry'); - const { BlockConflictResolvedError } = - await import('$lib/document/transcription/blockConflictMerge'); - try { - const updated = await saveBlockWithConflictRetry({ - fetchImpl: fetch, - documentId: doc.id, - blockId, - text, - mentionedPersons - }); - transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); - } catch (err) { - if (err instanceof BlockConflictResolvedError && err.merged) { - transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? err.merged! : b)); - } - throw err; - } -} - -async function deleteBlock(blockId: string) { - const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, { - method: 'DELETE' - }); - if (!res.ok) throw new Error('Delete failed'); - transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId); - annotationReloadKey++; -} - async function handleAnnotationDeleteRequest(annotationId: string) { const confirmed = await confirm({ title: m.transcription_block_delete_confirm(), destructive: true }); if (!confirmed) return; - - const block = transcriptionBlocks.find((b) => b.annotationId === annotationId); - if (block) { - await deleteBlock(block.id); - } else { - // Annotation has no linked block — delete the annotation directly - const res = await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, { - method: 'DELETE' - }); - if (!res.ok) throw new Error('Delete annotation failed'); - annotationReloadKey++; - } -} - -async function reviewToggle(blockId: string) { - const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, { - method: 'PUT' - }); - if (!res.ok) return; - const updated = await res.json(); - transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); -} - -async function markAllReviewed() { - const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, { - method: 'PUT' - }); - if (!res.ok) return; - const updated = await res.json(); - for (const b of updated) { - const existing = transcriptionBlocks.find((x) => x.id === b.id); - if (existing) existing.reviewed = b.reviewed; - } -} - -async function toggleTrainingLabel(label: string, enrolled: boolean) { - const res = await fetch(`/api/documents/${doc.id}/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'); + await transcription.deleteAnnotation(annotationId); } const ocrJob = createOcrJob({ documentId: () => doc?.id ?? '', onJobFinished: async () => { - await loadTranscriptionBlocks(); - annotationReloadKey++; - panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit'; + await transcription.load(); + transcription.bumpAnnotationReloadKey(); + panelMode = transcription.hasBlocks ? 'read' : 'edit'; } }); @@ -192,32 +86,14 @@ async function createBlockFromDraw(rect: { height: number; pageNumber: number; }) { - try { - const res = await fetch(`/api/documents/${doc.id}/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; - transcriptionBlocks = [...transcriptionBlocks, created]; - activeAnnotationId = created.annotationId; - } - } catch (e) { - console.error('Failed to create transcription block:', e); + const created = await transcription.createFromDraw(rect); + if (created) { + activeAnnotationId = created.annotationId; } } function handleBlockFocus(blockId: string) { - const block = transcriptionBlocks.find((b) => b.id === blockId); + const block = transcription.blocks.find((b) => b.id === blockId); if (block) { activeAnnotationId = block.annotationId; } @@ -228,11 +104,11 @@ async function handleAnnotationClick(annotationId: string) { if (!transcribeMode) { transcribeMode = true; - await loadTranscriptionBlocks(); + await transcription.load(); } // In read mode, highlight the matching paragraph - const block = transcriptionBlocks.find((b) => b.annotationId === annotationId); + const block = transcription.findByAnnotationId(annotationId); if (block) { highlightBlockId = block.id; setTimeout( @@ -268,11 +144,11 @@ function handleParagraphClick(annotationId: string) { // Load blocks and check OCR status when transcribe mode is entered $effect(() => { if (transcribeMode) { - loadTranscriptionBlocks().then(() => { + transcription.load().then(() => { if (skipInitialPanelMode) { skipInitialPanelMode = false; } else { - panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit'; + panelMode = transcription.hasBlocks ? 'read' : 'edit'; } }); ocrJob.checkStatus(); @@ -320,7 +196,7 @@ onMount(() => { skipInitialPanelMode = true; panelMode = m; }, - loadBlocks: loadTranscriptionBlocks, + loadBlocks: () => transcription.load(), setActiveAnnotationId: (id) => (activeAnnotationId = id), flashAnnotation: (annotationId) => { flashAnnotationId = annotationId; @@ -376,8 +252,8 @@ onMount(() => { isLoading={fileLoader.isLoading} error={fileLoader.fileError} transcribeMode={transcribeMode && !ocrJob.running} - blockNumbers={blockNumbers} - annotationReloadKey={annotationReloadKey} + blockNumbers={transcription.blockNumbers} + annotationReloadKey={transcription.annotationReloadKey} annotationsDimmed={transcribeMode && panelMode === 'read'} flashAnnotationId={flashAnnotationId} bind:activeAnnotationId={activeAnnotationId} @@ -414,9 +290,9 @@ onMount(() => { > (panelMode = newMode)} onClose={() => (transcribeMode = false)} /> @@ -461,14 +337,14 @@ onMount(() => { {:else if panelMode === 'read'} {:else} { canWrite={canWrite} trainingLabels={doc.trainingLabels ?? []} onBlockFocus={handleBlockFocus} - onSaveBlock={saveBlock} - onDeleteBlock={deleteBlock} - onReviewToggle={reviewToggle} - onMarkAllReviewed={markAllReviewed} + onSaveBlock={transcription.save} + onDeleteBlock={transcription.delete} + onReviewToggle={transcription.reviewToggle} + onMarkAllReviewed={transcription.markAllReviewed} onTriggerOcr={triggerOcr} - onToggleTrainingLabel={toggleTrainingLabel} + onToggleTrainingLabel={transcription.toggleTrainingLabel} /> {/if}