diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f50e8e80..cc298b26 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -71,134 +71,4 @@ jobs: run: | chmod +x mvnw ./mvnw clean test - working-directory: backend - - # ─── E2E Tests ──────────────────────────────────────────────────────────────── - # Needs: PostgreSQL + MinIO (via docker-compose) + Spring Boot + SvelteKit dev server. - # Test data is seeded by DataInitializer on first startup (admin user + e2e profile data). - e2e-tests: - name: E2E Tests - runs-on: ubuntu-latest - - # These env vars are picked up by docker-compose (overrides .env file) - env: - DOCKER_API_VERSION: "1.43" - POSTGRES_USER: archive_user - POSTGRES_PASSWORD: ci_db_password - POSTGRES_DB: family_archive_db - MINIO_ROOT_USER: minio_admin - MINIO_ROOT_PASSWORD: ci_minio_password - MINIO_DEFAULT_BUCKETS: archive-documents - PORT_DB: 5433 - PORT_MINIO_API: 9100 - PORT_MINIO_CONSOLE: 9101 - PORT_BACKEND: 8080 - PORT_FRONTEND: 3000 - - steps: - - uses: actions/checkout@v4 - - # ── Infrastructure ────────────────────────────────────────────────────── - - name: Cleanup leftover containers from previous runs - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down --volumes --remove-orphans || true - - - name: Start DB and MinIO - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets - - - name: Wait for DB to be ready - run: | - timeout 30 bash -c \ - 'until docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T db pg_isready -U archive_user; do sleep 2; done' - - - name: Connect job container to compose network - run: docker network connect familienarchiv_archive-net $(cat /etc/hostname) - - # ── Backend ───────────────────────────────────────────────────────────── - - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: temurin - - - name: Cache Maven repository - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: maven-${{ hashFiles('backend/pom.xml') }} - restore-keys: maven- - - - name: Build backend (skip tests — covered by separate Java test job) - run: | - chmod +x mvnw - ./mvnw clean package -DskipTests - working-directory: backend - - - name: Start backend - run: | - java -jar backend/target/*.jar \ - --spring.profiles.active=e2e \ - --SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/family_archive_db \ - --SPRING_DATASOURCE_USERNAME=archive_user \ - --SPRING_DATASOURCE_PASSWORD=ci_db_password \ - --S3_ENDPOINT=http://minio:9000 \ - --S3_ACCESS_KEY=minio_admin \ - --S3_SECRET_KEY=ci_minio_password \ - --S3_BUCKET_NAME=archive-documents \ - --S3_REGION=us-east-1 \ - --APP_ADMIN_USERNAME=admin \ - --APP_ADMIN_PASSWORD=admin123 \ - & - echo "Waiting for backend..." - timeout 90 bash -c \ - 'until curl -sf http://localhost:8080/actuator/health | grep -q "UP"; do sleep 3; done' - echo "Backend is up." - - # ── Frontend ───────────────────────────────────────────────────────────── - - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Cache node_modules - id: node-modules-cache - uses: actions/cache@v4 - with: - path: frontend/node_modules - key: node-modules-${{ hashFiles('frontend/package-lock.json') }} - - - name: Install frontend dependencies - if: steps.node-modules-cache.outputs.cache-hit != 'true' - run: npm ci - working-directory: frontend - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-chromium-${{ hashFiles('frontend/package-lock.json') }} - - - name: Install Playwright Chromium + system deps - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install chromium --with-deps - working-directory: frontend - - - name: Install Playwright system deps (browser binary already cached) - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium - working-directory: frontend - - # ── Tests ──────────────────────────────────────────────────────────────── - - name: Run E2E tests - run: npm run test:e2e - working-directory: frontend - env: - E2E_BASE_URL: http://localhost:3000 - E2E_USERNAME: admin - E2E_PASSWORD: admin123 - E2E_BACKEND_URL: http://localhost:8080 - - - name: Upload E2E results - if: always() - uses: actions/upload-artifact@v3 - with: - name: e2e-results - path: frontend/test-results/e2e/ + working-directory: backend \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java index c9f9fac8..cb6b6d70 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java @@ -85,6 +85,37 @@ public class CommentController { return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); } + // ─── Block (transcription) comments ──────────────────────────────────────── + + @GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments") + public List getBlockComments(@PathVariable UUID blockId) { + return commentService.getCommentsForBlock(blockId); + } + + @PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) + public DocumentComment postBlockComment( + @PathVariable UUID documentId, + @PathVariable UUID blockId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser author = resolveUser(authentication); + return commentService.postBlockComment(documentId, blockId, dto.getContent(), dto.getMentionedUserIds(), author); + } + + @PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments/{commentId}/replies") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) + public DocumentComment replyToBlockComment( + @PathVariable UUID documentId, + @PathVariable UUID commentId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser author = resolveUser(authentication); + return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); + } + // ─── Edit and delete (shared) ───────────────────────────────────────────── @PatchMapping("/api/documents/{documentId}/comments/{commentId}") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java new file mode 100644 index 00000000..227713d0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -0,0 +1,102 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; +import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.service.TranscriptionService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/documents/{documentId}/transcription-blocks") +@RequiredArgsConstructor +@Slf4j +public class TranscriptionBlockController { + + private final TranscriptionService transcriptionService; + private final UserService userService; + + @GetMapping + @RequirePermission(Permission.READ_ALL) + public List listBlocks(@PathVariable UUID documentId) { + return transcriptionService.listBlocks(documentId); + } + + @GetMapping("/{blockId}") + @RequirePermission(Permission.READ_ALL) + public TranscriptionBlock getBlock(@PathVariable UUID documentId, @PathVariable UUID blockId) { + return transcriptionService.getBlock(documentId, blockId); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.WRITE_ALL) + public TranscriptionBlock createBlock( + @PathVariable UUID documentId, + @RequestBody CreateTranscriptionBlockDTO dto, + Authentication authentication) { + UUID userId = requireUserId(authentication); + return transcriptionService.createBlock(documentId, dto, userId); + } + + @PutMapping("/{blockId}") + @RequirePermission(Permission.WRITE_ALL) + public TranscriptionBlock updateBlock( + @PathVariable UUID documentId, + @PathVariable UUID blockId, + @RequestBody UpdateTranscriptionBlockDTO dto, + Authentication authentication) { + UUID userId = requireUserId(authentication); + return transcriptionService.updateBlock(documentId, blockId, dto, userId); + } + + @DeleteMapping("/{blockId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission(Permission.WRITE_ALL) + public void deleteBlock( + @PathVariable UUID documentId, + @PathVariable UUID blockId) { + transcriptionService.deleteBlock(documentId, blockId); + } + + @PutMapping("/reorder") + @RequirePermission(Permission.WRITE_ALL) + public List reorderBlocks( + @PathVariable UUID documentId, + @RequestBody ReorderTranscriptionBlocksDTO dto) { + transcriptionService.reorderBlocks(documentId, dto); + return transcriptionService.listBlocks(documentId); + } + + @GetMapping("/{blockId}/history") + @RequirePermission(Permission.READ_ALL) + public List getBlockHistory( + @PathVariable UUID documentId, + @PathVariable UUID blockId) { + return transcriptionService.getBlockHistory(documentId, blockId); + } + + private UUID requireUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + throw DomainException.unauthorized("Authentication required"); + } + AppUser user = userService.findByUsername(authentication.getName()); + if (user == null) { + throw DomainException.unauthorized("User not found"); + } + return user.getId(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java new file mode 100644 index 00000000..90f46359 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java @@ -0,0 +1,25 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateTranscriptionBlockDTO { + @Min(0) + private int pageNumber; + @Min(0) + private double x; + @Min(0) + private double y; + @Positive + private double width; + @Positive + private double height; + private String text; + private String label; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java new file mode 100644 index 00000000..7a7e2efb --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ReorderTranscriptionBlocksDTO { + private List blockIds; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java new file mode 100644 index 00000000..f0577e6f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java @@ -0,0 +1,13 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateTranscriptionBlockDTO { + private String text; + private String label; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index e40b122c..5952dba5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -50,6 +50,12 @@ public enum ErrorCode { /** The new annotation overlaps an existing one on the same page. 409 */ ANNOTATION_OVERLAP, + // --- Transcription Blocks --- + /** The transcription block with the given ID does not exist. 404 */ + TRANSCRIPTION_BLOCK_NOT_FOUND, + /** Optimistic locking conflict — block was modified by another user. 409 */ + TRANSCRIPTION_BLOCK_CONFLICT, + // --- Comments --- /** The comment with the given ID does not exist. 404 */ COMMENT_NOT_FOUND, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java index 26294bb8..d64941ae 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java @@ -33,6 +33,9 @@ public class DocumentComment { @Column(name = "annotation_id") private UUID annotationId; + @Column(name = "block_id") + private UUID blockId; + @Column(name = "parent_id") private UUID parentId; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java new file mode 100644 index 00000000..6f1e008e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java @@ -0,0 +1,64 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "transcription_blocks") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TranscriptionBlock { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(name = "annotation_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID annotationId; + + @Column(name = "document_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID documentId; + + @Column(nullable = false, columnDefinition = "TEXT") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String text; + + @Column(length = 200) + private String label; + + @Column(name = "sort_order", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int sortOrder; + + @Version + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int version; + + @Column(name = "created_by") + private UUID createdBy; + + @Column(name = "updated_by") + private UUID updatedBy; + + @Column(name = "created_at", nullable = false, updatable = false) + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @UpdateTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java new file mode 100644 index 00000000..9a923e04 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java @@ -0,0 +1,39 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "transcription_block_versions") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TranscriptionBlockVersion { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(name = "block_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID blockId; + + @Column(nullable = false, columnDefinition = "TEXT") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String text; + + @Column(name = "changed_by") + private UUID changedBy; + + @Column(name = "changed_at", nullable = false, updatable = false) + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime changedAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java index 80269305..9327a350 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java @@ -13,4 +13,6 @@ public interface CommentRepository extends JpaRepository List findByAnnotationIdAndParentIdIsNull(UUID annotationId); List findByParentId(UUID parentId); + + List findByBlockIdAndParentIdIsNull(UUID blockId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java new file mode 100644 index 00000000..2e6c3365 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java @@ -0,0 +1,17 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TranscriptionBlockRepository extends JpaRepository { + + List findByDocumentIdOrderBySortOrderAsc(UUID documentId); + + Optional findByIdAndDocumentId(UUID id, UUID documentId); + + int countByDocumentId(UUID documentId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java new file mode 100644 index 00000000..b4d8399b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface TranscriptionBlockVersionRepository extends JpaRepository { + + List findByBlockIdOrderByChangedAtDesc(UUID blockId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java index 4d932c84..bfd4b6df 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -34,6 +34,28 @@ public class CommentService { return withRepliesAndMentions(roots); } + public List getCommentsForBlock(UUID blockId) { + List roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId); + return withRepliesAndMentions(roots); + } + + @Transactional + public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content, + List mentionedUserIds, AppUser author) { + DocumentComment comment = DocumentComment.builder() + .documentId(documentId) + .blockId(blockId) + .content(content) + .authorId(author.getId()) + .authorName(resolveAuthorName(author)) + .build(); + saveMentions(comment, mentionedUserIds); + DocumentComment saved = commentRepository.save(comment); + withMentionDTOs(saved); + notificationService.notifyMentions(mentionedUserIds, saved); + return saved; + } + @Transactional public DocumentComment postComment(UUID documentId, UUID annotationId, String content, List mentionedUserIds, AppUser author) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java new file mode 100644 index 00000000..2aff91bb --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -0,0 +1,140 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; +import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.repository.AnnotationRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TranscriptionService { + + private static final String TRANSCRIPTION_COLOR = "#00C7B1"; + private static final int MAX_TEXT_LENGTH = 10_000; + + private final TranscriptionBlockRepository blockRepository; + private final TranscriptionBlockVersionRepository versionRepository; + private final AnnotationRepository annotationRepository; + private final AnnotationService annotationService; + private final DocumentService documentService; + + public List listBlocks(UUID documentId) { + return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); + } + + public TranscriptionBlock getBlock(UUID documentId, UUID blockId) { + return blockRepository.findByIdAndDocumentId(blockId, documentId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, + "Transcription block not found: " + blockId)); + } + + @Transactional + public TranscriptionBlock createBlock(UUID documentId, CreateTranscriptionBlockDTO dto, UUID userId) { + Document doc = documentService.getDocumentById(documentId); + + CreateAnnotationDTO annotationDTO = new CreateAnnotationDTO( + dto.getPageNumber(), dto.getX(), dto.getY(), + dto.getWidth(), dto.getHeight(), TRANSCRIPTION_COLOR); + DocumentAnnotation annotation = annotationService.createAnnotation( + documentId, annotationDTO, userId, doc.getFileHash()); + + int nextOrder = blockRepository.countByDocumentId(documentId); + String text = sanitizeText(dto.getText()); + + TranscriptionBlock block = TranscriptionBlock.builder() + .annotationId(annotation.getId()) + .documentId(documentId) + .text(text) + .label(dto.getLabel()) + .sortOrder(nextOrder) + .createdBy(userId) + .updatedBy(userId) + .build(); + + TranscriptionBlock saved = blockRepository.save(block); + saveVersion(saved, userId); + log.info("Created transcription block {} for document {}", saved.getId(), documentId); + return saved; + } + + @Transactional + public TranscriptionBlock updateBlock(UUID documentId, UUID blockId, + UpdateTranscriptionBlockDTO dto, UUID userId) { + TranscriptionBlock block = getBlock(documentId, blockId); + + String text = sanitizeText(dto.getText()); + block.setText(text); + if (dto.getLabel() != null) { + block.setLabel(dto.getLabel()); + } + block.setUpdatedBy(userId); + + TranscriptionBlock saved = blockRepository.save(block); + saveVersion(saved, userId); + return saved; + } + + @Transactional + public void deleteBlock(UUID documentId, UUID blockId) { + TranscriptionBlock block = getBlock(documentId, blockId); + UUID annotationId = block.getAnnotationId(); + + // Block is the aggregate root — delete block first (cascades to versions + comments), + // then delete the dependent annotation directly (no ownership check needed) + blockRepository.delete(block); + blockRepository.flush(); + annotationRepository.deleteById(annotationId); + log.info("Deleted transcription block {} and annotation {} for document {}", + blockId, annotationId, documentId); + } + + @Transactional + public void reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) { + List blockIds = dto.getBlockIds(); + for (int i = 0; i < blockIds.size(); i++) { + TranscriptionBlock block = getBlock(documentId, blockIds.get(i)); + block.setSortOrder(i); + blockRepository.save(block); + } + } + + public List getBlockHistory(UUID documentId, UUID blockId) { + getBlock(documentId, blockId); + return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId); + } + + private void saveVersion(TranscriptionBlock block, UUID userId) { + TranscriptionBlockVersion version = TranscriptionBlockVersion.builder() + .blockId(block.getId()) + .text(block.getText()) + .changedBy(userId) + .build(); + versionRepository.save(version); + } + + String sanitizeText(String text) { + if (text == null) return ""; + if (text.length() > MAX_TEXT_LENGTH) { + text = text.substring(0, MAX_TEXT_LENGTH); + } + return text; + } +} diff --git a/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql b/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql new file mode 100644 index 00000000..94fc30de --- /dev/null +++ b/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql @@ -0,0 +1,16 @@ +CREATE TABLE transcription_blocks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE RESTRICT, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + text TEXT NOT NULL DEFAULT '' CHECK (length(text) <= 10000), + label VARCHAR(200), + sort_order INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 0, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + updated_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_tb_document_sort ON transcription_blocks(document_id, sort_order); +CREATE INDEX idx_tb_annotation ON transcription_blocks(annotation_id); diff --git a/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql b/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql new file mode 100644 index 00000000..2c03ab2c --- /dev/null +++ b/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql @@ -0,0 +1,9 @@ +CREATE TABLE transcription_block_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE, + text TEXT NOT NULL, + changed_by UUID REFERENCES users(id) ON DELETE SET NULL, + changed_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_tbv_block ON transcription_block_versions(block_id, changed_at DESC); diff --git a/backend/src/main/resources/db/migration/V20__add_block_id_to_comments.sql b/backend/src/main/resources/db/migration/V20__add_block_id_to_comments.sql new file mode 100644 index 00000000..025091ec --- /dev/null +++ b/backend/src/main/resources/db/migration/V20__add_block_id_to_comments.sql @@ -0,0 +1,4 @@ +ALTER TABLE document_comments + ADD COLUMN block_id UUID REFERENCES transcription_blocks(id) ON DELETE CASCADE; + +CREATE INDEX idx_dc_block ON document_comments(block_id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java index d9c2f31d..a556e676 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java @@ -279,4 +279,30 @@ class CommentControllerTest { .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } + + // ─── Block comment endpoints ───────────────────────────────────────────── + + @Test + @WithMockUser + void getBlockComments_returns200() throws Exception { + UUID blockId = UUID.randomUUID(); + when(commentService.getCommentsForBlock(blockId)).thenReturn(List.of()); + + mockMvc.perform(get("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void postBlockComment_returns201() throws Exception { + UUID blockId = UUID.randomUUID(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); + when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.blockId").value(blockId.toString())); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java new file mode 100644 index 00000000..a891413e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java @@ -0,0 +1,359 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.TranscriptionService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(TranscriptionBlockController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class TranscriptionBlockControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean TranscriptionService transcriptionService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final UUID DOC_ID = UUID.randomUUID(); + private static final UUID BLOCK_ID = UUID.randomUUID(); + private static final String URL_BASE = "/api/documents/" + DOC_ID + "/transcription-blocks"; + private static final String URL_BLOCK = URL_BASE + "/" + BLOCK_ID; + private static final String URL_REORDER = URL_BASE + "/reorder"; + private static final String URL_HISTORY = URL_BLOCK + "/history"; + + private static final String CREATE_JSON = + "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"Liebe Mutter,\"}"; + private static final String UPDATE_JSON = + "{\"text\":\"Neue Fassung\",\"label\":\"Anrede\"}"; + private static final String REORDER_JSON = + "{\"blockIds\":[\"" + UUID.randomUUID() + "\",\"" + UUID.randomUUID() + "\"]}"; + + private AppUser mockUser() { + return AppUser.builder().id(UUID.randomUUID()).username("user").build(); + } + + private TranscriptionBlock sampleBlock() { + return TranscriptionBlock.builder() + .id(BLOCK_ID).documentId(DOC_ID) + .annotationId(UUID.randomUUID()) + .text("Liebe Mutter,").sortOrder(0).build(); + } + + // ─── GET /api/documents/{id}/transcription-blocks ──────────────────────── + + @Test + void listBlocks_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void listBlocks_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void listBlocks_returns200_withBlocks_whenAuthorised() throws Exception { + TranscriptionBlock b = sampleBlock(); + when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(b)); + + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].text").value("Liebe Mutter,")); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void listBlocks_returns200_withEmptyArray_whenNoBlocksExist() throws Exception { + when(transcriptionService.listBlocks(any())).thenReturn(List.of()); + + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + // ─── GET /api/documents/{id}/transcription-blocks/{blockId} ───────────── + + @Test + void getBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getBlock_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlock_returns200_withBlockData_whenFound() throws Exception { + when(transcriptionService.getBlock(DOC_ID, BLOCK_ID)).thenReturn(sampleBlock()); + + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(BLOCK_ID.toString())) + .andExpect(jsonPath("$.text").value("Liebe Mutter,")) + .andExpect(jsonPath("$.sortOrder").value(0)); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlock_returns404_whenBlockDoesNotExist() throws Exception { + when(transcriptionService.getBlock(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TRANSCRIPTION_BLOCK_NOT_FOUND")); + } + + // ─── POST /api/documents/{id}/transcription-blocks ─────────────────────── + + @Test + void createBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void createBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception { + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock()); + + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.text").value("Liebe Mutter,")) + .andExpect(jsonPath("$.documentId").value(DOC_ID.toString())); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception { + when(userService.findByUsername(any())).thenReturn(null); + + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + // ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ───────────── + + @Test + void updateBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception { + TranscriptionBlock updated = sampleBlock(); + updated.setText("Neue Fassung"); + updated.setLabel("Anrede"); + + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any())) + .thenReturn(updated); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.text").value("Neue Fassung")) + .andExpect(jsonPath("$.label").value("Anrede")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns404_whenBlockDoesNotExist() throws Exception { + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.updateBlock(any(), any(), any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception { + when(userService.findByUsername(any())).thenReturn(null); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + // ─── DELETE /api/documents/{id}/transcription-blocks/{blockId} ─────────── + + @Test + void deleteBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void deleteBlock_returns204_whenAuthorised() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isNoContent()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void deleteBlock_returns404_whenBlockDoesNotExist() throws Exception { + org.mockito.Mockito.doThrow( + DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")) + .when(transcriptionService).deleteBlock(any(), any()); + + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isNotFound()); + } + + // ─── PUT /api/documents/{id}/transcription-blocks/reorder ──────────────── + + @Test + void reorderBlocks_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception { + when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock())); + + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + // ─── GET /api/documents/{id}/transcription-blocks/{blockId}/history ────── + + @Test + void getBlockHistory_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getBlockHistory_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns200_withVersionList_whenAuthorised() throws Exception { + TranscriptionBlockVersion v = TranscriptionBlockVersion.builder() + .id(UUID.randomUUID()).blockId(BLOCK_ID).text("v1").build(); + when(transcriptionService.getBlockHistory(DOC_ID, BLOCK_ID)).thenReturn(List.of(v)); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].text").value("v1")) + .andExpect(jsonPath("$[0].blockId").value(BLOCK_ID.toString())); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns404_whenBlockDoesNotExist() throws Exception { + when(transcriptionService.getBlockHistory(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns200_withEmptyList_whenNoVersionsExist() throws Exception { + when(transcriptionService.getBlockHistory(any(), any())).thenReturn(List.of()); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java new file mode 100644 index 00000000..c948ceed --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java @@ -0,0 +1,193 @@ +package org.raddatz.familienarchiv.repository; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TranscriptionBlockRepositoryTest { + + @Autowired TranscriptionBlockRepository blockRepository; + @Autowired TranscriptionBlockVersionRepository versionRepository; + @Autowired DocumentRepository documentRepository; + @Autowired AnnotationRepository annotationRepository; + @Autowired EntityManager em; + + private UUID documentId; + private UUID annotationId; + + @BeforeEach + void setUp() { + Document doc = documentRepository.save(Document.builder() + .title("Testbrief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .build()); + documentId = doc.getId(); + + DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder() + .documentId(documentId) + .pageNumber(1) + .x(0.1).y(0.2).width(0.3).height(0.4) + .color("#00C7B1") + .build()); + annotationId = annotation.getId(); + } + + // ─── findByDocumentIdOrderBySortOrderAsc ───────────────────────────────── + + @Test + void findByDocumentIdOrderBySortOrderAsc_returnsBlocksInSortOrder() { + blockRepository.save(block("Block B", 1)); + blockRepository.save(block("Block A", 0)); + blockRepository.save(block("Block C", 2)); + + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); + + assertThat(result).hasSize(3); + assertThat(result.get(0).getText()).isEqualTo("Block A"); + assertThat(result.get(1).getText()).isEqualTo("Block B"); + assertThat(result.get(2).getText()).isEqualTo("Block C"); + } + + @Test + void findByDocumentIdOrderBySortOrderAsc_returnsEmptyList_whenNoBlocksForDocument() { + UUID otherId = UUID.randomUUID(); + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(otherId); + assertThat(result).isEmpty(); + } + + @Test + void findByDocumentIdOrderBySortOrderAsc_doesNotReturnBlocksFromOtherDocument() { + blockRepository.save(block("My block", 0)); + + Document other = documentRepository.save(Document.builder() + .title("Anderer Brief").originalFilename("other.pdf").status(DocumentStatus.PLACEHOLDER).build()); + + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(other.getId()); + assertThat(result).isEmpty(); + } + + // ─── findByIdAndDocumentId ──────────────────────────────────────────────── + + @Test + void findByIdAndDocumentId_returnsBlock_whenBothMatch() { + TranscriptionBlock saved = blockRepository.save(block("Liebe Tante,", 0)); + + Optional found = blockRepository.findByIdAndDocumentId(saved.getId(), documentId); + + assertThat(found).isPresent(); + assertThat(found.get().getText()).isEqualTo("Liebe Tante,"); + } + + @Test + void findByIdAndDocumentId_returnsEmpty_whenDocumentIdDoesNotMatch() { + TranscriptionBlock saved = blockRepository.save(block("Liebe Tante,", 0)); + + Optional found = blockRepository.findByIdAndDocumentId(saved.getId(), UUID.randomUUID()); + + assertThat(found).isEmpty(); + } + + @Test + void findByIdAndDocumentId_returnsEmpty_whenBlockIdDoesNotExist() { + Optional found = blockRepository.findByIdAndDocumentId(UUID.randomUUID(), documentId); + assertThat(found).isEmpty(); + } + + // ─── countByDocumentId ──────────────────────────────────────────────────── + + @Test + void countByDocumentId_returnsZero_whenNoBlocksExist() { + assertThat(blockRepository.countByDocumentId(documentId)).isZero(); + } + + @Test + void countByDocumentId_returnsCorrectCount_afterMultipleSaves() { + blockRepository.save(block("Block 1", 0)); + blockRepository.save(block("Block 2", 1)); + blockRepository.save(block("Block 3", 2)); + + assertThat(blockRepository.countByDocumentId(documentId)).isEqualTo(3); + } + + @Test + void countByDocumentId_doesNotCountBlocksFromOtherDocument() { + blockRepository.save(block("Block 1", 0)); + + UUID otherId = UUID.randomUUID(); + assertThat(blockRepository.countByDocumentId(otherId)).isZero(); + } + + // ─── version (optimistic lock) ──────────────────────────────────────────── + + @Test + void version_startsAtZero_andIncrementsOnEachSave() { + TranscriptionBlock saved = blockRepository.saveAndFlush(block("initial", 0)); + assertThat(saved.getVersion()).isZero(); + + saved.setText("updated"); + TranscriptionBlock updated = blockRepository.saveAndFlush(saved); + assertThat(updated.getVersion()).isEqualTo(1); + } + + // ─── cascade: deleting a block cascades to its versions ────────────────── + + @Test + @Transactional + void delete_cascadesToVersions() { + TranscriptionBlock block = blockRepository.saveAndFlush(block("text", 0)); + versionRepository.saveAndFlush(TranscriptionBlockVersion.builder() + .blockId(block.getId()).text("text").build()); + + assertThat(versionRepository.findByBlockIdOrderByChangedAtDesc(block.getId())).hasSize(1); + + blockRepository.delete(block); + blockRepository.flush(); + em.clear(); + + assertThat(versionRepository.findByBlockIdOrderByChangedAtDesc(block.getId())).isEmpty(); + } + + // ─── cascade: deleting a document cascades to its blocks ───────────────── + + @Test + @Transactional + void deleteDocument_cascadesToBlocks() { + blockRepository.saveAndFlush(block("text", 0)); + assertThat(blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId)).hasSize(1); + + documentRepository.deleteById(documentId); + documentRepository.flush(); + em.clear(); + + assertThat(blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId)).isEmpty(); + } + + // ─── helper ────────────────────────────────────────────────────────────── + + private TranscriptionBlock block(String text, int sortOrder) { + return TranscriptionBlock.builder() + .annotationId(annotationId) + .documentId(documentId) + .text(text) + .sortOrder(sortOrder) + .build(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java index 8373f110..94851440 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -488,4 +488,40 @@ class CommentServiceTest { .build())) .build(); } + + // ─── Block-level comments ──────────────────────────────────────────────── + + @Test + void getCommentsForBlock_returnsRootCommentsFilteredByBlockId() { + UUID blockId = UUID.randomUUID(); + DocumentComment root = DocumentComment.builder() + .id(UUID.randomUUID()).blockId(blockId).content("Nice work").authorName("Felix") + .createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()).build(); + when(commentRepository.findByBlockIdAndParentIdIsNull(blockId)).thenReturn(List.of(root)); + when(commentRepository.findByParentId(root.getId())).thenReturn(List.of()); + + List result = commentService.getCommentsForBlock(blockId); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getContent()).isEqualTo("Nice work"); + } + + @Test + void postBlockComment_setsBlockIdOnComment() { + UUID documentId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("felix").firstName("Felix").lastName("Brandt").build(); + when(commentRepository.save(any())).thenAnswer(inv -> { + DocumentComment c = inv.getArgument(0); + c.setId(UUID.randomUUID()); + return c; + }); + + DocumentComment result = commentService.postBlockComment( + documentId, blockId, "Looks like Breslau", List.of(), author); + + assertThat(result.getBlockId()).isEqualTo(blockId); + assertThat(result.getDocumentId()).isEqualTo(documentId); + assertThat(result.getContent()).isEqualTo("Looks like Breslau"); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java new file mode 100644 index 00000000..ebe02d10 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -0,0 +1,246 @@ +package org.raddatz.familienarchiv.service; + +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 org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; +import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.repository.AnnotationRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@ExtendWith(MockitoExtension.class) +class TranscriptionServiceTest { + + @Mock TranscriptionBlockRepository blockRepository; + @Mock TranscriptionBlockVersionRepository versionRepository; + @Mock AnnotationRepository annotationRepository; + @Mock AnnotationService annotationService; + @Mock DocumentService documentService; + @InjectMocks TranscriptionService transcriptionService; + + // ─── getBlock ──────────────────────────────────────────────────────────────── + + @Test + void getBlock_throwsNotFound_whenBlockDoesNotExist() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> transcriptionService.getBlock(docId, blockId)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + } + + @Test + void getBlock_returnsBlock_whenExists() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("hello").build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + + TranscriptionBlock result = transcriptionService.getBlock(docId, blockId); + + assertThat(result).isEqualTo(block); + } + + // ─── createBlock ───────────────────────────────────────────────────────────── + + @Test + void createBlock_createsAnnotationAndBlockAndVersion() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + + Document doc = Document.builder().id(docId).fileHash("hash123").build(); + when(documentService.getDocumentById(docId)).thenReturn(doc); + + DocumentAnnotation annotation = DocumentAnnotation.builder().id(annotId).build(); + when(annotationService.createAnnotation(eq(docId), any(CreateAnnotationDTO.class), eq(userId), eq("hash123"))) + .thenReturn(annotation); + + when(blockRepository.countByDocumentId(docId)).thenReturn(0); + when(blockRepository.save(any())).thenAnswer(inv -> { + TranscriptionBlock b = inv.getArgument(0); + b.setId(UUID.randomUUID()); + return b; + }); + + CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null); + + TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId); + + assertThat(result.getAnnotationId()).isEqualTo(annotId); + assertThat(result.getText()).isEqualTo("hello"); + assertThat(result.getSortOrder()).isZero(); + assertThat(result.getCreatedBy()).isEqualTo(userId); + verify(versionRepository).save(any(TranscriptionBlockVersion.class)); + } + + // ─── updateBlock ───────────────────────────────────────────────────────────── + + @Test + void updateBlock_updatesTextAndSavesVersion() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("old").build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null); + + TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId); + + assertThat(result.getText()).isEqualTo("new text"); + assertThat(result.getUpdatedBy()).isEqualTo(userId); + verify(versionRepository).save(any(TranscriptionBlockVersion.class)); + } + + @Test + void updateBlock_updatesLabel_whenProvided() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("text").label("old label").build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede"); + + TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID()); + + assertThat(result.getLabel()).isEqualTo("Anrede"); + } + + // ─── deleteBlock ───────────────────────────────────────────────────────────── + + @Test + void deleteBlock_deletesBlockAndAnnotation() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).annotationId(annotId).build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + + transcriptionService.deleteBlock(docId, blockId); + + verify(blockRepository).delete(block); + verify(blockRepository).flush(); + verify(annotationRepository).deleteById(annotId); + } + + @Test + void deleteBlock_throwsNotFound_whenBlockMissing() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> transcriptionService.deleteBlock(docId, blockId)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + } + + // ─── reorderBlocks ─────────────────────────────────────────────────────────── + + @Test + void reorderBlocks_updatesSortOrder() { + UUID docId = UUID.randomUUID(); + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + + TranscriptionBlock block1 = TranscriptionBlock.builder() + .id(id1).documentId(docId).sortOrder(0).build(); + TranscriptionBlock block2 = TranscriptionBlock.builder() + .id(id2).documentId(docId).sortOrder(1).build(); + + when(blockRepository.findByIdAndDocumentId(id2, docId)).thenReturn(Optional.of(block2)); + when(blockRepository.findByIdAndDocumentId(id1, docId)).thenReturn(Optional.of(block1)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + ReorderTranscriptionBlocksDTO dto = new ReorderTranscriptionBlocksDTO(List.of(id2, id1)); + + transcriptionService.reorderBlocks(docId, dto); + + assertThat(block2.getSortOrder()).isZero(); + assertThat(block1.getSortOrder()).isEqualTo(1); + } + + // ─── getBlockHistory ───────────────────────────────────────────────────────── + + @Test + void getBlockHistory_returnsVersionsForBlock() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + + TranscriptionBlockVersion v = TranscriptionBlockVersion.builder() + .id(UUID.randomUUID()).blockId(blockId).text("ver1").build(); + when(versionRepository.findByBlockIdOrderByChangedAtDesc(blockId)).thenReturn(List.of(v)); + + List result = transcriptionService.getBlockHistory(docId, blockId); + + assertThat(result).containsExactly(v); + } + + // ─── sanitizeText ──────────────────────────────────────────────────────────── + + @Test + void sanitizeText_returnsEmptyString_forNull() { + assertThat(transcriptionService.sanitizeText(null)).isEmpty(); + } + + @Test + void sanitizeText_truncatesAtMaxLength() { + String longText = "a".repeat(15_000); + String result = transcriptionService.sanitizeText(longText); + assertThat(result).hasSize(10_000); + } + + @Test + void sanitizeText_preservesPlainText() { + assertThat(transcriptionService.sanitizeText("Liebe Mutter,")).isEqualTo("Liebe Mutter,"); + } + + // ─── listBlocks ────────────────────────────────────────────────────────────── + + @Test + void listBlocks_returnsBlocksOrderedBySortOrder() { + UUID docId = UUID.randomUUID(); + TranscriptionBlock b = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).sortOrder(0).build(); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of(b)); + + assertThat(transcriptionService.listBlocks(docId)).containsExactly(b); + } +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 852473db..4a03d881 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -17,6 +17,7 @@ bun.lockb /src/lib/generated/ /src/lib/paraglide/ /src/lib/paraglide_bak*/ +/src/paraglide/ # Test artifacts /test-results/ diff --git a/frontend/e2e/annotations.spec.ts b/frontend/e2e/annotations.spec.ts new file mode 100644 index 00000000..e4b0bed5 --- /dev/null +++ b/frontend/e2e/annotations.spec.ts @@ -0,0 +1,272 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E tests for the annotation overlay and transcribe-mode UI — issue #176. + * + * Strategy: + * - Transcription blocks are seeded via API in beforeAll — no canvas drawing in CI. + * - Browser tests verify transcribe-mode toggling, annotation overlay rendering, + * the visibility toggle, and scroll-sync between annotations and blocks. + */ + +let docHref: string; +let docId: string; +let annotAId: string; +let annotBId: string; +let blockAId: string; + +test.describe('Annotation overlay and transcribe mode', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + // 1. Create a document and upload a PDF so the annotation layer is active. + const createRes = await request.post('/api/documents', { + multipart: { title: 'E2E Annotation Test', documentDate: '1945-05-08' } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + const uploadRes = await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); + + // 2. Create two transcription blocks (each brings its own annotation). + const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.1, + text: 'Erste Zeile.', + label: 'Anrede' + } + }); + if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); + const blockA = await blockARes.json(); + blockAId = blockA.id; + annotAId = blockA.annotationId; + + const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.35, + width: 0.3, + height: 0.1, + text: 'Zweite Zeile.', + label: null + } + }); + if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); + const blockB = await blockBRes.json(); + annotBId = blockB.annotationId; + }); + + /** + * Navigate to the document, enter transcribe mode, and wait until the PDF + * has fully rendered (page counter appears) and the annotation rect is visible. + * Centralises the timing gate used by multiple tests. + */ + async function openTranscribeMode(page: Page, annotationId: string) { + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + // Wait for the PDF to finish loading — the page counter only renders when totalPages > 0 + await page.locator('.tabular-nums').waitFor({ timeout: 15_000 }); + // Wait for annotation rect (annotations API) and at least one block textarea (blocks API) + // to be ready — these are two independent fetches. + await Promise.all([ + page.locator(`[data-testid="annotation-${annotationId}"]`).waitFor({ timeout: 10_000 }), + page.getByRole('textbox').first().waitFor({ timeout: 10_000 }) + ]); + } + + // ─── Transcribe mode toggle ──────────────────────────────────────────────── + + test('Transkribieren button is visible on a PDF document', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-btn.png' }); + }); + + test('clicking Transkribieren enters transcribe mode and shows the Fertig button', async ({ + page + }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Transkribieren' })).not.toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-mode-active.png' }); + }); + + test('clicking Fertig exits transcribe mode and restores the Transkribieren button', async ({ + page + }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + + await page.getByRole('button', { name: 'Fertig' }).click(); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Fertig' })).not.toBeVisible(); + }); + + test('pressing Escape exits transcribe mode', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + }); + + // ─── Annotation overlay rendering ───────────────────────────────────────── + + test('annotation rects are rendered on the PDF after entering transcribe mode', async ({ + page + }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-rects-rendered.png' }); + }); + + test('numbered badges appear on annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + const annotA = page.locator(`[data-testid="annotation-${annotAId}"]`); + await expect(annotA.locator('div', { hasText: '1' })).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-numbered-badges.png' }); + }); + + // ─── Annotation visibility toggle ───────────────────────────────────────── + + test('annotation visibility toggle button appears when annotations exist', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await expect(page.getByRole('button', { name: 'Annotierungen verbergen' })).toBeVisible(); + }); + + test('clicking the visibility toggle hides annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.getByRole('button', { name: 'Annotierungen verbergen' }).click(); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Annotierungen anzeigen' })).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-hidden.png' }); + }); + + test('clicking the visibility toggle again restores annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.getByRole('button', { name: 'Annotierungen verbergen' }).click(); + await page.getByRole('button', { name: 'Annotierungen anzeigen' }).click(); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible(); + }); + + // ─── Scroll-sync: annotation → block ────────────────────────────────────── + + test('clicking an annotation rect scrolls the matching block into view in the right panel', async ({ + page + }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.locator(`[data-testid="annotation-${annotAId}"]`).click(); + + await expect(page.locator(`[data-block-id="${blockAId}"]`)).toBeVisible({ timeout: 5_000 }); + + await page.screenshot({ path: 'test-results/e2e/annotation-click-scroll-sync.png' }); + }); + + test('clicking annotation B activates the corresponding block in the panel', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotBId); + + await page.locator(`[data-testid="annotation-${annotBId}"]`).click(); + + // Block B's annotation should become active (full opacity), A's should dim + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS('opacity', '1'); + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS( + 'opacity', + '0.3' + ); + }); + + // ─── Scroll-sync: block → annotation (dimming) ──────────────────────────── + + test('focusing a block dims all other annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + // Focus block A's textarea to set it as active + await page.getByRole('textbox').first().click(); + + // Non-active annotation (B) must be dimmed + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS( + 'opacity', + '0.3' + ); + + // Active annotation (A) must be at full opacity + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS('opacity', '1'); + + await page.screenshot({ path: 'test-results/e2e/annotation-dimming.png' }); + }); + + // ─── Accessibility ───────────────────────────────────────────────────────── + + test('transcribe mode passes axe accessibility check', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/frontend/e2e/transcription.spec.ts b/frontend/e2e/transcription.spec.ts new file mode 100644 index 00000000..8b682905 --- /dev/null +++ b/frontend/e2e/transcription.spec.ts @@ -0,0 +1,296 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E tests for the annotation-backed transcription system — issue #176. + * + * Strategy: + * - Transcription blocks are created via API in beforeAll (no need to draw on canvas in CI). + * - Browser tests verify rendering, editing, auto-save feedback, reordering, deletion, and a11y. + */ + +let docHref: string; +let docId: string; + +test.describe('Transcription panel', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + // 1. Create a document with a PDF so the Transkription tab is meaningful. + const createRes = await request.post('/api/documents', { + multipart: { + title: 'E2E Transkription Test', + documentDate: '1945-05-08' + } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + + // 2. Create a document_annotation so we can attach blocks to it. + const annotARes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.1, color: '#00C7B1' } + }); + if (!annotARes.ok()) throw new Error(`Create annotation A failed: ${annotARes.status()}`); + const annotA = await annotARes.json(); + + const annotBRes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.3, width: 0.2, height: 0.1, color: '#00C7B1' } + }); + if (!annotBRes.ok()) throw new Error(`Create annotation B failed: ${annotBRes.status()}`); + const annotB = await annotBRes.json(); + + // 3. Create two transcription blocks via API. + const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: annotA.x, + y: annotA.y, + width: annotA.width, + height: annotA.height, + text: 'Liebe Mutter,', + label: 'Anrede' + } + }); + if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); + await blockARes.json(); + + const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: annotB.x, + y: annotB.y, + width: annotB.width, + height: annotB.height, + text: 'ich schreibe dir aus Breslau.', + label: null + } + }); + if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); + await blockBRes.json(); + }); + + // ─── Tab visibility ──────────────────────────────────────────────────────── + + test('Transkription tab is visible in the bottom panel tab bar', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/transcription-tab-visible.png' }); + }); + + // ─── Block rendering ────────────────────────────────────────────────────── + + test('blocks are rendered in sort order with correct text and label', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText('Liebe Mutter,')).toBeVisible(); + await expect(page.getByText('ich schreibe dir aus Breslau.')).toBeVisible(); + // Label for block A + await expect(page.getByText('Anrede')).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/transcription-blocks-rendered.png' }); + }); + + test('block numbers are rendered in turquoise badge', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + // Block 1 and 2 badges must be visible + await expect(page.getByText('1').first()).toBeVisible(); + await expect(page.getByText('2').first()).toBeVisible(); + }); + + test('next-block CTA shows Block 3 hint after two blocks', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText(/Block 3/)).toBeVisible(); + }); + + // ─── Text editing & auto-save feedback ──────────────────────────────────── + + test('editing a block shows "Speichere..." then "Gespeichert" indicator', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const firstTextarea = page.getByRole('textbox').first(); + await firstTextarea.click(); + await firstTextarea.fill('Liebe Mutter, ich bin wohlauf.'); + + // "Speichere..." should appear (debounce triggers after 1.5s) + await expect(page.getByText(/Speichere\.\.\./)).toBeVisible({ timeout: 5000 }); + // After save completes, "Gespeichert ✓" appears + await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 }); + + await page.screenshot({ path: 'test-results/e2e/transcription-autosave.png' }); + }); + + test('edited text persists after page reload', async ({ page }) => { + test.setTimeout(40_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const firstTextarea = page.getByRole('textbox').first(); + await firstTextarea.fill('Persistierter Text'); + + // Wait for auto-save to complete + await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 }); + + // Reload + await page.reload(); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + + await expect(page.getByText('Persistierter Text')).toBeVisible(); + }); + + // ─── Block reordering ───────────────────────────────────────────────────── + + test('move-up button is disabled on the first block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const upButtons = page.getByRole('button', { name: 'Nach oben' }); + await expect(upButtons.first()).toBeDisabled(); + }); + + test('move-down button is disabled on the last block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const downButtons = page.getByRole('button', { name: 'Nach unten' }); + await expect(downButtons.last()).toBeDisabled(); + }); + + test('clicking move-down on the first block swaps block order', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const textareas = page.getByRole('textbox'); + const before = await textareas.first().inputValue(); + + const downButtons = page.getByRole('button', { name: 'Nach unten' }); + await downButtons.first().click(); + + // After reorder, the block that was second should now appear first + const after = await textareas.first().inputValue(); + expect(after).not.toBe(before); + + await page.screenshot({ path: 'test-results/e2e/transcription-reorder.png' }); + }); + + // ─── Block deletion ─────────────────────────────────────────────────────── + + test('cancelling delete confirmation keeps the block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + // Dismiss the confirm dialog automatically + page.once('dialog', (dialog) => dialog.dismiss()); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + // Block should still be present + await expect(page.getByRole('textbox').first()).toBeVisible(); + }); + + // ─── Comment thread ─────────────────────────────────────────────────────── + + test('clicking Kommentieren button opens comment compose in the block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await page.getByText('Kommentieren').first().click(); + + await expect(page.getByPlaceholder(/Kommentar/)).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/transcription-comment-open.png' }); + }); + + // ─── Accessibility ──────────────────────────────────────────────────────── + + test('transcription panel passes axe accessibility check', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toHaveLength(0); + }); + + // ─── Empty state ────────────────────────────────────────────────────────── + + test('shows empty state when document has no transcription blocks', async ({ page, request }) => { + test.setTimeout(30_000); + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + const emptyDocRes = await request.post('/api/documents', { + multipart: { title: 'E2E Empty Transcription Test' } + }); + if (!emptyDocRes.ok()) throw new Error(`Create empty doc failed: ${emptyDocRes.status()}`); + const emptyDoc = await emptyDocRes.json(); + + await page.goto(`${baseURL}/documents/${emptyDoc.id}`); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText(/Markiere einen Bereich/)).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/transcription-empty-state.png' }); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1b395dfb..b1d59d3c 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -12,6 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( includeIgnoreFile(gitignorePath), + { ignores: ['src/paraglide/**'] }, js.configs.recommended, ...ts.configs.recommended, ...svelte.configs.recommended, diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 53fd4114..860891f4 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -315,10 +315,14 @@ "comp_expandable_show_less": "Weniger anzeigen", "error_comment_not_found": "Der Kommentar wurde nicht gefunden.", "comment_section_title": "Diskussion", - "comment_placeholder": "Kommentar schreiben…", + "comment_placeholder": "Kommentar schreiben… (@Name erwähnen · Enter senden)", "comment_btn_post": "Senden", "comment_btn_reply": "Antworten", - "comment_edited_label": "· bearbeitet", + "comment_edited_label": "(Bearbeitet)", + "comment_time_just_now": "gerade eben", + "comment_time_minutes": "vor {count} Minute(n)", + "comment_time_hours": "vor {count} Stunde(n)", + "comment_time_days": "vor {count} Tag(en)", "comment_panel_title": "Kommentare", "comment_panel_close": "Schließen", "doc_panel_tab_metadata": "Metadaten", @@ -423,5 +427,34 @@ "notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.", "notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}", "notification_read_state_read": "gelesen", - "notification_read_state_unread": "ungelesen" + "notification_read_state_unread": "ungelesen", + "error_transcription_block_not_found": "Der Transkriptionsblock wurde nicht gefunden.", + "error_transcription_block_conflict": "Der Block wurde zwischenzeitlich von jemand anderem geändert. Bitte laden Sie die Seite neu.", + "doc_details_toggle": "Details", + "doc_details_section_details": "Details", + "doc_details_section_persons": "Personen", + "doc_details_section_tags": "Schlagwörter", + "doc_details_field_date": "Datum", + "doc_details_field_sender": "Absender", + "doc_details_field_receivers": "Empfänger", + "doc_details_field_status": "Status", + "doc_details_no_persons": "Keine Personen zugeordnet", + "doc_details_no_tags": "Keine Schlagwörter zugeordnet", + "doc_details_more_receivers": "+{count} weitere", + "transcription_mode_label": "Transkribieren", + "transcription_mode_stop": "Fertig", + "transcription_block_placeholder": "Text hier eingeben...", + "transcription_block_save_saving": "Speichere...", + "transcription_block_save_saved": "Gespeichert", + "transcription_block_save_error": "Nicht gespeichert", + "transcription_block_save_retry": "Erneut versuchen", + "transcription_block_comment_btn": "Kommentieren", + "transcription_block_quote_hint": "Text markieren für Zitat", + "transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?", + "transcription_block_history_btn": "Verlauf", + "transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen", + "transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen", + "transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren", + "transcription_quote_stale": "Zitat aus älterer Version", + "transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3ad3cfdb..110d0c13 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -315,10 +315,14 @@ "comp_expandable_show_less": "Show less", "error_comment_not_found": "The comment could not be found.", "comment_section_title": "Discussion", - "comment_placeholder": "Write a comment…", + "comment_placeholder": "Write a comment… (@name to mention · Enter to send)", "comment_btn_post": "Send", "comment_btn_reply": "Reply", - "comment_edited_label": "· edited", + "comment_edited_label": "(Edited)", + "comment_time_just_now": "just now", + "comment_time_minutes": "{count} minute(s) ago", + "comment_time_hours": "{count} hour(s) ago", + "comment_time_days": "{count} day(s) ago", "comment_panel_title": "Comments", "comment_panel_close": "Close", "doc_panel_tab_metadata": "Metadata", @@ -423,5 +427,34 @@ "notification_empty_history_body": "Mentions and replies to your comments will appear here.", "notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}", "notification_read_state_read": "read", - "notification_read_state_unread": "unread" + "notification_read_state_unread": "unread", + "error_transcription_block_not_found": "Transcription block not found.", + "error_transcription_block_conflict": "This block was modified by someone else. Please reload the page.", + "doc_details_toggle": "Details", + "doc_details_section_details": "Details", + "doc_details_section_persons": "Persons", + "doc_details_section_tags": "Tags", + "doc_details_field_date": "Date", + "doc_details_field_sender": "Sender", + "doc_details_field_receivers": "Receivers", + "doc_details_field_status": "Status", + "doc_details_no_persons": "No persons assigned", + "doc_details_no_tags": "No tags assigned", + "doc_details_more_receivers": "+{count} more", + "transcription_mode_label": "Transcribe", + "transcription_mode_stop": "Done", + "transcription_block_placeholder": "Type text here...", + "transcription_block_save_saving": "Saving...", + "transcription_block_save_saved": "Saved", + "transcription_block_save_error": "Not saved", + "transcription_block_save_retry": "Retry", + "transcription_block_comment_btn": "Comment", + "transcription_block_quote_hint": "Select text to quote", + "transcription_block_delete_confirm": "Really delete this block and all its comments?", + "transcription_block_history_btn": "History", + "transcription_empty_cta": "Mark a region on the scan to start transcribing", + "transcription_next_block_cta": "Mark another passage on the scan to create block {number}", + "transcription_draw_tooltip": "Click and drag to mark a text region", + "transcription_quote_stale": "Quote from an older version", + "transcription_block_conflict": "This block was changed by someone else — please reload" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 8f8110c2..8ac74c35 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -315,10 +315,14 @@ "comp_expandable_show_less": "Mostrar menos", "error_comment_not_found": "El comentario no pudo encontrarse.", "comment_section_title": "Discusión", - "comment_placeholder": "Escribe un comentario…", + "comment_placeholder": "Escribe un comentario… (@nombre para mencionar · Enter para enviar)", "comment_btn_post": "Enviar", "comment_btn_reply": "Responder", - "comment_edited_label": "· editado", + "comment_edited_label": "(Editado)", + "comment_time_just_now": "justo ahora", + "comment_time_minutes": "hace {count} minuto(s)", + "comment_time_hours": "hace {count} hora(s)", + "comment_time_days": "hace {count} día(s)", "comment_panel_title": "Comentarios", "comment_panel_close": "Cerrar", "doc_panel_tab_metadata": "Metadatos", @@ -423,5 +427,34 @@ "notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.", "notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}", "notification_read_state_read": "leído", - "notification_read_state_unread": "no leído" + "notification_read_state_unread": "no leído", + "error_transcription_block_not_found": "Bloque de transcripción no encontrado.", + "error_transcription_block_conflict": "Este bloque fue modificado por otra persona. Por favor, recargue la página.", + "doc_details_toggle": "Detalles", + "doc_details_section_details": "Detalles", + "doc_details_section_persons": "Personas", + "doc_details_section_tags": "Etiquetas", + "doc_details_field_date": "Fecha", + "doc_details_field_sender": "Remitente", + "doc_details_field_receivers": "Destinatarios", + "doc_details_field_status": "Estado", + "doc_details_no_persons": "No hay personas asignadas", + "doc_details_no_tags": "No hay etiquetas asignadas", + "doc_details_more_receivers": "+{count} más", + "transcription_mode_label": "Transcribir", + "transcription_mode_stop": "Listo", + "transcription_block_placeholder": "Escriba el texto aquí...", + "transcription_block_save_saving": "Guardando...", + "transcription_block_save_saved": "Guardado", + "transcription_block_save_error": "No guardado", + "transcription_block_save_retry": "Reintentar", + "transcription_block_comment_btn": "Comentar", + "transcription_block_quote_hint": "Seleccione texto para citar", + "transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?", + "transcription_block_history_btn": "Historial", + "transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir", + "transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}", + "transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto", + "transcription_quote_stale": "Cita de una versión anterior", + "transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue" } diff --git a/frontend/src/lib/components/AnnotateHintStrip.svelte b/frontend/src/lib/components/AnnotateHintStrip.svelte deleted file mode 100644 index e3ea7ab6..00000000 --- a/frontend/src/lib/components/AnnotateHintStrip.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - -{#if annotateMode} - -{/if} diff --git a/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts b/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts deleted file mode 100644 index 80f72c13..00000000 --- a/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import AnnotateHintStrip from './AnnotateHintStrip.svelte'; - -afterEach(cleanup); - -describe('AnnotateHintStrip', () => { - it('is absent from the DOM when annotateMode is false', async () => { - render(AnnotateHintStrip, { annotateMode: false }); - const strip = page.getByTestId('annotate-hint-strip'); - await expect.element(strip).not.toBeInTheDocument(); - }); - - it('is present in the DOM when annotateMode is true', async () => { - render(AnnotateHintStrip, { annotateMode: true }); - const strip = page.getByTestId('annotate-hint-strip'); - await expect.element(strip).toBeInTheDocument(); - }); - - it('has hidden md:flex class to hide below 768px', async () => { - render(AnnotateHintStrip, { annotateMode: true }); - const strip = page.getByTestId('annotate-hint-strip'); - await expect.element(strip).toHaveClass('hidden'); - await expect.element(strip).toHaveClass('md:flex'); - }); -}); diff --git a/frontend/src/lib/components/AnnotationCommentPanel.svelte b/frontend/src/lib/components/AnnotationCommentPanel.svelte deleted file mode 100644 index d863e4a1..00000000 --- a/frontend/src/lib/components/AnnotationCommentPanel.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - -
- - - - -
-
-

