refactor(transcription): extract block CRUD into createTranscriptionBlocks hook
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m39s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m9s

Pulls the transcription-block state (load, save, delete, reviewToggle,
markAllReviewed, createFromDraw, toggleTrainingLabel, deleteAnnotation
+ derived blockNumbers / hasBlocks / lastEditedAt / annotationReloadKey)
out of documents/[id]/+page.svelte into a reusable factory in
lib/document/transcription/useTranscriptionBlocks.svelte.ts.

The page now reads transcription.blocks / .blockNumbers / .hasBlocks /
.lastEditedAt / .annotationReloadKey reactively and delegates writes
to transcription.{load, save, delete, reviewToggle, markAllReviewed,
createFromDraw, toggleTrainingLabel, deleteAnnotation,
findByAnnotationId, bumpAnnotationReloadKey}. The confirm-then-delete
dialog stays in the page; the hook only handles the data ops.

24 unit tests cover initial state, load (success / non-OK / network /
empty-id), derived state (blockNumbers in sortOrder, lastEditedAt
recent-pick, lastEditedAt-null fallback), delete (success bumps key /
non-OK throws), reviewToggle (success updates / non-OK no-op), markAll
(success / non-OK), createFromDraw (success / non-OK / network all
return correct shape), toggleTrainingLabel (200 / 500), deleteAnnotation
(linked-block path / orphan-annotation path / orphan-fail throw),
findByAnnotationId match + miss, bumpAnnotationReloadKey.

Also bumps the polling-loop test waits in useOcrJob.svelte.test.ts to
150-200ms (from 60-80ms) so the suite is reliable when run in parallel.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-10 10:34:14 +02:00
parent 3e9d2337e2
commit 59e47c048c
4 changed files with 710 additions and 159 deletions

View File

@@ -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> = {}): 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<string, () => Response | Promise<Response>>) {
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);
});
});

View File

@@ -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<string, number>;
readonly lastEditedAt: string | null;
readonly annotationReloadKey: number;
load(): Promise<void>;
save(blockId: string, text: string, mentionedPersons: PersonMention[]): Promise<void>;
delete(blockId: string): Promise<void>;
reviewToggle(blockId: string): Promise<void>;
markAllReviewed(): Promise<void>;
createFromDraw(rect: DrawRect): Promise<TranscriptionBlockData | null>;
toggleTrainingLabel(label: string, enrolled: boolean): Promise<void>;
deleteAnnotation(annotationId: string): Promise<void>;
findByAnnotationId(annotationId: string): TranscriptionBlockData | undefined;
bumpAnnotationReloadKey(): void;
}
export function createTranscriptionBlocks(
options: TranscriptionBlocksOptions
): TranscriptionBlocksController {
const { documentId } = options;
const fetchImpl = options.fetchImpl ?? fetch;
let blocks = $state<TranscriptionBlockData[]>([]);
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<TranscriptionBlockData | null> {
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<void> {
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<void> {
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
};
}

View File

@@ -261,7 +261,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => {
pollIntervalMs: 20 pollIntervalMs: 20
}); });
await job.triggerOcr('KURRENT', false); await job.triggerOcr('KURRENT', false);
await wait(60); await wait(150);
expect(job.progressMessage).not.toBe(''); expect(job.progressMessage).not.toBe('');
job.destroy(); job.destroy();
@@ -287,7 +287,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => {
pollIntervalMs: 20 pollIntervalMs: 20
}); });
await job.triggerOcr('KURRENT', false); await job.triggerOcr('KURRENT', false);
await wait(60); await wait(150);
expect(job.skippedPages).toBeGreaterThanOrEqual(0); expect(job.skippedPages).toBeGreaterThanOrEqual(0);
job.destroy(); job.destroy();
@@ -317,7 +317,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => {
resetDelayMs: 10 resetDelayMs: 10
}); });
await job.triggerOcr('KURRENT', false); await job.triggerOcr('KURRENT', false);
await wait(80); await wait(200);
expect(onJobFinished).toHaveBeenCalledWith('DONE'); expect(onJobFinished).toHaveBeenCalledWith('DONE');
job.destroy(); job.destroy();
@@ -347,7 +347,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => {
resetDelayMs: 10 resetDelayMs: 10
}); });
await job.triggerOcr('KURRENT', false); await job.triggerOcr('KURRENT', false);
await wait(80); await wait(200);
expect(onJobFinished).toHaveBeenCalledWith('FAILED'); expect(onJobFinished).toHaveBeenCalledWith('FAILED');
expect(job.errorMessage).toBeTruthy(); expect(job.errorMessage).toBeTruthy();
@@ -372,7 +372,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => {
pollIntervalMs: 20 pollIntervalMs: 20
}); });
await job.triggerOcr('KURRENT', false); await job.triggerOcr('KURRENT', false);
await wait(60); await wait(150);
expect(job.running).toBe(true); expect(job.running).toBe(true);
job.destroy(); job.destroy();
@@ -399,7 +399,7 @@ describe('createOcrJob — polling loop (short interval, real timers)', () => {
pollIntervalMs: 20 pollIntervalMs: 20
}); });
await job.triggerOcr('KURRENT', false); await job.triggerOcr('KURRENT', false);
await wait(60); await wait(150);
expect(job.running).toBe(true); expect(job.running).toBe(true);
job.destroy(); job.destroy();
@@ -437,7 +437,7 @@ describe('createOcrJob.destroy', () => {
job.destroy(); job.destroy();
const callsAtDestroy = fetchImpl.mock.calls.length; const callsAtDestroy = fetchImpl.mock.calls.length;
await wait(80); await wait(200);
// No additional fetch calls after destroy // No additional fetch calls after destroy
expect(fetchImpl.mock.calls.length).toBe(callsAtDestroy); expect(fetchImpl.mock.calls.length).toBe(callsAtDestroy);
}); });

