Files
familienarchiv/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts
Marcel e3e8373526 fix(transcription): throw error from markAllReviewed() on non-2xx response
Previously the function silently returned on failure, leaving no way
for callers to detect or surface the error to the user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:37:21 +02:00

219 lines
6.4 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) {
const body = await res.json().catch(() => ({}));
// Never render body.message — route through getErrorMessage() to prevent leaking backend internals
throw new Error((body as { code?: string })?.code ?? 'INTERNAL_ERROR');
}
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
};
}