feat(transcription): let read-only users read transcriptions (read tab only, no edit) (#697) #700
@@ -177,6 +177,13 @@ public class Document {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
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
|
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
||||||
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
||||||
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ public class DocumentController {
|
|||||||
// --- METADATA ---
|
// --- METADATA ---
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Document getDocument(@PathVariable UUID id) {
|
public Document getDocument(@PathVariable UUID id) {
|
||||||
return documentService.getDocumentById(id);
|
return documentService.getDocumentDetail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
|||||||
@@ -946,6 +946,19 @@ public class DocumentService {
|
|||||||
return doc;
|
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) {
|
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
||||||
return documentRepository.findAllById(ids);
|
return documentRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public class TranscriptionBlockQueryService {
|
|||||||
|
|
||||||
private final TranscriptionBlockRepository blockRepository;
|
private final TranscriptionBlockRepository blockRepository;
|
||||||
|
|
||||||
|
public boolean hasBlocks(UUID documentId) {
|
||||||
|
return blockRepository.existsByDocumentId(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
||||||
if (documentIds.isEmpty()) return Map.of();
|
if (documentIds.isEmpty()) return Map.of();
|
||||||
Map<UUID, Integer> result = new HashMap<>();
|
Map<UUID, Integer> result = new HashMap<>();
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
|
|
||||||
int countByDocumentId(UUID documentId);
|
int countByDocumentId(UUID documentId);
|
||||||
|
|
||||||
|
boolean existsByDocumentId(UUID documentId);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT b FROM TranscriptionBlock b
|
SELECT b FROM TranscriptionBlock b
|
||||||
JOIN DocumentAnnotation a ON a.id = b.annotationId
|
JOIN DocumentAnnotation a ON a.id = b.annotationId
|
||||||
|
|||||||
@@ -118,6 +118,37 @@ class DocumentServiceTest {
|
|||||||
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
|
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 ───────────────────────────────────────────────────────
|
// ─── updateDocument ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
|
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
@@ -190,6 +199,15 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns200_withWriteAllPermission() throws Exception {
|
void patchAnnotation_returns200_withWriteAllPermission() throws Exception {
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
|
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
|
||||||
@@ -142,6 +151,16 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isUnauthorized());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
|
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
|
||||||
@@ -181,6 +200,14 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isUnauthorized());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void editComment_returns200_whenHasPermission() throws Exception {
|
void editComment_returns200_whenHasPermission() throws Exception {
|
||||||
|
|||||||
@@ -159,6 +159,15 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
|
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
|
||||||
@@ -233,6 +242,15 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception {
|
void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception {
|
||||||
@@ -363,6 +381,15 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||||
@@ -440,6 +467,14 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.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 ───────────────────────────────────────────────────
|
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,4 +102,22 @@ class TranscriptionBlockRepositoryIntegrationTest {
|
|||||||
assertThat(byDoc).containsEntry(DOC_A, 100);
|
assertThat(byDoc).containsEntry(DOC_A, 100);
|
||||||
assertThat(byDoc).containsEntry(DOC_B, 0);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
frontend/e2e/transcription-read-only.spec.ts
Normal file
98
frontend/e2e/transcription-read-only.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -603,6 +603,8 @@
|
|||||||
"doc_details_no_tags": "Keine Schlagwörter zugeordnet",
|
"doc_details_no_tags": "Keine Schlagwörter zugeordnet",
|
||||||
"doc_details_more_receivers": "+{count} weitere",
|
"doc_details_more_receivers": "+{count} weitere",
|
||||||
"transcription_mode_label": "Transkribieren",
|
"transcription_mode_label": "Transkribieren",
|
||||||
|
"transcription_read_label": "Transkription lesen",
|
||||||
|
"transcription_panel_title": "Transkription",
|
||||||
"transcription_mode_stop": "Fertig",
|
"transcription_mode_stop": "Fertig",
|
||||||
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
|
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
|
||||||
"transcription_block_save_saving": "Speichere...",
|
"transcription_block_save_saving": "Speichere...",
|
||||||
|
|||||||
@@ -603,6 +603,8 @@
|
|||||||
"doc_details_no_tags": "No tags assigned",
|
"doc_details_no_tags": "No tags assigned",
|
||||||
"doc_details_more_receivers": "+{count} more",
|
"doc_details_more_receivers": "+{count} more",
|
||||||
"transcription_mode_label": "Transcribe",
|
"transcription_mode_label": "Transcribe",
|
||||||
|
"transcription_read_label": "Read transcription",
|
||||||
|
"transcription_panel_title": "Transcription",
|
||||||
"transcription_mode_stop": "Done",
|
"transcription_mode_stop": "Done",
|
||||||
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
|
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
|
||||||
"transcription_block_save_saving": "Saving...",
|
"transcription_block_save_saving": "Saving...",
|
||||||
|
|||||||
@@ -603,6 +603,8 @@
|
|||||||
"doc_details_no_tags": "No hay etiquetas asignadas",
|
"doc_details_no_tags": "No hay etiquetas asignadas",
|
||||||
"doc_details_more_receivers": "+{count} más",
|
"doc_details_more_receivers": "+{count} más",
|
||||||
"transcription_mode_label": "Transcribir",
|
"transcription_mode_label": "Transcribir",
|
||||||
|
"transcription_read_label": "Leer transcripción",
|
||||||
|
"transcription_panel_title": "Transcripción",
|
||||||
"transcription_mode_stop": "Listo",
|
"transcription_mode_stop": "Listo",
|
||||||
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
|
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
|
||||||
"transcription_block_save_saving": "Guardando...",
|
"transcription_block_save_saving": "Guardando...",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
|
|||||||
type Props = {
|
type Props = {
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
isPdf: boolean;
|
isPdf: boolean;
|
||||||
|
hasTranscription: boolean;
|
||||||
transcribeMode: boolean;
|
transcribeMode: boolean;
|
||||||
filePath?: string | null;
|
filePath?: string | null;
|
||||||
originalFilename?: string | null;
|
originalFilename?: string | null;
|
||||||
@@ -14,12 +15,18 @@ type Props = {
|
|||||||
let {
|
let {
|
||||||
canWrite,
|
canWrite,
|
||||||
isPdf,
|
isPdf,
|
||||||
|
hasTranscription,
|
||||||
transcribeMode = $bindable(),
|
transcribeMode = $bindable(),
|
||||||
filePath = null,
|
filePath = null,
|
||||||
originalFilename = null,
|
originalFilename = null,
|
||||||
fileUrl
|
fileUrl
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf);
|
||||||
|
const transcriptionLabel = $derived(
|
||||||
|
canWrite ? m.transcription_mode_label() : m.transcription_read_label()
|
||||||
|
);
|
||||||
|
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
|
|
||||||
function startTranscribe() {
|
function startTranscribe() {
|
||||||
@@ -50,10 +57,10 @@ function startTranscribe() {
|
|||||||
role="menu"
|
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"
|
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
|
<button
|
||||||
onclick={startTranscribe}
|
onclick={startTranscribe}
|
||||||
aria-label={m.transcription_mode_label()}
|
aria-label={transcriptionLabel}
|
||||||
aria-pressed={false}
|
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"
|
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"
|
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>
|
</svg>
|
||||||
{m.transcription_mode_label()}
|
{transcriptionLabel}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ afterEach(cleanup);
|
|||||||
const baseProps = {
|
const baseProps = {
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
isPdf: false,
|
isPdf: false,
|
||||||
|
hasTranscription: false,
|
||||||
transcribeMode: false,
|
transcribeMode: false,
|
||||||
filePath: null as string | null,
|
filePath: null as string | null,
|
||||||
originalFilename: 'brief.pdf' 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();
|
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 () => {
|
it('hides the transcribe action when already in transcribeMode', async () => {
|
||||||
render(DocumentMobileMenu, {
|
render(DocumentMobileMenu, {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Doc = {
|
|||||||
location?: string | null;
|
location?: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
tags?: Tag[] | null;
|
tags?: Tag[] | null;
|
||||||
|
hasTranscription?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GeschichteSummary = {
|
type GeschichteSummary = {
|
||||||
@@ -132,6 +133,7 @@ const overflowPersons = $derived(receivers.slice(2));
|
|||||||
documentId={doc.id}
|
documentId={doc.id}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
isPdf={!!isPdf}
|
isPdf={!!isPdf}
|
||||||
|
hasTranscription={!!doc.hasTranscription}
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
filePath={doc.filePath}
|
filePath={doc.filePath}
|
||||||
originalFilename={doc.originalFilename}
|
originalFilename={doc.originalFilename}
|
||||||
@@ -143,6 +145,7 @@ const overflowPersons = $derived(receivers.slice(2));
|
|||||||
<DocumentMobileMenu
|
<DocumentMobileMenu
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
isPdf={!!isPdf}
|
isPdf={!!isPdf}
|
||||||
|
hasTranscription={!!doc.hasTranscription}
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
filePath={doc.filePath}
|
filePath={doc.filePath}
|
||||||
originalFilename={doc.originalFilename}
|
originalFilename={doc.originalFilename}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type Props = {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
isPdf: boolean;
|
isPdf: boolean;
|
||||||
|
hasTranscription: boolean;
|
||||||
transcribeMode: boolean;
|
transcribeMode: boolean;
|
||||||
filePath?: string | null;
|
filePath?: string | null;
|
||||||
originalFilename?: string | null;
|
originalFilename?: string | null;
|
||||||
@@ -15,17 +16,23 @@ let {
|
|||||||
documentId,
|
documentId,
|
||||||
canWrite,
|
canWrite,
|
||||||
isPdf,
|
isPdf,
|
||||||
|
hasTranscription,
|
||||||
transcribeMode = $bindable(),
|
transcribeMode = $bindable(),
|
||||||
filePath = null,
|
filePath = null,
|
||||||
originalFilename = null,
|
originalFilename = null,
|
||||||
fileUrl
|
fileUrl
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf);
|
||||||
|
const transcriptionLabel = $derived(
|
||||||
|
canWrite ? m.transcription_mode_label() : m.transcription_read_label()
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if canWrite && isPdf && !transcribeMode}
|
{#if canOpenTranscription && !transcribeMode}
|
||||||
<button
|
<button
|
||||||
onclick={() => (transcribeMode = true)}
|
onclick={() => (transcribeMode = true)}
|
||||||
aria-label={m.transcription_mode_label()}
|
aria-label={transcriptionLabel}
|
||||||
aria-pressed={false}
|
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"
|
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"
|
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>
|
</svg>
|
||||||
{m.transcription_mode_label()}
|
{transcriptionLabel}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const baseProps = {
|
|||||||
documentId: 'd1',
|
documentId: 'd1',
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
isPdf: false,
|
isPdf: false,
|
||||||
|
hasTranscription: false,
|
||||||
transcribeMode: false,
|
transcribeMode: false,
|
||||||
filePath: null as string | null,
|
filePath: null as string | null,
|
||||||
originalFilename: 'brief.pdf' 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();
|
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 () => {
|
it('omits the transcribe button when not a PDF', async () => {
|
||||||
render(DocumentTopBarActions, {
|
render(DocumentTopBarActions, {
|
||||||
props: { ...baseProps, canWrite: true, isPdf: false, filePath: 'docs/x.jpg' }
|
props: { ...baseProps, canWrite: true, isPdf: false, filePath: 'docs/x.jpg' }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Props = {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
transcribeMode?: boolean;
|
transcribeMode?: boolean;
|
||||||
|
canAnnotate?: boolean;
|
||||||
blockNumbers?: Record<string, number>;
|
blockNumbers?: Record<string, number>;
|
||||||
annotationReloadKey?: number;
|
annotationReloadKey?: number;
|
||||||
activeAnnotationId: string | null;
|
activeAnnotationId: string | null;
|
||||||
@@ -33,6 +34,7 @@ let {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
transcribeMode = false,
|
transcribeMode = false,
|
||||||
|
canAnnotate = false,
|
||||||
blockNumbers = {},
|
blockNumbers = {},
|
||||||
annotationReloadKey = 0,
|
annotationReloadKey = 0,
|
||||||
activeAnnotationId = $bindable(),
|
activeAnnotationId = $bindable(),
|
||||||
@@ -93,6 +95,7 @@ let {
|
|||||||
url={fileUrl}
|
url={fileUrl}
|
||||||
documentId={doc.id}
|
documentId={doc.id}
|
||||||
transcribeMode={transcribeMode}
|
transcribeMode={transcribeMode}
|
||||||
|
canAnnotate={canAnnotate}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
annotationReloadKey={annotationReloadKey}
|
annotationReloadKey={annotationReloadKey}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ type Props = {
|
|||||||
hasBlocks: boolean;
|
hasBlocks: boolean;
|
||||||
blockCount: number;
|
blockCount: number;
|
||||||
lastEditedAt: string | null;
|
lastEditedAt: string | null;
|
||||||
|
canEdit?: boolean;
|
||||||
onModeChange: (mode: 'read' | 'edit') => void;
|
onModeChange: (mode: 'read' | 'edit') => void;
|
||||||
onClose: () => 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(
|
const formattedDate = $derived(
|
||||||
lastEditedAt
|
lastEditedAt
|
||||||
@@ -34,37 +43,41 @@ function handleReadClick() {
|
|||||||
<div
|
<div
|
||||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||||
>
|
>
|
||||||
<!-- Segmented toggle + help chip -->
|
<!-- Segmented toggle + help chip for editors; plain title for read-only users -->
|
||||||
<div class="flex items-center gap-1.5">
|
{#if canEdit}
|
||||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
<div class="flex items-center gap-1.5">
|
||||||
<button
|
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||||
type="button"
|
<button
|
||||||
data-testid="mode-read"
|
type="button"
|
||||||
aria-disabled={!hasBlocks}
|
data-testid="mode-read"
|
||||||
onclick={handleReadClick}
|
aria-disabled={!hasBlocks}
|
||||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
onclick={handleReadClick}
|
||||||
? 'bg-primary text-primary-fg'
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||||
: 'text-ink-2 hover:text-ink'}"
|
? 'bg-primary text-primary-fg'
|
||||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
>
|
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||||
{m.mode_read()}
|
>
|
||||||
</button>
|
{m.mode_read()}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
data-testid="mode-edit"
|
type="button"
|
||||||
onclick={() => onModeChange('edit')}
|
data-testid="mode-edit"
|
||||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
onclick={() => onModeChange('edit')}
|
||||||
? 'bg-primary text-primary-fg'
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||||
: 'text-ink-2 hover:text-ink'}"
|
? '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>
|
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<HelpPopover label={m.transcription_mode_help_label()}>
|
{:else}
|
||||||
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
<h2 class="text-[16px] font-semibold text-ink">{m.transcription_panel_title()}</h2>
|
||||||
</HelpPopover>
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status line (hidden on mobile to save space) -->
|
<!-- Status line (hidden on mobile to save space) -->
|
||||||
<p class="hidden text-xs text-ink-2 md:block">
|
<p class="hidden text-xs text-ink-2 md:block">
|
||||||
|
|||||||
@@ -22,6 +22,27 @@ describe('TranscriptionPanelHeader', () => {
|
|||||||
await expect.element(page.getByRole('button', { name: /bearbeiten/i })).toBeVisible();
|
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 () => {
|
it('marks the Lesen button as aria-disabled when hasBlocks is false', async () => {
|
||||||
render(TranscriptionPanelHeader, {
|
render(TranscriptionPanelHeader, {
|
||||||
...baseProps,
|
...baseProps,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ let {
|
|||||||
url,
|
url,
|
||||||
documentId = '',
|
documentId = '',
|
||||||
transcribeMode = false,
|
transcribeMode = false,
|
||||||
|
canAnnotate = false,
|
||||||
blockNumbers = {},
|
blockNumbers = {},
|
||||||
annotationReloadKey = 0,
|
annotationReloadKey = 0,
|
||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
@@ -28,6 +29,7 @@ let {
|
|||||||
url: string;
|
url: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
transcribeMode?: boolean;
|
transcribeMode?: boolean;
|
||||||
|
canAnnotate?: boolean;
|
||||||
blockNumbers?: Record<string, number>;
|
blockNumbers?: Record<string, number>;
|
||||||
annotationReloadKey?: number;
|
annotationReloadKey?: number;
|
||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
@@ -262,7 +264,7 @@ function handleAnnotationClick(id: string) {
|
|||||||
annotations={visibleAnnotations.filter(
|
annotations={visibleAnnotations.filter(
|
||||||
(a) => a.pageNumber === renderer.currentPage
|
(a) => a.pageNumber === renderer.currentPage
|
||||||
)}
|
)}
|
||||||
canDraw={transcribeMode}
|
canDraw={transcribeMode && canAnnotate}
|
||||||
color={TRANSCRIPTION_COLOR}
|
color={TRANSCRIPTION_COLOR}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
activeAnnotationId={activeAnnotationId}
|
activeAnnotationId={activeAnnotationId}
|
||||||
|
|||||||
@@ -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 () => {
|
it('renders the canvas region when documentFileHash is provided', async () => {
|
||||||
render(PdfViewer, {
|
render(PdfViewer, {
|
||||||
url: '/api/documents/test/file',
|
url: '/api/documents/test/file',
|
||||||
|
|||||||
@@ -1758,6 +1758,7 @@ export interface components {
|
|||||||
sender?: components["schemas"]["Person"];
|
sender?: components["schemas"]["Person"];
|
||||||
tags?: components["schemas"]["Tag"][];
|
tags?: components["schemas"]["Tag"][];
|
||||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||||
|
hasTranscription: boolean;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
};
|
};
|
||||||
PersonMention: {
|
PersonMention: {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const ocrJob = createOcrJob({
|
|||||||
onJobFinished: async () => {
|
onJobFinished: async () => {
|
||||||
await transcription.load();
|
await transcription.load();
|
||||||
transcription.bumpAnnotationReloadKey();
|
transcription.bumpAnnotationReloadKey();
|
||||||
panelMode = transcription.hasBlocks ? 'read' : 'edit';
|
panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,10 +148,10 @@ $effect(() => {
|
|||||||
if (skipInitialPanelMode) {
|
if (skipInitialPanelMode) {
|
||||||
skipInitialPanelMode = false;
|
skipInitialPanelMode = false;
|
||||||
} else {
|
} 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}
|
isLoading={fileLoader.isLoading}
|
||||||
error={fileLoader.fileError}
|
error={fileLoader.fileError}
|
||||||
transcribeMode={transcribeMode && !ocrJob.running}
|
transcribeMode={transcribeMode && !ocrJob.running}
|
||||||
|
canAnnotate={canWrite}
|
||||||
blockNumbers={transcription.blockNumbers}
|
blockNumbers={transcription.blockNumbers}
|
||||||
annotationReloadKey={transcription.annotationReloadKey}
|
annotationReloadKey={transcription.annotationReloadKey}
|
||||||
annotationsDimmed={transcribeMode && panelMode === 'read'}
|
annotationsDimmed={transcribeMode && panelMode === 'read'}
|
||||||
@@ -293,7 +294,10 @@ onMount(() => {
|
|||||||
hasBlocks={transcription.hasBlocks}
|
hasBlocks={transcription.hasBlocks}
|
||||||
blockCount={transcription.blocks.length}
|
blockCount={transcription.blocks.length}
|
||||||
lastEditedAt={transcription.lastEditedAt}
|
lastEditedAt={transcription.lastEditedAt}
|
||||||
onModeChange={(newMode) => (panelMode = newMode)}
|
canEdit={canWrite}
|
||||||
|
onModeChange={(newMode) => {
|
||||||
|
if (canWrite) panelMode = newMode;
|
||||||
|
}}
|
||||||
onClose={() => (transcribeMode = false)}
|
onClose={() => (transcribeMode = false)}
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ describe('documents/[id] page', () => {
|
|||||||
try {
|
try {
|
||||||
mockPage.url = new URL('http://localhost/documents/d-ocr-fail?task=transcribe');
|
mockPage.url = new URL('http://localhost/documents/d-ocr-fail?task=transcribe');
|
||||||
render(DocumentDetailPage, {
|
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(() => {
|
await vi.waitFor(() => {
|
||||||
expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull();
|
expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull();
|
||||||
@@ -391,7 +391,7 @@ describe('documents/[id] page', () => {
|
|||||||
try {
|
try {
|
||||||
mockPage.url = new URL('http://localhost/documents/d-ocr-run?task=transcribe');
|
mockPage.url = new URL('http://localhost/documents/d-ocr-run?task=transcribe');
|
||||||
render(DocumentDetailPage, {
|
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();
|
await expect.element(browserPage.getByText('OCR läuft')).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user