feat(transcription): let read-only users read transcriptions (read tab only, no edit) (#697) #700

Merged
marcel merged 12 commits from feat/issue-697-readers-read-transcriptions into main 2026-05-31 13:28:38 +02:00
28 changed files with 521 additions and 45 deletions

View File

@@ -177,6 +177,13 @@ public class Document {
@Builder.Default
private Set<TrainingLabel> 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

View File

@@ -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)

View File

@@ -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<Document> getDocumentsByIds(List<UUID> ids) {
return documentRepository.findAllById(ids);
}

View File

@@ -17,6 +17,10 @@ public class TranscriptionBlockQueryService {
private final TranscriptionBlockRepository blockRepository;
public boolean hasBlocks(UUID documentId) {
return blockRepository.existsByDocumentId(documentId);
}
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
Map<UUID, Integer> result = new HashMap<>();

View File

@@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
int countByDocumentId(UUID documentId);
boolean existsByDocumentId(UUID documentId);
@Query("""
SELECT b FROM TranscriptionBlock b
JOIN DocumentAnnotation a ON a.id = b.annotationId

View File

@@ -118,6 +118,37 @@ class DocumentServiceTest {
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
}
@Test
void getDocumentById_doesNotQueryTranscription() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
documentService.getDocumentById(id);
verifyNoInteractions(transcriptionBlockQueryService);
}
@Test
void getDocumentDetail_setsHasTranscriptionTrue_whenBlocksExist() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(true);
assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isTrue();
}
@Test
void getDocumentDetail_setsHasTranscriptionFalse_whenNoBlocksExist() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(false);
assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isFalse();
}
// ─── updateDocument ───────────────────────────────────────────────────────
@Test

View File

@@ -83,6 +83,15 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void createAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
@@ -190,6 +199,15 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void patchAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns200_withWriteAllPermission() throws Exception {

View File

@@ -94,6 +94,15 @@ class CommentControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void postBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
@@ -142,6 +151,16 @@ class CommentControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void replyToBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
@@ -181,6 +200,14 @@ class CommentControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void editComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void editComment_returns200_whenHasPermission() throws Exception {

View File

@@ -159,6 +159,15 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void createBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(post(URL_BASE).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
@@ -233,6 +242,15 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void updateBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(put(URL_BLOCK).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception {
@@ -363,6 +381,15 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void reorderBlocks_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(put(URL_REORDER).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(REORDER_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
@@ -440,6 +467,14 @@ class TranscriptionBlockControllerTest {
.andExpect(jsonPath("$.reviewed").value(true));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void reviewBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
DOC_ID, BLOCK_ID).with(csrf()))
.andExpect(status().isForbidden());
}
// ─── PUT .../review-all ───────────────────────────────────────────────────
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";

View File

@@ -0,0 +1,35 @@
package org.raddatz.familienarchiv.document.transcription;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TranscriptionBlockQueryServiceTest {
@Mock TranscriptionBlockRepository blockRepository;
@InjectMocks TranscriptionBlockQueryService queryService;
@Test
void hasBlocks_returns_true_when_a_block_exists() {
UUID documentId = UUID.randomUUID();
when(blockRepository.existsByDocumentId(documentId)).thenReturn(true);
assertThat(queryService.hasBlocks(documentId)).isTrue();
}
@Test
void hasBlocks_returns_false_when_no_block_exists() {
UUID documentId = UUID.randomUUID();
when(blockRepository.existsByDocumentId(documentId)).thenReturn(false);
assertThat(queryService.hasBlocks(documentId)).isFalse();
}
}

View File

@@ -102,4 +102,22 @@ class TranscriptionBlockRepositoryIntegrationTest {
assertThat(byDoc).containsEntry(DOC_A, 100);
assertThat(byDoc).containsEntry(DOC_B, 0);
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)"
})
void existsByDocumentId_returns_true_when_document_has_a_block() {
assertThat(repository.existsByDocumentId(DOC_A)).isTrue();
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')"
})
void existsByDocumentId_returns_false_when_document_has_no_blocks() {
assertThat(repository.existsByDocumentId(DOC_A)).isFalse();
}
}

View File

@@ -0,0 +1,98 @@
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { login } from './helpers/auth';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
/**
* E2E for issue #697 — read-only users can read an existing transcription.
*
* Setup runs as admin (default storage state): a PDF document with one
* transcription block, so hasTranscription is true. The assertions run in a
* fresh context logged in as the seeded READ_ALL-only "reader": they can open
* the read view but see no edit tab and no per-block edit controls, and the
* panel cannot be switched to edit.
*/
let docId: string;
let docHref: string;
test.describe.configure({ mode: 'serial' });
test.describe('Read-only user reads an existing transcription', () => {
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();
}
});
});

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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}
<button
onclick={startTranscribe}
aria-label={m.transcription_mode_label()}
aria-label={transcriptionLabel}
aria-pressed={false}
class="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
>
@@ -70,7 +77,7 @@ function startTranscribe() {
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
{transcriptionLabel}
</button>
{/if}

View File

@@ -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: {

View File

@@ -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));
<DocumentMobileMenu
canWrite={canWrite}
isPdf={!!isPdf}
hasTranscription={!!doc.hasTranscription}
bind:transcribeMode={transcribeMode}
filePath={doc.filePath}
originalFilename={doc.originalFilename}

View File

@@ -5,6 +5,7 @@ type Props = {
documentId: string;
canWrite: boolean;
isPdf: boolean;
hasTranscription: boolean;
transcribeMode: boolean;
filePath?: string | null;
originalFilename?: string | null;
@@ -15,17 +16,23 @@ let {
documentId,
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()
);
</script>
{#if canWrite && isPdf && !transcribeMode}
{#if canOpenTranscription && !transcribeMode}
<button
onclick={() => (transcribeMode = true)}
aria-label={m.transcription_mode_label()}
aria-label={transcriptionLabel}
aria-pressed={false}
class="hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex"
>
@@ -42,7 +49,7 @@ let {
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
{transcriptionLabel}
</button>
{/if}

View File

@@ -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' }

View File

@@ -17,6 +17,7 @@ type Props = {
isLoading: boolean;
error: string;
transcribeMode?: boolean;
canAnnotate?: boolean;
blockNumbers?: Record<string, number>;
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}

View File

@@ -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() {
<div
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
>
<!-- Segmented toggle + help chip -->
<div class="flex items-center gap-1.5">
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
<button
type="button"
data-testid="mode-read"
aria-disabled={!hasBlocks}
onclick={handleReadClick}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
style:opacity={!hasBlocks ? '0.35' : undefined}
>
{m.mode_read()}
</button>
<button
type="button"
data-testid="mode-edit"
onclick={() => onModeChange('edit')}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
>
<span class="md:hidden">{m.mode_edit_short()}</span>
<span class="hidden md:inline">{m.mode_edit()}</span>
</button>
<!-- Segmented toggle + help chip for editors; plain title for read-only users -->
{#if canEdit}
<div class="flex items-center gap-1.5">
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
<button
type="button"
data-testid="mode-read"
aria-disabled={!hasBlocks}
onclick={handleReadClick}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
style:opacity={!hasBlocks ? '0.35' : undefined}
>
{m.mode_read()}
</button>
<button
type="button"
data-testid="mode-edit"
onclick={() => onModeChange('edit')}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
>
<span class="md:hidden">{m.mode_edit_short()}</span>
<span class="hidden md:inline">{m.mode_edit()}</span>
</button>
</div>
<HelpPopover label={m.transcription_mode_help_label()}>
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
</HelpPopover>
</div>
<HelpPopover label={m.transcription_mode_help_label()}>
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
</HelpPopover>
</div>
{:else}
<h2 class="text-[16px] font-semibold text-ink">{m.transcription_panel_title()}</h2>
{/if}
<!-- Status line (hidden on mobile to save space) -->
<p class="hidden text-xs text-ink-2 md:block">

View File

@@ -22,6 +22,27 @@ describe('TranscriptionPanelHeader', () => {
await expect.element(page.getByRole('button', { name: /bearbeiten/i })).toBeVisible();
});
it('renders both tabs when canEdit is true', async () => {
render(TranscriptionPanelHeader, { ...baseProps, canEdit: true });
await expect.element(page.getByTestId('mode-read')).toBeInTheDocument();
await expect.element(page.getByTestId('mode-edit')).toBeInTheDocument();
});
it('hides the edit tab and shows a plain title when canEdit is false', async () => {
render(TranscriptionPanelHeader, { ...baseProps, canEdit: false });
await expect.element(page.getByTestId('mode-edit')).not.toBeInTheDocument();
await expect.element(page.getByTestId('mode-read')).not.toBeInTheDocument();
await expect.element(page.getByRole('heading', { name: /^transkription$/i })).toBeVisible();
});
it('keeps the section status line visible for readers (canEdit false)', async () => {
render(TranscriptionPanelHeader, { ...baseProps, canEdit: false, blockCount: 3 });
await expect.element(page.getByText('3 Abschnitte')).toBeVisible();
});
it('marks the Lesen button as aria-disabled when hasBlocks is false', async () => {
render(TranscriptionPanelHeader, {
...baseProps,

View File

@@ -14,6 +14,7 @@ let {
url,
documentId = '',
transcribeMode = false,
canAnnotate = false,
blockNumbers = {},
annotationReloadKey = 0,
activeAnnotationId = $bindable<string | null>(null),
@@ -28,6 +29,7 @@ let {
url: string;
documentId?: string;
transcribeMode?: boolean;
canAnnotate?: boolean;
blockNumbers?: Record<string, number>;
annotationReloadKey?: number;
activeAnnotationId?: string | null;
@@ -262,7 +264,7 @@ function handleAnnotationClick(id: string) {
annotations={visibleAnnotations.filter(
(a) => a.pageNumber === renderer.currentPage
)}
canDraw={transcribeMode}
canDraw={transcribeMode && canAnnotate}
color={TRANSCRIPTION_COLOR}
blockNumbers={blockNumbers}
activeAnnotationId={activeAnnotationId}

View File

@@ -86,6 +86,38 @@ describe('PdfViewer — loaded state', () => {
}
});
it('makes the annotation surface drawable (crosshair) when transcribeMode and canAnnotate', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
transcribeMode: true,
canAnnotate: true,
libLoader: makeFakeLibLoader()
});
await vi.waitFor(() => {
const surface = document.querySelector('[role="presentation"]');
expect(surface).not.toBeNull();
expect(surface?.getAttribute('style') ?? '').toContain('crosshair');
});
});
it('does not make the annotation surface drawable when canAnnotate is false (read-only user)', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
transcribeMode: true,
canAnnotate: false,
libLoader: makeFakeLibLoader()
});
await vi.waitFor(() => {
expect(document.querySelector('.bg-pdf-bg')).not.toBeNull();
});
const surface = document.querySelector('[role="presentation"]');
expect(surface?.getAttribute('style') ?? '').not.toContain('crosshair');
});
it('renders the canvas region when documentFileHash is provided', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',

View File

@@ -1758,6 +1758,7 @@ export interface components {
sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][];
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
hasTranscription: boolean;
thumbnailUrl?: string;
};
PersonMention: {

View File

@@ -71,7 +71,7 @@ const ocrJob = createOcrJob({
onJobFinished: async () => {
await transcription.load();
transcription.bumpAnnotationReloadKey();
panelMode = transcription.hasBlocks ? 'read' : 'edit';
panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read';
}
});
@@ -148,10 +148,10 @@ $effect(() => {
if (skipInitialPanelMode) {
skipInitialPanelMode = false;
} else {
panelMode = transcription.hasBlocks ? 'read' : 'edit';
panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read';
}
});
ocrJob.checkStatus();
if (canWrite) ocrJob.checkStatus();
}
});
@@ -252,6 +252,7 @@ onMount(() => {
isLoading={fileLoader.isLoading}
error={fileLoader.fileError}
transcribeMode={transcribeMode && !ocrJob.running}
canAnnotate={canWrite}
blockNumbers={transcription.blockNumbers}
annotationReloadKey={transcription.annotationReloadKey}
annotationsDimmed={transcribeMode && panelMode === 'read'}
@@ -293,7 +294,10 @@ onMount(() => {
hasBlocks={transcription.hasBlocks}
blockCount={transcription.blocks.length}
lastEditedAt={transcription.lastEditedAt}
onModeChange={(newMode) => (panelMode = newMode)}
canEdit={canWrite}
onModeChange={(newMode) => {
if (canWrite) panelMode = newMode;
}}
onClose={() => (transcribeMode = false)}
/>
<div class="flex-1 overflow-y-auto">

View File

@@ -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 {