- {m.comment_panel_title()} -

- -
-
- -
-
-
diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index 3d79b61b..2afe3b47 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -10,19 +10,19 @@ type DrawRect = { let { annotations = [], - canAnnotate, + canDraw, color, + blockNumbers = {}, + activeAnnotationId = null, onDraw, - onDelete, - commentCounts, onAnnotationClick }: { annotations: Annotation[]; - canAnnotate: boolean; + canDraw: boolean; color: string; - onDraw: (rect: { x: number; y: number; width: number; height: number }) => void; - onDelete: (id: string) => void; - commentCounts?: Record; + blockNumbers?: Record; + activeAnnotationId?: string | null; + onDraw: (rect: DrawRect) => void; onAnnotationClick?: (id: string) => void; } = $props(); @@ -45,7 +45,7 @@ function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: nu } function handlePointerDown(event: PointerEvent) { - if (!canAnnotate) return; + if (!canDraw) return; if ((event.target as HTMLElement).closest('[data-annotation]')) return; @@ -58,7 +58,7 @@ function handlePointerDown(event: PointerEvent) { } function handlePointerMove(event: PointerEvent) { - if (!canAnnotate || !drawStart) return; + if (!canDraw || !drawStart) return; const container = event.currentTarget as HTMLElement; const coords = getNormalizedCoords(event, container); @@ -72,7 +72,7 @@ function handlePointerMove(event: PointerEvent) { } function handlePointerUp(event: PointerEvent) { - if (!canAnnotate || !drawStart || !drawRect) return; + if (!canDraw || !drawStart || !drawRect) return; const container = event.currentTarget as HTMLElement; const coords = getNormalizedCoords(event, container); @@ -93,7 +93,7 @@ function handlePointerUp(event: PointerEvent) { let hoveredId = $state(null); const containerStyle = $derived( - `position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair; touch-action: none;' : ''}` + `position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canDraw ? ' cursor: crosshair; touch-action: none;' : ''}` ); @@ -110,9 +110,11 @@ const containerStyle = $derived( data-annotation role="button" tabindex="0" - aria-label="Kommentare anzeigen" + aria-label="Block anzeigen" onclick={() => onAnnotationClick?.(annotation.id)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); + }} onpointerenter={() => (hoveredId = annotation.id)} onpointerleave={() => (hoveredId = null)} style=" @@ -121,71 +123,36 @@ const containerStyle = $derived( top: {annotation.y * 100}%; width: {annotation.width * 100}%; height: {annotation.height * 100}%; - background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)}; - box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'}; + background-color: {hexToRgba(annotation.color, hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3)}; + box-shadow: {annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'}; + opacity: {activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1}; pointer-events: auto; - transition: background-color 0.15s ease, box-shadow 0.15s ease; - {onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''} + cursor: pointer; + transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease; " > - {#if canAnnotate} - - {/if} - {#if (commentCounts?.[annotation.id] ?? 0) > 0} -
- {commentCounts?.[annotation.id]} + {blockNumbers[annotation.id]}
{/if} diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts index e25f95c4..7a4b4e07 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts @@ -18,7 +18,7 @@ type Annotation = { createdAt: string; }; -function makeAnnotation(id = 'ann-1'): Annotation { +function makeAnnotation(id = 'ann-1', color = '#00C7B1'): Annotation { return { id, documentId: 'doc-1', @@ -27,7 +27,7 @@ function makeAnnotation(id = 'ann-1'): Annotation { y: 0.1, width: 0.3, height: 0.2, - color: '#ff0000', + color, createdAt: new Date().toISOString() }; } @@ -36,39 +36,77 @@ describe('AnnotationLayer', () => { it('renders a colored element for each annotation', async () => { render(AnnotationLayer, { annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')], - canAnnotate: false, - color: '#ff0000', - onDraw: () => {}, - onDelete: () => {} + canDraw: false, + color: '#00C7B1', + onDraw: () => {} }); await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument(); }); - it('shows a delete button for each annotation when canAnnotate is true', async () => { + it('has crosshair cursor when canDraw is true', async () => { render(AnnotationLayer, { - annotations: [makeAnnotation('ann-1')], - canAnnotate: true, - color: '#ff0000', - onDraw: () => {}, - onDelete: () => {} + annotations: [], + canDraw: true, + color: '#00C7B1', + onDraw: () => {} }); - await expect - .element(page.getByRole('button', { name: /annotation löschen/i })) - .toBeInTheDocument(); + const container = document.querySelector('[role="presentation"]')!; + expect(container.getAttribute('style')).toContain('cursor: crosshair'); }); - it('does not show delete buttons when canAnnotate is false', async () => { + it('does not have crosshair cursor when canDraw is false', async () => { render(AnnotationLayer, { - annotations: [makeAnnotation('ann-1')], - canAnnotate: false, - color: '#ff0000', - onDraw: () => {}, - onDelete: () => {} + annotations: [], + canDraw: false, + color: '#00C7B1', + onDraw: () => {} }); - expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull(); + const container = document.querySelector('[role="presentation"]')!; + expect(container.getAttribute('style')).not.toContain('cursor: crosshair'); + }); + + it('dims non-active annotations when activeAnnotationId is set', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')], + canDraw: false, + color: '#00C7B1', + activeAnnotationId: 'ann-1', + onDraw: () => {} + }); + + const active = page.getByTestId('annotation-ann-1').element(); + const dimmed = page.getByTestId('annotation-ann-2').element(); + expect(active.style.opacity).toBe('1'); + expect(dimmed.style.opacity).toBe('0.3'); + }); + + it('shows all annotations at full opacity when no activeAnnotationId', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')], + canDraw: false, + color: '#00C7B1', + onDraw: () => {} + }); + + const el1 = page.getByTestId('annotation-ann-1').element(); + const el2 = page.getByTestId('annotation-ann-2').element(); + expect(el1.style.opacity).toBe('1'); + expect(el2.style.opacity).toBe('1'); + }); + + it('does not show delete buttons (annotations owned by blocks)', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1')], + canDraw: true, + color: '#00C7B1', + onDraw: () => {} + }); + + await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); + expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull(); }); }); diff --git a/frontend/src/lib/components/AnnotationSidePanel.svelte b/frontend/src/lib/components/AnnotationSidePanel.svelte deleted file mode 100644 index 28d292e4..00000000 --- a/frontend/src/lib/components/AnnotationSidePanel.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - -
- -
- - {m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })} - - -
- - -
- {#if activeAnnotationId} - {#key activeAnnotationId} - - {/key} - {/if} -
-
diff --git a/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts b/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts deleted file mode 100644 index 84745470..00000000 --- a/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import AnnotationSidePanel from './AnnotationSidePanel.svelte'; - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [] - }) -); - -const baseProps = { - documentId: 'doc-1', - activeAnnotationPage: 1, - canComment: true, - currentUserId: 'user-1', - canAdmin: false, - onClose: vi.fn() -}; - -describe('AnnotationSidePanel – visibility', () => { - it('is hidden (translated off-screen) when activeAnnotationId is null', async () => { - render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: null }); - const panel = document.querySelector('[data-testid="annotation-side-panel"]'); - expect(panel?.classList.contains('translate-x-full')).toBe(true); - expect(panel?.classList.contains('translate-x-0')).toBe(false); - }); - - it('is visible when activeAnnotationId is set', async () => { - render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1' }); - const panel = document.querySelector('[data-testid="annotation-side-panel"]'); - expect(panel?.classList.contains('translate-x-0')).toBe(true); - expect(panel?.classList.contains('translate-x-full')).toBe(false); - }); -}); - -describe('AnnotationSidePanel – close button', () => { - it('calls onClose when the close button is clicked', async () => { - const onClose = vi.fn(); - render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1', onClose }); - await page.getByRole('button', { name: /schließen/i }).click(); - expect(onClose).toHaveBeenCalledOnce(); - }); -}); - -describe('AnnotationSidePanel – targetCommentId forwarding', () => { - it('renders CommentThread when annotation is active', async () => { - render(AnnotationSidePanel, { - ...baseProps, - activeAnnotationId: 'ann-1', - targetCommentId: 'comment-42' - }); - // CommentThread renders inside the panel when activeAnnotationId is set - const panel = document.querySelector('[data-testid="annotation-side-panel"]'); - expect(panel).not.toBeNull(); - expect(panel?.classList.contains('translate-x-0')).toBe(true); - }); - - it('does not render CommentThread when annotation is null', async () => { - render(AnnotationSidePanel, { - ...baseProps, - activeAnnotationId: null, - targetCommentId: 'comment-42' - }); - // Panel is hidden and no fetch should have been triggered for comments - const panel = document.querySelector('[data-testid="annotation-side-panel"]'); - expect(panel?.classList.contains('translate-x-full')).toBe(true); - }); -}); diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index b741c632..73858028 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -1,7 +1,7 @@ - -{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)} - {#if editingId === comment.id} -
- saveEdit(comment.id)} - /> -
- - -
-
- {:else} -
-
-
- {comment.authorName} - {timeAgo(comment.createdAt)} - {#if wasEdited(comment)} - - {m.comment_edited_label()} - {timeAgo(comment.updatedAt)} - - {/if} -
-

- - {@html renderBody(comment.content, comment.mentionDTOs ?? [])} -

-
- {#if canModify(comment)} -
- - -
- {/if} -
- {#if showReplyButton && canComment} -
- -
- {/if} - {/if} -{/snippet} - -
- {#if comments.length === 0} -
+{#if flatMessages.length > 0} +
+
-

{m.comment_empty_hint()}

+ {flatMessages.length} + {flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
- {/if} - {#each comments as thread, ti (thread.id)} -
0 ? 'border-t border-line pt-4' : ''}> - -
- {@render commentEntry(thread, thread.id, thread.replies.length === 0)} -
- - {#each thread.replies as reply, ri (reply.id)} -
- {@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)} -
- {/each} +
+ {#each flatMessages as msg (msg.id)} + {@const parsed = extractQuote(msg.content)} +
+
+ {getInitials(msg.authorName)} +
+
+
+ {msg.authorName} + {#if wasEdited(msg)} + {timeAgo(msg.updatedAt)} {m.comment_edited_label()} + {:else} + {timeAgo(msg.createdAt)} + {/if} +
+ {#if parsed.quote} +
+ “{parsed.quote}” +
+ {/if} - - {#if replyingTo === thread.id} -
- postReply(thread.id)} - /> -
- - + {#if editingId === msg.id} + +
Enter speichern · Esc abbrechen
+ {:else} + + +
{ if (isOwn(msg)) startEdit(msg); }}> +

+ + {@html renderBody(parsed.body, msg.mentionDTOs ?? [])} +

+ {#if isOwn(msg)} + + {/if} +
+ {/if}
- {/if} + {/each}
- {/each} +
+{/if} - - {#if canComment} -
0 ? 'border-t border-line pt-4' : ''}> -
- -
- -
-
-
- {/if} -
+{#if canComment && (showCompose || flatMessages.length > 0)} +
+ +
+{/if} diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte b/frontend/src/lib/components/DocumentBottomPanel.svelte deleted file mode 100644 index 209bf76e..00000000 --- a/frontend/src/lib/components/DocumentBottomPanel.svelte +++ /dev/null @@ -1,193 +0,0 @@ - - -
- - - - -
- -
- {#each tabs as tab (tab.id)} - - {/each} -
- - {#if open} - - {/if} -
- - - {#if open} -
- {#if activeTab === 'metadata'} - - {:else if activeTab === 'transcription'} - - {:else if activeTab === 'discussion'} - - {:else if activeTab === 'history'} - - {/if} -
- {/if} -
diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts b/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts deleted file mode 100644 index 975d36e6..00000000 --- a/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import DocumentBottomPanel from './DocumentBottomPanel.svelte'; -import type { Comment } from '$lib/types'; - -afterEach(cleanup); - -function makeComment(id: string): Comment { - return { - id, - authorId: 'user-1', - authorName: 'Alice', - content: 'Hello', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - replies: [] - }; -} - -const doc = { id: 'doc-1', title: 'Test' }; - -const baseProps = { - doc, - canComment: true, - currentUserId: 'user-1', - canAdmin: false, - height: 300, - activeTab: 'discussion' as const -}; - -describe('DocumentBottomPanel – discussion badge', () => { - it('always shows a badge on the Discussion tab', async () => { - render(DocumentBottomPanel, { ...baseProps, comments: [], open: true }); - await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument(); - await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0'); - }); - - it('shows the correct count when comments exist', async () => { - render(DocumentBottomPanel, { - ...baseProps, - comments: [makeComment('c-1'), makeComment('c-2')], - open: true - }); - await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2'); - }); -}); diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte b/frontend/src/lib/components/DocumentMetadataDrawer.svelte new file mode 100644 index 00000000..22e8b13a --- /dev/null +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte @@ -0,0 +1,146 @@ + + +{#snippet personCard(person: Person)} + + + {getFullName(person)} + +{/snippet} + +
+
+ +
+

+ {m.doc_details_section_details()} +

+
+
+
{m.doc_details_field_date()}
+
{formattedDate}
+
+
+
{m.form_label_location()}
+
{displayLocation}
+
+
+
{m.doc_details_field_status()}
+
{statusLabel}
+
+
+
+ + +
+

+ {m.doc_details_section_persons()} +

+ {#if hasPersons} +
+ {#if sender} +
+

+ {m.doc_details_field_sender()} +

+ {@render personCard(sender)} +
+ {/if} + {#if receivers.length > 0} +
+

+ {m.doc_details_field_receivers()} +

+
+ {#each displayedReceivers as receiver (receiver.id)} + {@render personCard(receiver)} + {/each} +
+ {#if hiddenReceiverCount > 0 && !showAllReceivers} + + {/if} +
+ {/if} +
+ {:else} +

{m.doc_details_no_persons()}

+ {/if} +
+ + +
+

+ {m.doc_details_section_tags()} +

+ {#if hasTags} +
+ {#each tags as tag (tag.id)} + + {tag.name} + + {/each} +
+ {:else} +

{m.doc_details_no_tags()}

+ {/if} +
+
+
diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts new file mode 100644 index 00000000..7265a9a9 --- /dev/null +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte'; + +afterEach(cleanup); + +const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' }; +const receivers = [ + { id: 'r1', firstName: 'Anna', lastName: 'Schmidt' }, + { id: 'r2', firstName: 'Hans', lastName: 'Weber' } +]; +const tags = [ + { id: 't1', name: 'Familienbrief' }, + { id: 't2', name: 'Kriegszeit' } +]; + +function renderDrawer(overrides: Record = {}) { + return render(DocumentMetadataDrawer, { + documentDate: '1942-03-15', + location: 'Berlin', + status: 'UPLOADED', + sender, + receivers, + tags, + ...overrides + }); +} + +// ─── Details column ────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — details column', () => { + it('renders formatted date', async () => { + renderDrawer(); + await expect.element(page.getByText('15. März 1942')).toBeInTheDocument(); + }); + + it('renders dash when date is null', async () => { + renderDrawer({ documentDate: null }); + const dds = page.getByText('—'); + await expect.element(dds.first()).toBeInTheDocument(); + }); + + it('renders location', async () => { + renderDrawer(); + await expect.element(page.getByText('Berlin')).toBeInTheDocument(); + }); + + it('renders dash when location is null', async () => { + renderDrawer({ location: null }); + const dashes = page.getByText('—'); + await expect.element(dashes.first()).toBeInTheDocument(); + }); + + it('renders translated status label', async () => { + renderDrawer(); + // "Hochgeladen" is the German translation of UPLOADED + await expect.element(page.getByText('Hochgeladen')).toBeInTheDocument(); + }); +}); + +// ─── Persons column ────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — persons column', () => { + it('renders sender name as link to person detail', async () => { + renderDrawer(); + const link = page.getByRole('link', { name: /Karl Müller/ }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute('href', '/persons/s1'); + }); + + it('renders receiver names as links', async () => { + renderDrawer(); + const anna = page.getByRole('link', { name: /Anna Schmidt/ }); + await expect.element(anna).toHaveAttribute('href', '/persons/r1'); + const hans = page.getByRole('link', { name: /Hans Weber/ }); + await expect.element(hans).toHaveAttribute('href', '/persons/r2'); + }); + + it('shows empty state when no sender and no receivers', async () => { + renderDrawer({ sender: null, receivers: [] }); + await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument(); + }); +}); + +// ─── Tags column ───────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — tags column', () => { + it('renders tag chips as links', async () => { + renderDrawer(); + const fb = page.getByRole('link', { name: 'Familienbrief' }); + await expect.element(fb).toBeInTheDocument(); + await expect.element(fb).toHaveAttribute('href', '/?tag=Familienbrief'); + }); + + it('shows empty state when no tags', async () => { + renderDrawer({ tags: [] }); + await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index 9c1bd419..7d610706 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -1,12 +1,14 @@ -{#snippet annotateBtn(mobile: boolean)} +{#snippet transcribeBtn(mobile: boolean)} {/snippet} -{#snippet annotateStopBtn(mobile: boolean)} +{#snippet transcribeStopBtn(mobile: boolean)} {/snippet} @@ -155,20 +175,41 @@ let mobileMenuOpen = $state(false); {/if} + + +
- {#if canAnnotate && isPdf && !annotateMode} - {@render annotateBtn(false)} + {#if canWrite && isPdf && !transcribeMode} + {@render transcribeBtn(false)} {/if} - {#if canAnnotate && isPdf && annotateMode} - {@render annotateStopBtn(false)} + {#if transcribeMode} + {@render transcribeStopBtn(false)} {/if} - {#if canWrite && !annotateMode} + {#if canWrite && !transcribeMode} {/if} - {#if doc.filePath && !annotateMode} + {#if doc.filePath && !transcribeMode} {@render downloadLink(false)} {/if} - {#if (canAnnotate && isPdf) || doc.filePath} + {#if (canWrite && isPdf) || doc.filePath}
- {#if canAnnotate && isPdf && !annotateMode} - {@render annotateBtn(true)} + {#if canWrite && isPdf && !transcribeMode} + {@render transcribeBtn(true)} {/if} {#if doc.filePath} @@ -231,6 +272,17 @@ let mobileMenuOpen = $state(false);
- - + + {#if detailsOpen} +
+ +
+ {/if}
diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index 0806d784..6266f975 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -9,15 +9,19 @@ type Doc = { fileHash?: string | null; }; +type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number }; + type Props = { doc: Doc; fileUrl: string; isLoading: boolean; error: string; - annotateMode: boolean; + transcribeMode?: boolean; + blockNumbers?: Record; + annotationReloadKey?: number; activeAnnotationId: string | null; - activeAnnotationPage: number | null; onAnnotationClick: (id: string) => void; + onTranscriptionDraw?: (rect: DrawRect) => void; }; let { @@ -25,10 +29,12 @@ let { fileUrl, isLoading, error, - annotateMode = $bindable(), + transcribeMode = false, + blockNumbers = {}, + annotationReloadKey = 0, activeAnnotationId = $bindable(), - activeAnnotationPage = $bindable(), - onAnnotationClick + onAnnotationClick, + onTranscriptionDraw }: Props = $props(); @@ -80,10 +86,12 @@ let { {:else if fileUrl} diff --git a/frontend/src/lib/components/MentionEditor.svelte b/frontend/src/lib/components/MentionEditor.svelte index a97c018f..52b1ac74 100644 --- a/frontend/src/lib/components/MentionEditor.svelte +++ b/frontend/src/lib/components/MentionEditor.svelte @@ -115,7 +115,8 @@ function closePopup() { } function handleKeydown(e: KeyboardEvent) { - if (e.ctrlKey && e.key === 'Enter') { + // Enter sends, Shift+Enter adds newline + if (e.key === 'Enter' && !e.shiftKey && query === null) { e.preventDefault(); onsubmit?.(); return; @@ -152,33 +153,6 @@ function handleKeydown(e: KeyboardEvent) { } } -async function handleAtButtonClick() { - if (!textarea) return; - const pos = textarea.selectionStart; - const before = value.slice(0, pos); - const after = value.slice(pos); - // Ensure @ is preceded by whitespace or is at the start - const needsSpace = before.length > 0 && !/\s$/.test(before); - const insertion = needsSpace ? ' @' : '@'; - value = before + insertion + after; - - await tick(); - if (!textarea) return; - const newPos = pos + insertion.length; - textarea.selectionStart = newPos; - textarea.selectionEnd = newPos; - textarea.focus(); - - // Trigger mention detection after inserting @ - const detected = detectMention(value, newPos); - if (detected !== null) { - mentionStart = newPos - 1; - query = detected; - highlightedIndex = 0; - scheduleSearch(detected); - } -} - onDestroy(() => clearTimeout(debounceTimer)); const popupOpen = $derived(query !== null); @@ -224,14 +198,4 @@ const popupOpen = $derived(query !== null); {/if}
{/if} - -
diff --git a/frontend/src/lib/components/PanelDiscussion.svelte b/frontend/src/lib/components/PanelDiscussion.svelte deleted file mode 100644 index 40d9af39..00000000 --- a/frontend/src/lib/components/PanelDiscussion.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - -
- -
diff --git a/frontend/src/lib/components/PanelHistory.svelte b/frontend/src/lib/components/PanelHistory.svelte deleted file mode 100644 index 2be6f7aa..00000000 --- a/frontend/src/lib/components/PanelHistory.svelte +++ /dev/null @@ -1,519 +0,0 @@ - - -
- {#if historyLoading} -

{m.history_loading()}

- {:else if !historyLoaded} - - {:else if versions.length === 0} -

{m.history_empty()}

- {:else} - -
- -
- - {#if compareMode} -
-
- - -
-
- - -
- -
- - - {#if diffLoading} -

{m.history_loading()}

- {:else if noDiff} -
- {m.history_diff_no_changes()} -
- {:else if diffEntries.length > 0} -
- {#each diffEntries as entry (entry.field)} -
- {entry.label} - {#if entry.kind === 'text'} -

- {#each entry.parts as part, partIdx (partIdx)} - {#if part.added} - {part.value} - {:else if part.removed} - {part.value} - {:else} - {part.value} - {/if} - {/each} -

- {:else if entry.kind === 'scalar'} -
- {entry.oldVal || '—'} - - {entry.newVal || '—'} -
- {:else if entry.kind === 'relation'} -
- {#each entry.removed as item (item)} - {item} - {/each} - {#each entry.added as item (item)} - {item} - {/each} -
- {/if} -
- {/each} -
- {/if} - {:else} - -
    - {#each versions as v, i (v.id)} -
  • - - - - {#if selectedVersionId === v.id} - {#if diffLoading} -

    {m.history_loading()}

    - {:else if noDiff} -
    - {m.history_diff_no_changes()} -
    - {:else if diffEntries.length > 0} -
    - {#each diffEntries as entry (entry.field)} -
    - {entry.label} - {#if entry.kind === 'text'} -

    - {#each entry.parts as part, partIdx (partIdx)} - {#if part.added} - {part.value} - {:else if part.removed} - {part.value} - {:else} - {part.value} - {/if} - {/each} -

    - {:else if entry.kind === 'scalar'} -
    - {entry.oldVal || '—'} - - {entry.newVal || '—'} -
    - {:else if entry.kind === 'relation'} -
    - {#each entry.removed as item (item)} - {item} - {/each} - {#each entry.added as item (item)} - {item} - {/each} -
    - {/if} -
    - {/each} -
    - {/if} - {/if} -
  • - {/each} -
- {/if} - {/if} -
diff --git a/frontend/src/lib/components/PanelMetadata.svelte b/frontend/src/lib/components/PanelMetadata.svelte deleted file mode 100644 index f8c2719c..00000000 --- a/frontend/src/lib/components/PanelMetadata.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - -
diff --git a/frontend/src/lib/components/PanelTranscription.svelte b/frontend/src/lib/components/PanelTranscription.svelte deleted file mode 100644 index d8bc79a8..00000000 --- a/frontend/src/lib/components/PanelTranscription.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -
-
- {#if !doc.summary && !doc.transcription} -

- {/if} - - {#if doc.summary} -
- - {m.doc_label_summary()} - -

{doc.summary}

-
- {/if} - - {#if doc.transcription} -
- - {m.form_label_transcription()} - -

- {doc.transcription} -

-
- {/if} -
-
diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 704a8a07..2eac4308 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -1,26 +1,31 @@ + +
+ + + {blockNumber} + + + +
+ + + + + + +
+ +
+ +
+ {#if label} + + {label} + + {/if} +
+ + + + + +
+
+ {#if !hasComments} + + {/if} +
+ +
+ + {#if saveState === 'saving'} + + {m.transcription_block_save_saving()} + + {:else if saveState === 'saved' || saveState === 'fading'} + + {m.transcription_block_save_saved()} + + {:else if saveState === 'error'} + + {m.transcription_block_save_error()} + + + + {/if} + + + +
+
+ + +
+ (commentCount = count)} + /> +
+
+
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts new file mode 100644 index 00000000..b439f327 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscriptionBlock from './TranscriptionBlock.svelte'; + +afterEach(cleanup); + +function renderBlock(overrides: Record = {}) { + return render(TranscriptionBlock, { + blockId: 'block-1', + documentId: 'doc-1', + blockNumber: 3, + text: 'Liebe Mutter,', + label: null, + active: false, + saveState: 'idle' as const, + canComment: true, + currentUserId: 'user-1', + onTextChange: vi.fn(), + onFocus: vi.fn(), + onDeleteClick: vi.fn(), + onRetry: vi.fn(), + ...overrides + }); +} + +// ─── Rendering ─────────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — rendering', () => { + it('renders block number in turquoise badge', async () => { + renderBlock(); + await expect.element(page.getByText('3')).toBeInTheDocument(); + }); + + it('renders text in textarea', async () => { + renderBlock(); + const textarea = page.getByRole('textbox'); + await expect.element(textarea).toHaveValue('Liebe Mutter,'); + }); + + it('renders optional label when provided', async () => { + renderBlock({ label: 'Anrede' }); + await expect.element(page.getByText('Anrede')).toBeInTheDocument(); + }); + + it('does not render label when null', async () => { + renderBlock({ label: null }); + const label = page.getByText('Anrede'); + await expect.element(label).not.toBeInTheDocument(); + }); +}); + +// ─── Save states ───────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — save states', () => { + it('shows nothing in idle state', async () => { + renderBlock({ saveState: 'idle' }); + const saving = page.getByText('Speichere...'); + await expect.element(saving).not.toBeInTheDocument(); + }); + + it('shows "Speichere..." in saving state', async () => { + renderBlock({ saveState: 'saving' }); + await expect.element(page.getByText('Speichere...')).toBeInTheDocument(); + }); + + it('shows "Gespeichert" in saved state', async () => { + renderBlock({ saveState: 'saved' }); + await expect.element(page.getByText(/Gespeichert/)).toBeInTheDocument(); + }); + + it('shows error with retry button in error state', async () => { + const onRetry = vi.fn(); + renderBlock({ saveState: 'error', onRetry }); + await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument(); + const retryBtn = page.getByText('Erneut versuchen'); + await expect.element(retryBtn).toBeInTheDocument(); + }); +}); + +// ─── Active state ──────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — active border', () => { + it('has turquoise left border when active', async () => { + renderBlock({ active: true }); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); + const block = document.querySelector('[data-block-id="block-1"]')!; + expect(block.className).toContain('border-turquoise'); + }); + + it('has error left border when save failed', async () => { + renderBlock({ saveState: 'error' }); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); + const block = document.querySelector('[data-block-id="block-1"]')!; + expect(block.className).toContain('border-error'); + }); +}); + +// ─── Interactions ──────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — interactions', () => { + it('calls onTextChange when typing in textarea', async () => { + const onTextChange = vi.fn(); + renderBlock({ onTextChange }); + const textarea = page.getByRole('textbox'); + await textarea.fill('Neue Zeile'); + expect(onTextChange).toHaveBeenCalled(); + }); + + it('calls onFocus when textarea is focused', async () => { + const onFocus = vi.fn(); + renderBlock({ onFocus }); + const textarea = page.getByRole('textbox'); + await textarea.click(); + expect(onFocus).toHaveBeenCalled(); + }); + + it('shows Kommentieren button when no comments exist', async () => { + renderBlock(); + const btn = page.getByText('Kommentieren'); + await expect.element(btn).toBeInTheDocument(); + }); +}); + +// ─── Reorder controls ──────────────────────────────────────────────────────── + +describe('TranscriptionBlock — reorder controls', () => { + it('shows a drag handle element', async () => { + renderBlock(); + const handle = document.querySelector('[data-drag-handle]'); + expect(handle).not.toBeNull(); + }); + + it('disables move-up button when isFirst', async () => { + renderBlock({ isFirst: true }); + const btn = page.getByRole('button', { name: 'Nach oben' }); + await expect.element(btn).toBeDisabled(); + }); + + it('disables move-down button when isLast', async () => { + renderBlock({ isLast: true }); + const btn = page.getByRole('button', { name: 'Nach unten' }); + await expect.element(btn).toBeDisabled(); + }); + + it('calls onMoveUp when up arrow clicked', async () => { + const onMoveUp = vi.fn(); + renderBlock({ onMoveUp, isFirst: false }); + const btn = page.getByRole('button', { name: 'Nach oben' }); + await btn.click(); + expect(onMoveUp).toHaveBeenCalled(); + }); + + it('calls onMoveDown when down arrow clicked', async () => { + const onMoveDown = vi.fn(); + renderBlock({ onMoveDown, isLast: false }); + const btn = page.getByRole('button', { name: 'Nach unten' }); + await btn.click(); + expect(onMoveDown).toHaveBeenCalled(); + }); +}); + +// ─── Delete confirmation ────────────────────────────────────────────────────── + +describe('TranscriptionBlock — delete confirmation', () => { + it('does not call onDeleteClick when user cancels confirm dialog', async () => { + const onDeleteClick = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderBlock({ onDeleteClick }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }); + await deleteBtn.click(); + + expect(onDeleteClick).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('calls onDeleteClick when user confirms deletion', async () => { + const onDeleteClick = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderBlock({ onDeleteClick }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }); + await deleteBtn.click(); + + expect(onDeleteClick).toHaveBeenCalledOnce(); + vi.restoreAllMocks(); + }); +}); + +// ─── Quote selection ───────────────────────────────────────────────────────── + +describe('TranscriptionBlock — quote selection', () => { + it('shows quote hint after text is selected in textarea', async () => { + renderBlock({ text: 'Breslau, den 12. August' }); + const textarea = page.getByRole('textbox'); + // Select all text via keyboard shortcut to trigger mouseup with selection + await textarea.click(); + await textarea.selectText(); + // Fire mouseup to trigger the selection handler + await textarea.dispatchEvent('mouseup'); + await expect.element(page.getByText(/Zitat/)).toBeInTheDocument(); + }); +}); + +// ─── Fading state ──────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — fading save state', () => { + it('shows Gespeichert text in fading state (opacity-0 fade-out)', async () => { + renderBlock({ saveState: 'fading' }); + const indicator = page.getByText(/Gespeichert/); + await expect.element(indicator).toBeInTheDocument(); + // The fading class sets opacity-0 + const el = document.querySelector('.opacity-0'); + expect(el).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte new file mode 100644 index 00000000..5d675a01 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -0,0 +1,331 @@ + + +
+ {#if hasBlocks} + +
+ {#each sortedBlocks as block, i (block.id)} + {#if dropTargetIdx === i} +
+ {/if} + +
handleGripDown(e, block.id)} + class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}" + style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''} + > + handleTextChange(block.id, text)} + onFocus={() => handleFocus(block.id)} + onDeleteClick={() => handleDelete(block.id)} + onRetry={() => handleRetry(block.id)} + onMoveUp={() => handleMoveUp(block.id)} + onMoveDown={() => handleMoveDown(block.id)} + isFirst={i === 0} + isLast={i === sortedBlocks.length - 1} + /> +
+ {/each} + + {#if dropTargetIdx === sortedBlocks.length} +
+ {/if} + + +
+ {m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })} +
+
+ {:else} +
+ + + +

+ {m.transcription_empty_cta()} +

+
+ {/if} +
diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts new file mode 100644 index 00000000..11a4d29b --- /dev/null +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscriptionEditView from './TranscriptionEditView.svelte'; + +afterEach(cleanup); + +const block1 = { + id: 'b1', + annotationId: 'a1', + documentId: 'doc-1', + text: 'Block eins', + label: null, + sortOrder: 0, + version: 0 +}; +const block2 = { + id: 'b2', + annotationId: 'a2', + documentId: 'doc-1', + text: 'Block zwei', + label: null, + sortOrder: 1, + version: 0 +}; + +function renderView(overrides: Record = {}) { + return render(TranscriptionEditView, { + documentId: 'doc-1', + blocks: [block1, block2], + canComment: true, + currentUserId: 'user-1', + onBlockFocus: vi.fn(), + onSaveBlock: vi.fn(), + onDeleteBlock: vi.fn(), + ...overrides + }); +} + +describe('TranscriptionEditView — rendering', () => { + it('renders blocks in sort order', async () => { + renderView(); + const textareas = page.getByRole('textbox').all(); + expect(textareas.length).toBeGreaterThanOrEqual(2); + }); + + it('shows next-block CTA after block list', async () => { + renderView(); + await expect.element(page.getByText(/Block 3/)).toBeInTheDocument(); + }); + + it('shows empty state when no blocks', async () => { + renderView({ blocks: [] }); + await expect.element(page.getByText(/Markiere einen Bereich/)).toBeInTheDocument(); + }); +}); + +describe('TranscriptionEditView — annotation sync', () => { + it('activates block matching activeAnnotationId', async () => { + renderView({ activeAnnotationId: 'a2' }); + // Block 2 (annotation a2) should have turquoise border + const block = document.querySelector('[data-block-id="b2"]')!; + expect(block.className).toContain('border-turquoise'); + }); + + it('does not activate any block when activeAnnotationId is null', async () => { + renderView({ activeAnnotationId: null }); + const block1 = document.querySelector('[data-block-id="b1"]')!; + const block2 = document.querySelector('[data-block-id="b2"]')!; + expect(block1.className).not.toContain('border-turquoise'); + expect(block2.className).not.toContain('border-turquoise'); + }); +}); + +describe('TranscriptionEditView — reorder', () => { + it('renders move-up button disabled on first block', async () => { + renderView(); + const upButtons = page.getByRole('button', { name: 'Nach oben' }).all(); + // First block's up button should be disabled + await expect.element(upButtons[0]).toBeDisabled(); + }); + + it('renders move-down button disabled on last block', async () => { + renderView(); + const downButtons = page.getByRole('button', { name: 'Nach unten' }).all(); + // Last block's down button should be disabled + await expect.element(downButtons[downButtons.length - 1]).toBeDisabled(); + }); + + it('has a drag handle on each block', async () => { + renderView(); + const handles = document.querySelectorAll('[data-drag-handle]'); + expect(handles.length).toBe(2); + }); +}); + +// ─── Auto-save debounce ─────────────────────────────────────────────────────── + +describe('TranscriptionEditView — auto-save debounce', () => { + it('calls onSaveBlock after 1500ms debounce when text changes', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + renderView({ onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('Neue Zeile'); + + // Not called immediately + expect(onSaveBlock).not.toHaveBeenCalled(); + + // Advance past debounce + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile'); + vi.useRealTimers(); + }); + + it('resets debounce timer on rapid successive changes', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + renderView({ onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('First'); + vi.advanceTimersByTime(500); + + await textarea.fill('Second'); + vi.advanceTimersByTime(500); + + // 1000ms elapsed since first change — should not have saved yet + expect(onSaveBlock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + await vi.runAllTimersAsync(); + + // Only one save with the final value + expect(onSaveBlock).toHaveBeenCalledTimes(1); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second'); + vi.useRealTimers(); + }); +}); + +// ─── Save state transitions ─────────────────────────────────────────────────── + +describe('TranscriptionEditView — save state indicators', () => { + it('shows saving indicator while onSaveBlock is in-flight', async () => { + vi.useFakeTimers(); + let resolveSave!: () => void; + const onSaveBlock = vi.fn().mockReturnValue(new Promise((r) => (resolveSave = r))); + renderView({ onSaveBlock }); + + await page.getByRole('textbox').first().fill('Hello'); + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + await expect.element(page.getByText('Speichere...')).toBeInTheDocument(); + + resolveSave(); + vi.useRealTimers(); + }); + + it('shows error state when onSaveBlock rejects', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockRejectedValue(new Error('network')); + renderView({ onSaveBlock }); + + await page.getByRole('textbox').first().fill('Fails'); + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument(); + await expect.element(page.getByText('Erneut versuchen')).toBeInTheDocument(); + vi.useRealTimers(); + }); +}); + +// ─── Flush on blur ──────────────────────────────────────────────────────────── + +describe('TranscriptionEditView — flush on blur', () => { + it('flushes pending save immediately on textarea blur before debounce expires', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + renderView({ onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('Blur text'); + + // Blur before 1500ms debounce fires + await textarea.blur(); + + await vi.runAllTimersAsync(); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text'); + vi.useRealTimers(); + }); +}); + +// ─── onDeleteBlock callback ─────────────────────────────────────────────────── + +describe('TranscriptionEditView — delete block', () => { + it('calls onDeleteBlock with correct blockId when delete is confirmed', async () => { + const onDeleteBlock = vi.fn().mockResolvedValue(undefined); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderView({ onDeleteBlock }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + expect(onDeleteBlock).toHaveBeenCalledWith('b1'); + vi.restoreAllMocks(); + }); + + it('does not call onDeleteBlock when deletion is cancelled', async () => { + const onDeleteBlock = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderView({ onDeleteBlock }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + expect(onDeleteBlock).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index d5964198..eaa402ed 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -18,6 +18,8 @@ export type ErrorCode = | 'INVALID_RESET_TOKEN' | 'ANNOTATION_NOT_FOUND' | 'ANNOTATION_OVERLAP' + | 'TRANSCRIPTION_BLOCK_NOT_FOUND' + | 'TRANSCRIPTION_BLOCK_CONFLICT' | 'COMMENT_NOT_FOUND' | 'UNAUTHORIZED' | 'FORBIDDEN' @@ -74,6 +76,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_annotation_not_found(); case 'ANNOTATION_OVERLAP': return m.error_annotation_overlap(); + case 'TRANSCRIPTION_BLOCK_NOT_FOUND': + return m.error_transcription_block_not_found(); + case 'TRANSCRIPTION_BLOCK_CONFLICT': + return m.error_transcription_block_conflict(); case 'COMMENT_NOT_FOUND': return m.error_comment_not_found(); case 'UNAUTHORIZED': diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a2144e40..490f5352 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -27,6 +27,16 @@ export type Comment = { export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history'; +export type TranscriptionBlockData = { + id: string; + annotationId: string; + documentId: string; + text: string; + label: string | null; + sortOrder: number; + version: number; +}; + export type Annotation = { id: string; documentId: string; diff --git a/frontend/src/routes/documents/[id]/+page.server.ts b/frontend/src/routes/documents/[id]/+page.server.ts index 01777b9a..5fb27114 100644 --- a/frontend/src/routes/documents/[id]/+page.server.ts +++ b/frontend/src/routes/documents/[id]/+page.server.ts @@ -1,17 +1,12 @@ import { error, redirect } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; export async function load({ params, fetch }) { const { id } = params; const api = createApiClient(fetch); - const base = env.API_INTERNAL_URL || 'http://localhost:8080'; - const [docResult, commentsRes] = await Promise.all([ - api.GET('/api/documents/{id}', { params: { path: { id } } }), - fetch(`${base}/api/documents/${id}/comments`).catch(() => null) - ]); + const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } }); if (docResult.response.status === 401) throw redirect(302, '/login'); @@ -20,14 +15,5 @@ export async function load({ params, fetch }) { throw error(docResult.response.status, getErrorMessage(code)); } - let comments: unknown[] = []; - if (commentsRes?.ok) { - try { - comments = await commentsRes.json(); - } catch { - // ignore invalid response - } - } - - return { document: docResult.data!, comments }; + return { document: docResult.data! }; } diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index fd986cfd..a3af67a0 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,24 +1,14 @@