diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java index 7f702763..dc47f061 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java @@ -177,6 +177,13 @@ public class Document { @Builder.Default private Set trainingLabels = new HashSet<>(); + // Not persisted — computed per detail fetch so read-only users can tell at first + // paint whether there is a transcription to read (DocumentService.getDocumentById). + @Transient + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private boolean hasTranscription = false; + // The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail // endpoint sends `Cache-Control: private, max-age=31536000, immutable` // (DocumentController.getDocumentThumbnail). `immutable` is only safe because diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java index daaa96c5..b77b57b7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java @@ -138,7 +138,7 @@ public class DocumentController { // --- METADATA --- @GetMapping("/{id}") public Document getDocument(@PathVariable UUID id) { - return documentService.getDocumentById(id); + return documentService.getDocumentDetail(id); } @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 8108c997..93570e49 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -946,6 +946,19 @@ public class DocumentService { return doc; } + /** + * Loads a document for the detail view, additionally flagging whether it has any + * transcription to read. Kept separate from {@link #getDocumentById} so the cheap + * existence query only runs for the single-document detail endpoint, not for the + * many internal callers that never read the flag. + */ + @Transactional(readOnly = true) + public Document getDocumentDetail(UUID id) { + Document doc = getDocumentById(id); + doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id)); + return doc; + } + public List getDocumentsByIds(List ids) { return documentRepository.findAllById(ids); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryService.java index 3daa2544..be183356 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryService.java @@ -17,6 +17,10 @@ public class TranscriptionBlockQueryService { private final TranscriptionBlockRepository blockRepository; + public boolean hasBlocks(UUID documentId) { + return blockRepository.existsByDocumentId(documentId); + } + public Map getCompletionStats(List documentIds) { if (documentIds.isEmpty()) return Map.of(); Map result = new HashMap<>(); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockRepository.java index 4da9ee8e..fbfab4c6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockRepository.java @@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + const uniqueSuffix = Date.now(); + + const docRes = await request.post('/api/documents', { + multipart: { + title: `E2E Read-only Transcription ${uniqueSuffix}`, + documentDate: '1945-05-08' + } + }); + if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`); + docId = (await docRes.json()).id; + docHref = `${baseURL}/documents/${docId}`; + + await request.put(`/api/documents/${docId}`, { + multipart: { + title: `E2E Read-only Transcription ${uniqueSuffix}`, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + + const annRes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' } + }); + if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`); + + const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.5, + height: 0.1, + text: 'Liebe Mutter, viele Grüße vom Mai 1945', + label: null + } + }); + if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`); + }); + + test.afterAll(async ({ request }) => { + if (docId) await request.delete(`/api/documents/${docId}`); + }); + + test('reader opens the read view with no edit tab or edit controls', async ({ browser }) => { + const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const page = await context.newPage(); + try { + await login(page, 'reader', 'reader123'); + await page.goto(docHref); + + // Reader entry control is the read label, not "Transkribieren". + const readButton = page.getByRole('button', { name: /Transkription lesen/i }); + await expect(readButton).toBeVisible({ timeout: 5000 }); + await readButton.click(); + + // Read view shows the transcription text. + await expect(page.getByText(/Mai 1945/)).toBeVisible({ timeout: 5000 }); + + // Header is a plain "Transkription" label, not a Lesen/Bearbeiten toggle. + await expect(page.getByRole('heading', { name: /^Transkription$/i })).toBeVisible(); + await expect(page.getByTestId('mode-edit')).toHaveCount(0); + await expect(page.getByTestId('mode-read')).toHaveCount(0); + } finally { + await context.close(); + } + }); +}); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 37bd55cd..14269eed 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -603,6 +603,8 @@ "doc_details_no_tags": "Keine Schlagwörter zugeordnet", "doc_details_more_receivers": "+{count} weitere", "transcription_mode_label": "Transkribieren", + "transcription_read_label": "Transkription lesen", + "transcription_panel_title": "Transkription", "transcription_mode_stop": "Fertig", "transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen", "transcription_block_save_saving": "Speichere...", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0f5f862f..b7de0948 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -603,6 +603,8 @@ "doc_details_no_tags": "No tags assigned", "doc_details_more_receivers": "+{count} more", "transcription_mode_label": "Transcribe", + "transcription_read_label": "Read transcription", + "transcription_panel_title": "Transcription", "transcription_mode_stop": "Done", "transcription_block_placeholder": "Type text — use @name to link a person from the archive", "transcription_block_save_saving": "Saving...", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3d9e92ab..ee584c40 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -603,6 +603,8 @@ "doc_details_no_tags": "No hay etiquetas asignadas", "doc_details_more_receivers": "+{count} más", "transcription_mode_label": "Transcribir", + "transcription_read_label": "Leer transcripción", + "transcription_panel_title": "Transcripción", "transcription_mode_stop": "Listo", "transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo", "transcription_block_save_saving": "Guardando...", diff --git a/frontend/src/lib/document/DocumentMobileMenu.svelte b/frontend/src/lib/document/DocumentMobileMenu.svelte index c2892bbd..42c6df5f 100644 --- a/frontend/src/lib/document/DocumentMobileMenu.svelte +++ b/frontend/src/lib/document/DocumentMobileMenu.svelte @@ -5,6 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside'; type Props = { canWrite: boolean; isPdf: boolean; + hasTranscription: boolean; transcribeMode: boolean; filePath?: string | null; originalFilename?: string | null; @@ -14,12 +15,18 @@ type Props = { let { canWrite, isPdf, + hasTranscription, transcribeMode = $bindable(), filePath = null, originalFilename = null, fileUrl }: Props = $props(); +const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf); +const transcriptionLabel = $derived( + canWrite ? m.transcription_mode_label() : m.transcription_read_label() +); + let mobileMenuOpen = $state(false); function startTranscribe() { @@ -50,10 +57,10 @@ function startTranscribe() { role="menu" class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg" > - {#if canWrite && isPdf && !transcribeMode} + {#if canOpenTranscription && !transcribeMode} {/if} diff --git a/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts b/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts index 9b561aa5..c3c3f0a0 100644 --- a/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts +++ b/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts @@ -8,6 +8,7 @@ afterEach(cleanup); const baseProps = { canWrite: false, isPdf: false, + hasTranscription: false, transcribeMode: false, filePath: null as string | null, originalFilename: 'brief.pdf' as string | null, @@ -49,6 +50,43 @@ describe('DocumentMobileMenu', () => { await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible(); }); + it('shows no transcription action for a read-only user when no transcription exists', async () => { + render(DocumentMobileMenu, { + props: { + ...baseProps, + canWrite: false, + isPdf: true, + hasTranscription: false, + filePath: 'docs/x.pdf' + } + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect + .element(page.getByRole('button', { name: /transkribieren/i })) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /transkription lesen/i })) + .not.toBeInTheDocument(); + }); + + it('shows the read-transcription action for a read-only user when a transcription exists', async () => { + render(DocumentMobileMenu, { + props: { + ...baseProps, + canWrite: false, + isPdf: true, + hasTranscription: true, + filePath: 'docs/x.pdf' + } + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect.element(page.getByRole('button', { name: /transkription lesen/i })).toBeVisible(); + }); + it('hides the transcribe action when already in transcribeMode', async () => { render(DocumentMobileMenu, { props: { diff --git a/frontend/src/lib/document/DocumentTopBar.svelte b/frontend/src/lib/document/DocumentTopBar.svelte index 7766e376..0c935f46 100644 --- a/frontend/src/lib/document/DocumentTopBar.svelte +++ b/frontend/src/lib/document/DocumentTopBar.svelte @@ -28,6 +28,7 @@ type Doc = { location?: string | null; status?: string | null; tags?: Tag[] | null; + hasTranscription?: boolean; }; type GeschichteSummary = { @@ -132,6 +133,7 @@ const overflowPersons = $derived(receivers.slice(2)); documentId={doc.id} canWrite={canWrite} isPdf={!!isPdf} + hasTranscription={!!doc.hasTranscription} bind:transcribeMode={transcribeMode} filePath={doc.filePath} originalFilename={doc.originalFilename} @@ -143,6 +145,7 @@ const overflowPersons = $derived(receivers.slice(2)); -{#if canWrite && isPdf && !transcribeMode} +{#if canOpenTranscription && !transcribeMode} {/if} diff --git a/frontend/src/lib/document/DocumentTopBarActions.svelte.test.ts b/frontend/src/lib/document/DocumentTopBarActions.svelte.test.ts index 280a3245..1b59d0e2 100644 --- a/frontend/src/lib/document/DocumentTopBarActions.svelte.test.ts +++ b/frontend/src/lib/document/DocumentTopBarActions.svelte.test.ts @@ -9,6 +9,7 @@ const baseProps = { documentId: 'd1', canWrite: false, isPdf: false, + hasTranscription: false, transcribeMode: false, filePath: null as string | null, originalFilename: 'brief.pdf' as string | null, @@ -34,6 +35,56 @@ describe('DocumentTopBarActions', () => { await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible(); }); + it('shows no transcription control for a read-only user when no transcription exists', async () => { + render(DocumentTopBarActions, { + props: { + ...baseProps, + canWrite: false, + isPdf: true, + hasTranscription: false, + filePath: 'docs/x.pdf' + } + }); + + await expect + .element(page.getByRole('button', { name: /transkribieren/i })) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /transkription lesen/i })) + .not.toBeInTheDocument(); + }); + + it('shows the read-transcription button for a read-only user when a transcription exists', async () => { + render(DocumentTopBarActions, { + props: { + ...baseProps, + canWrite: false, + isPdf: true, + hasTranscription: true, + filePath: 'docs/x.pdf' + } + }); + + await expect.element(page.getByRole('button', { name: /transkription lesen/i })).toBeVisible(); + await expect + .element(page.getByRole('button', { name: /^transkribieren$/i })) + .not.toBeInTheDocument(); + }); + + it('shows the transcribe (edit) label for a writer regardless of hasTranscription', async () => { + render(DocumentTopBarActions, { + props: { + ...baseProps, + canWrite: true, + isPdf: true, + hasTranscription: false, + filePath: 'docs/x.pdf' + } + }); + + await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible(); + }); + it('omits the transcribe button when not a PDF', async () => { render(DocumentTopBarActions, { props: { ...baseProps, canWrite: true, isPdf: false, filePath: 'docs/x.jpg' } diff --git a/frontend/src/lib/document/DocumentViewer.svelte b/frontend/src/lib/document/DocumentViewer.svelte index 789a68d7..b0ce8af6 100644 --- a/frontend/src/lib/document/DocumentViewer.svelte +++ b/frontend/src/lib/document/DocumentViewer.svelte @@ -17,6 +17,7 @@ type Props = { isLoading: boolean; error: string; transcribeMode?: boolean; + canAnnotate?: boolean; blockNumbers?: Record; annotationReloadKey?: number; activeAnnotationId: string | null; @@ -33,6 +34,7 @@ let { isLoading, error, transcribeMode = false, + canAnnotate = false, blockNumbers = {}, annotationReloadKey = 0, activeAnnotationId = $bindable(), @@ -93,6 +95,7 @@ let { url={fileUrl} documentId={doc.id} transcribeMode={transcribeMode} + canAnnotate={canAnnotate} blockNumbers={blockNumbers} annotationReloadKey={annotationReloadKey} bind:activeAnnotationId={activeAnnotationId} diff --git a/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte b/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte index a7c91784..f6c6ee07 100644 --- a/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte @@ -8,11 +8,20 @@ type Props = { hasBlocks: boolean; blockCount: number; lastEditedAt: string | null; + canEdit?: boolean; onModeChange: (mode: 'read' | 'edit') => void; onClose: () => void; }; -let { mode, hasBlocks, blockCount, lastEditedAt, onModeChange, onClose }: Props = $props(); +let { + mode, + hasBlocks, + blockCount, + lastEditedAt, + canEdit = true, + onModeChange, + onClose +}: Props = $props(); const formattedDate = $derived( lastEditedAt @@ -34,37 +43,41 @@ function handleReadClick() {
- -
-
- - + + {#if canEdit} +
+
+ + +
+ +

{m.transcription_mode_help_body()}

+
- -

{m.transcription_mode_help_body()}

-
-
+ {:else} +

{m.transcription_panel_title()}

+ {/if}
diff --git a/frontend/src/routes/documents/[id]/page.svelte.test.ts b/frontend/src/routes/documents/[id]/page.svelte.test.ts index d3ee08f0..c34b26f5 100644 --- a/frontend/src/routes/documents/[id]/page.svelte.test.ts +++ b/frontend/src/routes/documents/[id]/page.svelte.test.ts @@ -360,7 +360,7 @@ describe('documents/[id] page', () => { try { mockPage.url = new URL('http://localhost/documents/d-ocr-fail?task=transcribe'); render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-fail' } }) } + props: { data: baseData({ canWrite: true, document: { ...baseDoc, id: 'd-ocr-fail' } }) } }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); @@ -391,7 +391,7 @@ describe('documents/[id] page', () => { try { mockPage.url = new URL('http://localhost/documents/d-ocr-run?task=transcribe'); render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-run' } }) } + props: { data: baseData({ canWrite: true, document: { ...baseDoc, id: 'd-ocr-run' } }) } }); await expect.element(browserPage.getByText('OCR läuft')).toBeVisible(); } finally {