diff --git a/frontend/messages/de.json b/frontend/messages/de.json index dcd08246..37e25574 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -633,6 +633,9 @@ "transcription_block_review": "Als geprüft markieren", "transcription_block_unreview": "Markierung aufheben", "transcription_reviewed_count": "{reviewed} von {total} geprüft", + "transcription_mark_all_reviewed": "Alle als fertig markieren", + "transcription_mark_all_reviewed_disabled": "Alle Blöcke sind bereits als fertig markiert", + "transcription_mark_all_reviewed_error": "Markierung fehlgeschlagen. Bitte versuchen Sie es erneut.", "training_ocr_heading": "Kurrent-Erkennung trainieren", "training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.", "training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f915633e..b4b675c5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -633,6 +633,9 @@ "transcription_block_review": "Mark as reviewed", "transcription_block_unreview": "Unmark as reviewed", "transcription_reviewed_count": "{reviewed} of {total} reviewed", + "transcription_mark_all_reviewed": "Mark all as reviewed", + "transcription_mark_all_reviewed_disabled": "All blocks are already marked as reviewed", + "transcription_mark_all_reviewed_error": "Failed to mark all as reviewed. Please try again.", "training_ocr_heading": "Train Kurrent recognition", "training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.", "training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1f1dec02..898b3e85 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -633,6 +633,9 @@ "transcription_block_review": "Marcar como revisado", "transcription_block_unreview": "Desmarcar como revisado", "transcription_reviewed_count": "{reviewed} de {total} revisados", + "transcription_mark_all_reviewed": "Marcar todo como revisado", + "transcription_mark_all_reviewed_disabled": "Todos los bloques ya están marcados como revisados", + "transcription_mark_all_reviewed_error": "Error al marcar como revisado. Intente de nuevo.", "training_ocr_heading": "Entrenar reconocimiento Kurrent", "training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.", "training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos", diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte index 9aeb473e..87e9749a 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte @@ -49,6 +49,7 @@ let activeBlockId: string | null = $state(null); let localLabels: string[] = $derived.by(() => [...trainingLabels]); let listEl: HTMLElement | null = $state(null); let markingAllReviewed = $state(false); +let markAllError = $state(null); const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); const hasBlocks = $derived(blocks.length > 0); @@ -67,8 +68,11 @@ $effect(() => { async function handleMarkAllReviewed() { if (!onMarkAllReviewed) return; markingAllReviewed = true; + markAllError = null; try { await onMarkAllReviewed(); + } catch { + markAllError = m.transcription_mark_all_reviewed_error(); } finally { markingAllReviewed = false; } @@ -169,7 +173,7 @@ async function handleLabelToggle(label: string) { {/if} @@ -217,6 +221,31 @@ async function handleLabelToggle(label: string) { style="width: {reviewProgress}%" > + {#if markAllError} + + {/if}
diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts index ec53e911..1abe063f 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts @@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import TranscriptionEditView from './TranscriptionEditView.svelte'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; +import { m } from '$lib/paraglide/messages.js'; afterEach(cleanup); @@ -312,14 +313,14 @@ describe('TranscriptionEditView — mark all reviewed', () => { onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) }); await expect - .element(page.getByRole('button', { name: /Alle als fertig markieren/ })) + .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .toBeInTheDocument(); }); it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => { renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] }); await expect - .element(page.getByRole('button', { name: /Alle als fertig markieren/ })) + .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .not.toBeInTheDocument(); }); @@ -329,7 +330,7 @@ describe('TranscriptionEditView — mark all reviewed', () => { onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) }); await expect - .element(page.getByRole('button', { name: /Alle als fertig markieren/ })) + .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .toBeDisabled(); }); @@ -343,7 +344,7 @@ describe('TranscriptionEditView — mark all reviewed', () => { // userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick // handlers when a TipTap editor is mounted in the same component tree. const btn = (await page - .getByRole('button', { name: /Alle als fertig markieren/ }) + .getByRole('button', { name: m.transcription_mark_all_reviewed() }) .element()) as HTMLButtonElement; btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1)); @@ -361,12 +362,83 @@ describe('TranscriptionEditView — mark all reviewed', () => { // Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick const btnEl = (await page - .getByRole('button', { name: /Alle als fertig markieren/ }) + .getByRole('button', { name: m.transcription_mark_all_reviewed() }) .element()) as HTMLButtonElement; btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await expect - .element(page.getByRole('button', { name: /Alle als fertig markieren/ })) + .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .toBeDisabled(); resolveMarkAll(); }); + + it('shows error message when onMarkAllReviewed callback rejects', async () => { + const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR')); + renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed }); + + const btnEl = (await page + .getByRole('button', { name: m.transcription_mark_all_reviewed() }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + await expect + .element(page.getByRole('alert')) + .toHaveTextContent(m.transcription_mark_all_reviewed_error()); + }); + + it('clears error when dismiss button is clicked', async () => { + const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR')); + renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed }); + + const btnEl = (await page + .getByRole('button', { name: m.transcription_mark_all_reviewed() }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + + const dismissEl = (await page + .getByRole('button', { name: m.comp_dismiss() }) + .element()) as HTMLButtonElement; + dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await expect.element(page.getByRole('alert')).not.toBeInTheDocument(); + }); + + it('clears error on next successful markAllReviewed call', async () => { + const onMarkAllReviewed = vi + .fn() + .mockRejectedValueOnce(new Error('INTERNAL_ERROR')) + .mockResolvedValue(undefined); + renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed }); + + const btnEl = (await page + .getByRole('button', { name: m.transcription_mark_all_reviewed() }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + // Wait for the button to be re-enabled before the second click — ensures the first + // async rejection has fully settled and Svelte has flushed state changes + await expect + .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) + .not.toBeDisabled(); + + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await expect.element(page.getByRole('alert')).not.toBeInTheDocument(); + }); + + it('re-enables button after markAllReviewed failure', async () => { + const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR')); + renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed }); + + const btnEl = (await page + .getByRole('button', { name: m.transcription_mark_all_reviewed() }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + + await expect + .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) + .not.toBeDisabled(); + }); }); diff --git a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.test.ts b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.test.ts index a7670791..044aaed3 100644 --- a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.test.ts +++ b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.test.ts @@ -259,12 +259,15 @@ describe('createTranscriptionBlocks.markAllReviewed', () => { expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true); }); - it('is a no-op when PUT returns non-OK', async () => { + it('throws and leaves blocks unchanged 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({ code: 'INTERNAL_ERROR' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); } return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { status: 200, @@ -274,7 +277,26 @@ describe('createTranscriptionBlocks.markAllReviewed', () => { const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); - await ctrl.markAllReviewed(); + await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR'); + expect(ctrl.blocks[0].reviewed).toBe(false); + }); + + it('throws INTERNAL_ERROR when PUT returns non-JSON body (e.g. nginx 502)', 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('Bad Gateway', { status: 502 }); + } + 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 expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR'); expect(ctrl.blocks[0].reviewed).toBe(false); }); }); diff --git a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts index 3a601f1a..da864ad8 100644 --- a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts +++ b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts @@ -119,7 +119,11 @@ export function createTranscriptionBlocks( const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, { method: 'PUT' }); - if (!res.ok) return; + 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);