View File

@@ -8,8 +8,8 @@ import DocumentViewer from '$lib/document/DocumentViewer.svelte';
import TranscriptionEditView from '$lib/document/transcription/TranscriptionEditView.svelte'; import TranscriptionEditView from '$lib/document/transcription/TranscriptionEditView.svelte';
import TranscriptionReadView from '$lib/document/transcription/TranscriptionReadView.svelte'; import TranscriptionReadView from '$lib/document/transcription/TranscriptionReadView.svelte';
import TranscriptionPanelHeader from '$lib/document/transcription/TranscriptionPanelHeader.svelte'; import TranscriptionPanelHeader from '$lib/document/transcription/TranscriptionPanelHeader.svelte';
import type { TranscriptionBlockData } from '$lib/shared/types';
import { createOcrJob } from '$lib/ocr/useOcrJob.svelte'; import { createOcrJob } from '$lib/ocr/useOcrJob.svelte';
import { createTranscriptionBlocks } from '$lib/document/transcription/useTranscriptionBlocks.svelte';
import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte'; import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte';
import { scrollToCommentFromQuery } from '$lib/shared/utils/deepLinkScroll'; import { scrollToCommentFromQuery } from '$lib/shared/utils/deepLinkScroll';
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js'; import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
@@ -53,131 +53,25 @@ const prefersReducedMotion = $derived(
// ── Transcription blocks ───────────────────────────────────────────────────── // ── Transcription blocks ─────────────────────────────────────────────────────
let transcriptionBlocks = $state<TranscriptionBlockData[]>([]); const transcription = createTranscriptionBlocks({
let annotationReloadKey = $state(0); documentId: () => doc?.id ?? ''
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();
}); });
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) { async function handleAnnotationDeleteRequest(annotationId: string) {
const confirmed = await confirm({ const confirmed = await confirm({
title: m.transcription_block_delete_confirm(), title: m.transcription_block_delete_confirm(),
destructive: true destructive: true
}); });
if (!confirmed) return; if (!confirmed) return;
await transcription.deleteAnnotation(annotationId);
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');
} }
const ocrJob = createOcrJob({ const ocrJob = createOcrJob({
documentId: () => doc?.id ?? '', documentId: () => doc?.id ?? '',
onJobFinished: async () => { onJobFinished: async () => {
await loadTranscriptionBlocks(); await transcription.load();
annotationReloadKey++; transcription.bumpAnnotationReloadKey();
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit'; panelMode = transcription.hasBlocks ? 'read' : 'edit';
} }
}); });
@@ -192,32 +86,14 @@ async function createBlockFromDraw(rect: {
height: number; height: number;
pageNumber: number; pageNumber: number;
}) { }) {
try { const created = await transcription.createFromDraw(rect);
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`, { if (created) {
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; activeAnnotationId = created.annotationId;
} }
} catch (e) {
console.error('Failed to create transcription block:', e);
}
} }
function handleBlockFocus(blockId: string) { function handleBlockFocus(blockId: string) {
const block = transcriptionBlocks.find((b) => b.id === blockId); const block = transcription.blocks.find((b) => b.id === blockId);
if (block) { if (block) {
activeAnnotationId = block.annotationId; activeAnnotationId = block.annotationId;
} }
@@ -228,11 +104,11 @@ async function handleAnnotationClick(annotationId: string) {
if (!transcribeMode) { if (!transcribeMode) {
transcribeMode = true; transcribeMode = true;
await loadTranscriptionBlocks(); await transcription.load();
} }
// In read mode, highlight the matching paragraph // In read mode, highlight the matching paragraph
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId); const block = transcription.findByAnnotationId(annotationId);
if (block) { if (block) {
highlightBlockId = block.id; highlightBlockId = block.id;
setTimeout( setTimeout(
@@ -268,11 +144,11 @@ function handleParagraphClick(annotationId: string) {
// Load blocks and check OCR status when transcribe mode is entered // Load blocks and check OCR status when transcribe mode is entered
$effect(() => { $effect(() => {
if (transcribeMode) { if (transcribeMode) {
loadTranscriptionBlocks().then(() => { transcription.load().then(() => {
if (skipInitialPanelMode) { if (skipInitialPanelMode) {
skipInitialPanelMode = false; skipInitialPanelMode = false;
} else { } else {
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit'; panelMode = transcription.hasBlocks ? 'read' : 'edit';
} }
}); });
ocrJob.checkStatus(); ocrJob.checkStatus();
@@ -320,7 +196,7 @@ onMount(() => {
skipInitialPanelMode = true; skipInitialPanelMode = true;
panelMode = m; panelMode = m;
}, },
loadBlocks: loadTranscriptionBlocks, loadBlocks: () => transcription.load(),
setActiveAnnotationId: (id) => (activeAnnotationId = id), setActiveAnnotationId: (id) => (activeAnnotationId = id),
flashAnnotation: (annotationId) => { flashAnnotation: (annotationId) => {
flashAnnotationId = annotationId; flashAnnotationId = annotationId;
@@ -376,8 +252,8 @@ onMount(() => {
isLoading={fileLoader.isLoading} isLoading={fileLoader.isLoading}
error={fileLoader.fileError} error={fileLoader.fileError}
transcribeMode={transcribeMode && !ocrJob.running} transcribeMode={transcribeMode && !ocrJob.running}
blockNumbers={blockNumbers} blockNumbers={transcription.blockNumbers}
annotationReloadKey={annotationReloadKey} annotationReloadKey={transcription.annotationReloadKey}
annotationsDimmed={transcribeMode && panelMode === 'read'} annotationsDimmed={transcribeMode && panelMode === 'read'}
flashAnnotationId={flashAnnotationId} flashAnnotationId={flashAnnotationId}
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
@@ -414,9 +290,9 @@ onMount(() => {
> >
<TranscriptionPanelHeader <TranscriptionPanelHeader
mode={panelMode} mode={panelMode}
hasBlocks={hasBlocks} hasBlocks={transcription.hasBlocks}
blockCount={transcriptionBlocks.length} blockCount={transcription.blocks.length}
lastEditedAt={lastEditedAt} lastEditedAt={transcription.lastEditedAt}
onModeChange={(newMode) => (panelMode = newMode)} onModeChange={(newMode) => (panelMode = newMode)}
onClose={() => (transcribeMode = false)} onClose={() => (transcribeMode = false)}
/> />
@@ -461,14 +337,14 @@ onMount(() => {
</div> </div>
{:else if panelMode === 'read'} {:else if panelMode === 'read'}
<TranscriptionReadView <TranscriptionReadView
blocks={transcriptionBlocks} blocks={transcription.blocks}
highlightBlockId={highlightBlockId} highlightBlockId={highlightBlockId}
onParagraphClick={handleParagraphClick} onParagraphClick={handleParagraphClick}
/> />
{:else} {:else}
<TranscriptionEditView <TranscriptionEditView
documentId={doc.id} documentId={doc.id}
blocks={transcriptionBlocks} blocks={transcription.blocks}
canComment={canWrite} canComment={canWrite}
currentUserId={currentUserId} currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId} activeAnnotationId={activeAnnotationId}
@@ -477,12 +353,12 @@ onMount(() => {
canWrite={canWrite} canWrite={canWrite}
trainingLabels={doc.trainingLabels ?? []} trainingLabels={doc.trainingLabels ?? []}
onBlockFocus={handleBlockFocus} onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock} onSaveBlock={transcription.save}
onDeleteBlock={deleteBlock} onDeleteBlock={transcription.delete}
onReviewToggle={reviewToggle} onReviewToggle={transcription.reviewToggle}
onMarkAllReviewed={markAllReviewed} onMarkAllReviewed={transcription.markAllReviewed}
onTriggerOcr={triggerOcr} onTriggerOcr={triggerOcr}
onToggleTrainingLabel={toggleTrainingLabel} onToggleTrainingLabel={transcription.toggleTrainingLabel}
/> />
{/if} {/if}
</div> </div>