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>
215 lines
6.1 KiB
TypeScript
215 lines
6.1 KiB
TypeScript
/* 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
|
|
};
|
|
}
|