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

@@ -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<TranscriptionBlockData[]>([]);
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(() => {
>
<TranscriptionPanelHeader
mode={panelMode}
hasBlocks={hasBlocks}
blockCount={transcriptionBlocks.length}
lastEditedAt={lastEditedAt}
hasBlocks={transcription.hasBlocks}
blockCount={transcription.blocks.length}
lastEditedAt={transcription.lastEditedAt}
onModeChange={(newMode) => (panelMode = newMode)}
onClose={() => (transcribeMode = false)}
/>
@@ -461,14 +337,14 @@ onMount(() => {
</div>
{:else if panelMode === 'read'}
<TranscriptionReadView
blocks={transcriptionBlocks}
blocks={transcription.blocks}
highlightBlockId={highlightBlockId}
onParagraphClick={handleParagraphClick}
/>
{:else}
<TranscriptionEditView
documentId={doc.id}
blocks={transcriptionBlocks}
blocks={transcription.blocks}
canComment={canWrite}
currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId}
@@ -477,12 +353,12 @@ onMount(() => {
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}
</div>