diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java index 7b8158af..acdac4c5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java @@ -16,10 +16,10 @@ public class AsyncConfig { @Bean public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(1); - executor.setMaxPoolSize(1); - executor.setQueueCapacity(1); - executor.setThreadNamePrefix("Import-"); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(10); + executor.setThreadNamePrefix("Async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); return executor; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/MinioConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/MinioConfig.java index a3fb187c..981ddb65 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/MinioConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/MinioConfig.java @@ -5,6 +5,7 @@ import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; @@ -44,6 +45,19 @@ public class MinioConfig { .build(); } + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .endpointOverride(URI.create(endpoint)) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()) + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .build(); + } + @Bean public CommandLineRunner testS3Connection(S3Client s3Client) { return args -> { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java new file mode 100644 index 00000000..4b8f9cd3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java @@ -0,0 +1,88 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.BatchOcrDTO; +import org.raddatz.familienarchiv.dto.OcrStatusDTO; +import org.raddatz.familienarchiv.dto.TriggerOcrDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.OcrJob; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.service.OcrBatchService; +import org.raddatz.familienarchiv.service.OcrProgressService; +import org.raddatz.familienarchiv.service.OcrService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import jakarta.validation.Valid; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class OcrController { + + private final OcrService ocrService; + private final OcrBatchService ocrBatchService; + private final OcrProgressService ocrProgressService; + private final UserService userService; + + @PostMapping("/api/documents/{documentId}/ocr") + @ResponseStatus(HttpStatus.ACCEPTED) + @RequirePermission(Permission.WRITE_ALL) + public Map triggerOcr( + @PathVariable UUID documentId, + @RequestBody TriggerOcrDTO dto, + Authentication authentication) { + UUID userId = resolveUserId(authentication); + UUID jobId = ocrService.startOcr(documentId, dto.getScriptType(), userId); + return Map.of("jobId", jobId); + } + + @PostMapping("/api/ocr/batch") + @ResponseStatus(HttpStatus.ACCEPTED) + @RequirePermission(Permission.ADMIN) + public Map triggerBatch( + @RequestBody @Valid BatchOcrDTO dto, + Authentication authentication) { + UUID userId = resolveUserId(authentication); + UUID jobId = ocrBatchService.startBatch(dto.getDocumentIds(), userId); + return Map.of("jobId", jobId); + } + + @GetMapping("/api/ocr/jobs/{jobId}") + @RequirePermission(Permission.READ_ALL) + public OcrJob getJobStatus(@PathVariable UUID jobId) { + return ocrService.getJob(jobId); + } + + @GetMapping(value = "/api/ocr/jobs/{jobId}/progress", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @RequirePermission(Permission.READ_ALL) + public SseEmitter streamProgress(@PathVariable UUID jobId) { + ocrService.getJob(jobId); + return ocrProgressService.register(jobId); + } + + @GetMapping("/api/documents/{documentId}/ocr-status") + @RequirePermission(Permission.READ_ALL) + public OcrStatusDTO getDocumentOcrStatus(@PathVariable UUID documentId) { + return ocrService.getDocumentOcrStatus(documentId); + } + + private UUID resolveUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) return null; + try { + AppUser user = userService.findByUsername(authentication.getName()); + return user != null ? user.getId() : null; + } catch (Exception e) { + log.warn("Failed to resolve user ID for authentication: {}", authentication.getName(), e); + return null; + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java index 227713d0..fd52d8f4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -81,6 +81,14 @@ public class TranscriptionBlockController { return transcriptionService.listBlocks(documentId); } + @PutMapping("/{blockId}/review") + @RequirePermission(Permission.WRITE_ALL) + public TranscriptionBlock reviewBlock( + @PathVariable UUID documentId, + @PathVariable UUID blockId) { + return transcriptionService.reviewBlock(documentId, blockId); + } + @GetMapping("/{blockId}/history") @RequirePermission(Permission.READ_ALL) public List getBlockHistory( diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchOcrDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchOcrDTO.java new file mode 100644 index 00000000..69506437 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchOcrDTO.java @@ -0,0 +1,19 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BatchOcrDTO { + @NotEmpty + @Size(max = 500, message = "batch size must not exceed 500 documents") + private List documentIds; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java index db81687f..846d9321 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java @@ -1,9 +1,15 @@ package org.raddatz.familienarchiv.dto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @NoArgsConstructor @AllArgsConstructor @@ -14,4 +20,19 @@ public class CreateAnnotationDTO { private double width; private double height; private String color; + + @Size(min = 4, max = 4, message = "polygon must have exactly 4 points") + @UniquePoints + @Valid + private List<@Size(min = 2, max = 2, message = "each point must have exactly 2 coordinates") + List<@DecimalMin("0.0") @DecimalMax("1.0") Double>> polygon; + + public CreateAnnotationDTO(int pageNumber, double x, double y, double width, double height, String color) { + this.pageNumber = pageNumber; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.color = color; + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentUpdateDTO.java index 79789f24..2cf39760 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentUpdateDTO.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.UUID; import lombok.Data; +import org.raddatz.familienarchiv.model.ScriptType; @Data public class DocumentUpdateDTO { @@ -18,4 +19,5 @@ public class DocumentUpdateDTO { private List receiverIds; private String tags; private Boolean metadataComplete; + private ScriptType scriptType; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/OcrStatusDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/OcrStatusDTO.java new file mode 100644 index 00000000..c23ca303 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/OcrStatusDTO.java @@ -0,0 +1,19 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OcrStatusDTO { + private String status; + private UUID jobId; + private int currentPage; + private int totalPages; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TriggerOcrDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TriggerOcrDTO.java new file mode 100644 index 00000000..dda443b3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TriggerOcrDTO.java @@ -0,0 +1,13 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.raddatz.familienarchiv.model.ScriptType; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TriggerOcrDTO { + private ScriptType scriptType; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePoints.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePoints.java new file mode 100644 index 00000000..6e954094 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePoints.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = UniquePointsValidator.class) +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface UniquePoints { + String message() default "polygon must contain 4 unique points"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePointsValidator.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePointsValidator.java new file mode 100644 index 00000000..eac16820 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePointsValidator.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.HashSet; +import java.util.List; + +public class UniquePointsValidator implements ConstraintValidator>> { + + @Override + public boolean isValid(List> polygon, ConstraintValidatorContext context) { + if (polygon == null) return true; + return new HashSet<>(polygon).size() == polygon.size(); + } +} 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 b105df54..e3b0c99c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -66,6 +66,16 @@ public enum ErrorCode { /** The notification with the given ID does not exist. 404 */ NOTIFICATION_NOT_FOUND, + // --- OCR --- + /** The OCR service is not available or not healthy. 503 */ + OCR_SERVICE_UNAVAILABLE, + /** The OCR job with the given ID does not exist. 404 */ + OCR_JOB_NOT_FOUND, + /** The document is not in UPLOADED status and cannot be OCR'd. 400 */ + OCR_DOCUMENT_NOT_UPLOADED, + /** OCR processing failed for the document. 500 */ + OCR_PROCESSING_FAILED, + // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/BlockSource.java b/backend/src/main/java/org/raddatz/familienarchiv/model/BlockSource.java new file mode 100644 index 00000000..eb412e64 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/BlockSource.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.model; + +public enum BlockSource { + MANUAL, + OCR +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index f72e3f5e..e5be77a3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -91,6 +91,12 @@ public class Document { @Builder.Default private boolean metadataComplete = false; + @Enumerated(EnumType.STRING) + @Column(name = "script_type", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private ScriptType scriptType = ScriptType.UNKNOWN; + @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id")) @Builder.Default diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java index 281f88a2..5aaaff2d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java @@ -4,8 +4,11 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; @Entity @@ -52,6 +55,10 @@ public class DocumentAnnotation { @Column(name = "file_hash", length = 64) private String fileHash; + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private List> polygon; + @Column(name = "created_by") private UUID createdBy; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/OcrDocumentStatus.java b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrDocumentStatus.java new file mode 100644 index 00000000..d96620b3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrDocumentStatus.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.model; + +public enum OcrDocumentStatus { + PENDING, + RUNNING, + DONE, + FAILED, + SKIPPED +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/OcrJob.java b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrJob.java new file mode 100644 index 00000000..076d3ef3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrJob.java @@ -0,0 +1,65 @@ +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 = "ocr_jobs") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OcrJob { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private OcrJobStatus status = OcrJobStatus.PENDING; + + @Column(name = "total_documents", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int totalDocuments; + + @Column(name = "processed_documents", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private int processedDocuments = 0; + + @Column(name = "error_count", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private int errorCount = 0; + + @Column(name = "skipped_count", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private int skippedCount = 0; + + @Column(name = "progress_message") + private String progressMessage; + + @Column(name = "created_by") + private UUID createdBy; + + @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/OcrJobDocument.java b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrJobDocument.java new file mode 100644 index 00000000..c8f3f702 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrJobDocument.java @@ -0,0 +1,59 @@ +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 = "ocr_job_documents") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OcrJobDocument { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(name = "job_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID jobId; + + @Column(name = "document_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID documentId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private OcrDocumentStatus status = OcrDocumentStatus.PENDING; + + @Column(name = "error_message") + private String errorMessage; + + @Column(name = "current_page") + @Builder.Default + private int currentPage = 0; + + @Column(name = "total_pages") + @Builder.Default + private int totalPages = 0; + + @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/OcrJobStatus.java b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrJobStatus.java new file mode 100644 index 00000000..5f1bf442 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrJobStatus.java @@ -0,0 +1,8 @@ +package org.raddatz.familienarchiv.model; + +public enum OcrJobStatus { + PENDING, + RUNNING, + DONE, + FAILED +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PolygonConverter.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PolygonConverter.java new file mode 100644 index 00000000..28362e8f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PolygonConverter.java @@ -0,0 +1,36 @@ +package org.raddatz.familienarchiv.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.List; + +@Converter +public class PolygonConverter implements AttributeConverter>, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference>> TYPE_REF = new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(List> polygon) { + if (polygon == null) return null; + try { + return MAPPER.writeValueAsString(polygon); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to serialize polygon", e); + } + } + + @Override + public List> convertToEntityAttribute(String json) { + if (json == null || json.isEmpty()) return null; + try { + return MAPPER.readValue(json, TYPE_REF); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to deserialize polygon", e); + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/ScriptType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/ScriptType.java new file mode 100644 index 00000000..b6ff83e4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/ScriptType.java @@ -0,0 +1,8 @@ +package org.raddatz.familienarchiv.model; + +public enum ScriptType { + UNKNOWN, + TYPEWRITER, + HANDWRITING_LATIN, + HANDWRITING_KURRENT +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java index 6f1e008e..8f01dbeb 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java @@ -41,6 +41,17 @@ public class TranscriptionBlock { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private int sortOrder; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private BlockSource source = BlockSource.MANUAL; + + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private boolean reviewed = false; + @Version @Column(nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrJobDocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrJobDocumentRepository.java new file mode 100644 index 00000000..3d781804 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrJobDocumentRepository.java @@ -0,0 +1,20 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.OcrDocumentStatus; +import org.raddatz.familienarchiv.model.OcrJobDocument; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface OcrJobDocumentRepository extends JpaRepository { + + List findByJobIdOrderByCreatedAtAsc(UUID jobId); + + List findByJobIdAndStatus(UUID jobId, OcrDocumentStatus status); + + Optional findByJobIdAndDocumentId(UUID jobId, UUID documentId); + + Optional findFirstByDocumentIdAndStatusIn(UUID documentId, List statuses); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrJobRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrJobRepository.java new file mode 100644 index 00000000..5d319ccf --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrJobRepository.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.OcrJob; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface OcrJobRepository extends JpaRepository { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java index f52c70b0..6735ef31 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java @@ -48,6 +48,26 @@ public class AnnotationService { return annotationRepository.save(annotation); } + @Transactional + public DocumentAnnotation createOcrAnnotation(UUID documentId, CreateAnnotationDTO dto, + UUID userId, String fileHash, + List> polygon) { + DocumentAnnotation annotation = DocumentAnnotation.builder() + .documentId(documentId) + .pageNumber(dto.getPageNumber()) + .x(dto.getX()) + .y(dto.getY()) + .width(dto.getWidth()) + .height(dto.getHeight()) + .color(dto.getColor()) + .fileHash(fileHash) + .createdBy(userId) + .polygon(polygon) + .build(); + + return annotationRepository.save(annotation); + } + @Transactional public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) { DocumentAnnotation annotation = annotationRepository diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 7d1bef2b..f06a9922 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.ScriptType; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; @@ -222,6 +223,10 @@ public class DocumentService { doc.setMetadataComplete(dto.getMetadataComplete()); } + if (dto.getScriptType() != null) { + doc.setScriptType(dto.getScriptType()); + } + // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde) if (newFile != null && !newFile.isEmpty()) { FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename()); @@ -373,6 +378,13 @@ public class DocumentService { return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate")); } + @Transactional + public void updateScriptType(UUID documentId, ScriptType scriptType) { + Document doc = getDocumentById(documentId); + doc.setScriptType(scriptType); + documentRepository.save(doc); + } + public Document getDocumentById(UUID id) { return documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java index 57e225c6..acf6f23d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java @@ -4,6 +4,8 @@ import software.amazon.awssdk.core.ResponseInputStream; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,6 +18,7 @@ import java.io.IOException; import java.io.InputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Duration; import java.util.UUID; @Service @@ -24,10 +27,13 @@ public class FileService { private static final Logger log = LoggerFactory.getLogger(FileService.class); private final S3Client s3Client; + private final S3Presigner s3Presigner; private final String bucketName; - public FileService(S3Client s3Client, @Value("${app.s3.bucket}") String bucketName) { + public FileService(S3Client s3Client, S3Presigner s3Presigner, + @Value("${app.s3.bucket}") String bucketName) { this.s3Client = s3Client; + this.s3Presigner = s3Presigner; this.bucketName = bucketName; } @@ -106,6 +112,24 @@ public class FileService { } } + /** + * Generates a presigned URL for downloading an object from S3/MinIO. + * Valid for 15 minutes — enough for OCR processing on CPU. + */ + public String generatePresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(15)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + // ─── private helpers ────────────────────────────────────────────────────── private static String sha256Hex(byte[] bytes) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrAsyncRunner.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrAsyncRunner.java new file mode 100644 index 00000000..9100f58e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrAsyncRunner.java @@ -0,0 +1,217 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.model.*; +import org.raddatz.familienarchiv.repository.OcrJobDocumentRepository; +import org.raddatz.familienarchiv.repository.OcrJobRepository; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OcrAsyncRunner { + + private static final String OCR_ANNOTATION_COLOR = "#00C7B1"; + + private final OcrClient ocrClient; + private final DocumentService documentService; + private final TranscriptionService transcriptionService; + private final AnnotationService annotationService; + private final FileService fileService; + private final OcrJobRepository ocrJobRepository; + private final OcrJobDocumentRepository ocrJobDocumentRepository; + private final OcrProgressService ocrProgressService; + + @Async + public void runSingleDocument(UUID jobId, UUID documentId, UUID userId) { + OcrJob job = ocrJobRepository.findById(jobId).orElse(null); + if (job == null) return; + + job.setStatus(OcrJobStatus.RUNNING); + updateProgress(job, "PREPARING"); + + OcrJobDocument jobDoc = ocrJobDocumentRepository.findByJobIdAndDocumentId(jobId, documentId) + .orElse(null); + if (jobDoc != null) { + jobDoc.setStatus(OcrDocumentStatus.RUNNING); + ocrJobDocumentRepository.save(jobDoc); + } + + Document doc = documentService.getDocumentById(documentId); + + try { + updateProgress(job, "LOADING"); + clearExistingBlocks(documentId); + String pdfUrl = fileService.generatePresignedUrl(doc.getFilePath()); + + AtomicInteger blockCounter = new AtomicInteger(0); + AtomicInteger currentPage = new AtomicInteger(0); + AtomicInteger skippedPages = new AtomicInteger(0); + AtomicInteger totalPages = new AtomicInteger(0); + + ocrClient.streamBlocks(pdfUrl, doc.getScriptType(), event -> { + switch (event) { + case OcrStreamEvent.Start start -> { + totalPages.set(start.totalPages()); + if (jobDoc != null) { + jobDoc.setTotalPages(start.totalPages()); + ocrJobDocumentRepository.save(jobDoc); + } + } + case OcrStreamEvent.Page page -> { + for (OcrBlockResult block : page.blocks()) { + createSingleBlock(documentId, block, userId, + doc.getFileHash(), blockCounter.getAndIncrement()); + } + currentPage.incrementAndGet(); + if (jobDoc != null) { + jobDoc.setCurrentPage(currentPage.get()); + ocrJobDocumentRepository.save(jobDoc); + } + updateProgress(job, "ANALYZING_PAGE:" + currentPage.get() + + ":" + totalPages.get() + ":" + blockCounter.get()); + } + case OcrStreamEvent.Error error -> { + log.warn("OCR page {} failed for document {}: {}", + error.pageNumber(), documentId, error.message()); + skippedPages.incrementAndGet(); + currentPage.incrementAndGet(); + if (jobDoc != null) { + jobDoc.setCurrentPage(currentPage.get()); + ocrJobDocumentRepository.save(jobDoc); + } + } + case OcrStreamEvent.Done done -> { + if (jobDoc != null) { + jobDoc.setCurrentPage(totalPages.get()); + ocrJobDocumentRepository.save(jobDoc); + } + } + } + }); + + job.setStatus(OcrJobStatus.DONE); + job.setProcessedDocuments(1); + updateProgress(job, "DONE:" + blockCounter.get() + ":" + skippedPages.get()); + if (jobDoc != null) { + jobDoc.setStatus(OcrDocumentStatus.DONE); + ocrJobDocumentRepository.save(jobDoc); + } + } catch (Exception e) { + log.error("OCR processing failed for document {}", documentId, e); + job.setStatus(OcrJobStatus.FAILED); + job.setErrorCount(1); + updateProgress(job, "ERROR"); + if (jobDoc != null) { + jobDoc.setStatus(OcrDocumentStatus.FAILED); + jobDoc.setErrorMessage(e.getMessage()); + ocrJobDocumentRepository.save(jobDoc); + } + } + } + + private void updateProgress(OcrJob job, String message) { + job.setProgressMessage(message); + ocrJobRepository.save(job); + } + + @Async + public void runBatch(UUID jobId, UUID userId) { + OcrJob job = ocrJobRepository.findById(jobId).orElse(null); + if (job == null) return; + + job.setStatus(OcrJobStatus.RUNNING); + ocrJobRepository.save(job); + + List jobDocs = ocrJobDocumentRepository.findByJobIdOrderByCreatedAtAsc(jobId); + + for (OcrJobDocument jobDoc : jobDocs) { + Document doc = documentService.getDocumentById(jobDoc.getDocumentId()); + + if (doc.getStatus() == DocumentStatus.PLACEHOLDER) { + jobDoc.setStatus(OcrDocumentStatus.SKIPPED); + ocrJobDocumentRepository.save(jobDoc); + job.setSkippedCount(job.getSkippedCount() + 1); + ocrJobRepository.save(job); + ocrProgressService.emit(jobId, "document", Map.of( + "documentId", jobDoc.getDocumentId(), + "status", "SKIPPED", + "processed", job.getProcessedDocuments(), + "total", job.getTotalDocuments())); + continue; + } + + jobDoc.setStatus(OcrDocumentStatus.RUNNING); + ocrJobDocumentRepository.save(jobDoc); + + try { + processDocument(jobDoc.getDocumentId(), doc, userId); + jobDoc.setStatus(OcrDocumentStatus.DONE); + job.setProcessedDocuments(job.getProcessedDocuments() + 1); + } catch (Exception e) { + log.error("OCR batch: failed document {}", jobDoc.getDocumentId(), e); + jobDoc.setStatus(OcrDocumentStatus.FAILED); + jobDoc.setErrorMessage(e.getMessage()); + job.setErrorCount(job.getErrorCount() + 1); + } + + ocrJobDocumentRepository.save(jobDoc); + ocrJobRepository.save(job); + + ocrProgressService.emit(jobId, "document", Map.of( + "documentId", jobDoc.getDocumentId(), + "status", jobDoc.getStatus().name(), + "processed", job.getProcessedDocuments(), + "total", job.getTotalDocuments())); + } + + job.setStatus(OcrJobStatus.DONE); + ocrJobRepository.save(job); + + ocrProgressService.emit(jobId, "done", Map.of( + "processed", job.getProcessedDocuments(), + "errors", job.getErrorCount(), + "skipped", job.getSkippedCount())); + ocrProgressService.complete(jobId); + } + + void processDocument(UUID documentId, Document doc, UUID userId) { + clearExistingBlocks(documentId); + + String pdfUrl = fileService.generatePresignedUrl(doc.getFilePath()); + List blocks = ocrClient.extractBlocks(pdfUrl, doc.getScriptType()); + createTranscriptionBlocks(documentId, blocks, userId, doc.getFileHash()); + } + + private void clearExistingBlocks(UUID documentId) { + transcriptionService.deleteAllBlocksByDocument(documentId); + } + + private void createTranscriptionBlocks(UUID documentId, List blocks, + UUID userId, String fileHash) { + for (int i = 0; i < blocks.size(); i++) { + createSingleBlock(documentId, blocks.get(i), userId, fileHash, i); + } + } + + void createSingleBlock(UUID documentId, OcrBlockResult block, + UUID userId, String fileHash, int sortOrder) { + CreateAnnotationDTO annotationDTO = new CreateAnnotationDTO( + block.pageNumber(), block.x(), block.y(), + block.width(), block.height(), OCR_ANNOTATION_COLOR); + + DocumentAnnotation annotation = annotationService.createOcrAnnotation( + documentId, annotationDTO, userId, fileHash, block.polygon()); + + transcriptionService.createOcrBlock(documentId, annotation.getId(), + block.text(), sortOrder, userId); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrBatchService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrBatchService.java new file mode 100644 index 00000000..294ba849 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrBatchService.java @@ -0,0 +1,50 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.*; +import org.raddatz.familienarchiv.repository.OcrJobDocumentRepository; +import org.raddatz.familienarchiv.repository.OcrJobRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OcrBatchService { + + private final OcrHealthClient ocrHealthClient; + private final OcrJobRepository ocrJobRepository; + private final OcrJobDocumentRepository ocrJobDocumentRepository; + private final OcrAsyncRunner ocrAsyncRunner; + + public UUID startBatch(List documentIds, UUID userId) { + if (!ocrHealthClient.isHealthy()) { + throw DomainException.internal(ErrorCode.OCR_SERVICE_UNAVAILABLE, + "OCR service is not available"); + } + + OcrJob job = OcrJob.builder() + .totalDocuments(documentIds.size()) + .createdBy(userId) + .status(OcrJobStatus.PENDING) + .build(); + job = ocrJobRepository.save(job); + + for (UUID docId : documentIds) { + OcrJobDocument jobDoc = OcrJobDocument.builder() + .jobId(job.getId()) + .documentId(docId) + .status(OcrDocumentStatus.PENDING) + .build(); + ocrJobDocumentRepository.save(jobDoc); + } + + ocrAsyncRunner.runBatch(job.getId(), userId); + return job.getId(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrBlockResult.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrBlockResult.java new file mode 100644 index 00000000..b091f145 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrBlockResult.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.service; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record OcrBlockResult( + int pageNumber, + double x, + double y, + double width, + double height, + List> polygon, + String text +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrClient.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrClient.java new file mode 100644 index 00000000..9cf7c886 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrClient.java @@ -0,0 +1,35 @@ +package org.raddatz.familienarchiv.service; + +import org.raddatz.familienarchiv.model.ScriptType; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.function.Consumer; + +public interface OcrClient { + List extractBlocks(String pdfUrl, ScriptType scriptType); + + /** + * Stream OCR results page-by-page via NDJSON. Implementations should override + * this method. The default exists only for backward compatibility during migration + * — it calls extractBlocks() and synthesizes events from the collected result. + */ + default void streamBlocks(String pdfUrl, ScriptType scriptType, Consumer handler) { + List allBlocks = extractBlocks(pdfUrl, scriptType); + + LinkedHashMap> byPage = new LinkedHashMap<>(); + for (OcrBlockResult block : allBlocks) { + byPage.computeIfAbsent(block.pageNumber(), k -> new ArrayList<>()).add(block); + } + + int totalPages = byPage.isEmpty() ? 0 : byPage.keySet().stream().mapToInt(i -> i).max().orElse(0) + 1; + handler.accept(new OcrStreamEvent.Start(totalPages)); + + for (var entry : byPage.entrySet()) { + handler.accept(new OcrStreamEvent.Page(entry.getKey(), entry.getValue())); + } + + handler.accept(new OcrStreamEvent.Done(allBlocks.size(), 0)); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrHealthClient.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrHealthClient.java new file mode 100644 index 00000000..3a62f592 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrHealthClient.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.service; + +public interface OcrHealthClient { + boolean isHealthy(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrProgressService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrProgressService.java new file mode 100644 index 00000000..8b3bc798 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrProgressService.java @@ -0,0 +1,69 @@ +package org.raddatz.familienarchiv.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +@Service +@Slf4j +public class OcrProgressService { + + private static final long SSE_TIMEOUT = 5 * 60 * 1000L; + + private final ConcurrentHashMap> emitters = new ConcurrentHashMap<>(); + + public SseEmitter register(UUID jobId) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT); + emitters.computeIfAbsent(jobId, k -> new CopyOnWriteArrayList<>()).add(emitter); + + emitter.onCompletion(() -> removeEmitter(jobId, emitter)); + emitter.onTimeout(() -> removeEmitter(jobId, emitter)); + emitter.onError(e -> removeEmitter(jobId, emitter)); + + return emitter; + } + + public void emit(UUID jobId, String eventType, Object data) { + List jobEmitters = emitters.get(jobId); + if (jobEmitters == null) return; + + for (SseEmitter emitter : jobEmitters) { + try { + emitter.send(SseEmitter.event().name(eventType).data(data)); + } catch (IOException e) { + log.debug("SSE send failed for job {} — removing emitter", jobId); + removeEmitter(jobId, emitter); + } + } + } + + public void complete(UUID jobId) { + List jobEmitters = emitters.remove(jobId); + if (jobEmitters == null) return; + + for (SseEmitter emitter : jobEmitters) { + try { + emitter.complete(); + } catch (Exception e) { + log.debug("SSE complete failed for job {}", jobId); + } + } + } + + private void removeEmitter(UUID jobId, SseEmitter emitter) { + List jobEmitters = emitters.get(jobId); + if (jobEmitters != null) { + jobEmitters.remove(emitter); + if (jobEmitters.isEmpty()) { + emitters.remove(jobId); + } + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java new file mode 100644 index 00000000..dcc14dd1 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java @@ -0,0 +1,88 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.OcrStatusDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.*; +import org.raddatz.familienarchiv.repository.OcrJobDocumentRepository; +import org.raddatz.familienarchiv.repository.OcrJobRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OcrService { + + private final OcrHealthClient ocrHealthClient; + private final DocumentService documentService; + private final OcrJobRepository ocrJobRepository; + private final OcrJobDocumentRepository ocrJobDocumentRepository; + private final OcrAsyncRunner ocrAsyncRunner; + + public OcrJob getJob(UUID jobId) { + return ocrJobRepository.findById(jobId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.OCR_JOB_NOT_FOUND, "OCR job not found: " + jobId)); + } + + public OcrStatusDTO getDocumentOcrStatus(UUID documentId) { + List activeStatuses = List.of( + OcrDocumentStatus.PENDING, OcrDocumentStatus.RUNNING); + + Optional activeJobDoc = ocrJobDocumentRepository + .findFirstByDocumentIdAndStatusIn(documentId, activeStatuses); + + if (activeJobDoc.isEmpty()) { + return OcrStatusDTO.builder().status("NONE").build(); + } + + OcrJobDocument jobDoc = activeJobDoc.get(); + return OcrStatusDTO.builder() + .status(jobDoc.getStatus().name()) + .jobId(jobDoc.getJobId()) + .currentPage(jobDoc.getCurrentPage()) + .totalPages(jobDoc.getTotalPages()) + .build(); + } + + public UUID startOcr(UUID documentId, ScriptType scriptTypeOverride, UUID userId) { + Document doc = documentService.getDocumentById(documentId); + + if (doc.getStatus() == DocumentStatus.PLACEHOLDER) { + throw DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, + "Document has no file attached: " + documentId); + } + + if (!ocrHealthClient.isHealthy()) { + throw DomainException.internal(ErrorCode.OCR_SERVICE_UNAVAILABLE, + "OCR service is not available"); + } + + if (scriptTypeOverride != null) { + documentService.updateScriptType(documentId, scriptTypeOverride); + } + + OcrJob job = OcrJob.builder() + .totalDocuments(1) + .createdBy(userId) + .status(OcrJobStatus.PENDING) + .build(); + job = ocrJobRepository.save(job); + + OcrJobDocument jobDoc = OcrJobDocument.builder() + .jobId(job.getId()) + .documentId(documentId) + .status(OcrDocumentStatus.PENDING) + .build(); + ocrJobDocumentRepository.save(jobDoc); + + ocrAsyncRunner.runSingleDocument(job.getId(), documentId, userId); + return job.getId(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrStreamEvent.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrStreamEvent.java new file mode 100644 index 00000000..aec0e4f1 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrStreamEvent.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.service; + +import java.util.List; + +public sealed interface OcrStreamEvent { + + record Start(int totalPages) implements OcrStreamEvent {} + + record Page(int pageNumber, List blocks) implements OcrStreamEvent {} + + record Error(int pageNumber, String message) implements OcrStreamEvent {} + + record Done(int totalBlocks, int skippedPages) implements OcrStreamEvent {} +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/RestClientOcrClient.java b/backend/src/main/java/org/raddatz/familienarchiv/service/RestClientOcrClient.java new file mode 100644 index 00000000..a0f7ccf3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/RestClientOcrClient.java @@ -0,0 +1,187 @@ +package org.raddatz.familienarchiv.service; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.model.ScriptType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +@Component +@Slf4j +public class RestClientOcrClient implements OcrClient, OcrHealthClient { + + private static final ObjectMapper NDJSON_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true); + + private final RestClient restClient; + private final HttpClient streamingHttpClient; + private final String baseUrl; + + public RestClientOcrClient(@Value("${app.ocr.base-url:http://ocr-service:8000}") String baseUrl) { + this.baseUrl = baseUrl; + + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofMinutes(10)); + + this.restClient = RestClient.builder() + .baseUrl(baseUrl) + .requestFactory(requestFactory) + .build(); + + this.streamingHttpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + @Override + public List extractBlocks(String pdfUrl, ScriptType scriptType) { + Map body = Map.of( + "pdfUrl", pdfUrl, + "scriptType", scriptType.name(), + "language", "de"); + + List response = restClient.post() + .uri("/ocr") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + if (response == null) return List.of(); + + return response.stream() + .map(OcrBlockJson::toResult) + .toList(); + } + + @Override + public boolean isHealthy() { + try { + restClient.get() + .uri("/health") + .retrieve() + .toBodilessEntity(); + return true; + } catch (Exception e) { + log.warn("OCR service health check failed: {}", e.getMessage()); + return false; + } + } + + @Override + public void streamBlocks(String pdfUrl, ScriptType scriptType, Consumer handler) { + String body; + try { + body = NDJSON_MAPPER.writeValueAsString(Map.of( + "pdfUrl", pdfUrl, + "scriptType", scriptType.name(), + "language", "de")); + } catch (IOException e) { + throw new RuntimeException("Failed to serialize OCR request", e); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ocr/stream")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .timeout(Duration.ofMinutes(5)) + .build(); + + try { + HttpResponse response = streamingHttpClient.send( + request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() == 404) { + log.info("OCR service does not support /ocr/stream (404), falling back to /ocr"); + OcrClient.super.streamBlocks(pdfUrl, scriptType, handler); + return; + } + + try (InputStream inputStream = response.body()) { + parseNdjsonStream(inputStream, handler); + } + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new RuntimeException("NDJSON stream failed: " + e.getMessage(), e); + } + } + + static void parseNdjsonStream(InputStream inputStream, Consumer handler) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + + JsonNode node = NDJSON_MAPPER.readTree(line); + String type = node.path("type").asText(); + + switch (type) { + case "start" -> handler.accept( + new OcrStreamEvent.Start(node.path("totalPages").asInt())); + case "page" -> { + int pageNumber = node.path("pageNumber").asInt(); + List blocks = NDJSON_MAPPER.convertValue( + node.path("blocks"), + new TypeReference<>() {}); + handler.accept(new OcrStreamEvent.Page(pageNumber, blocks)); + } + case "error" -> handler.accept( + new OcrStreamEvent.Error( + node.path("pageNumber").asInt(), + node.path("message").asText())); + case "done" -> handler.accept( + new OcrStreamEvent.Done( + node.path("totalBlocks").asInt(), + node.path("skippedPages").asInt())); + default -> log.debug("Ignoring unknown NDJSON event type: {}", type); + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to parse NDJSON stream: " + e.getMessage(), e); + } + } + + record OcrBlockJson( + @JsonProperty("pageNumber") int pageNumber, + double x, + double y, + double width, + double height, + List> polygon, + String text + ) { + OcrBlockResult toResult() { + return new OcrBlockResult(pageNumber, x, y, width, height, polygon, text); + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java index 2aff91bb..c93c98a5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -8,6 +8,7 @@ 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.BlockSource; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.TranscriptionBlock; @@ -75,6 +76,24 @@ public class TranscriptionService { return saved; } + @Transactional + public TranscriptionBlock createOcrBlock(UUID documentId, UUID annotationId, + String text, int sortOrder, UUID userId) { + String sanitized = sanitizeText(text); + TranscriptionBlock block = TranscriptionBlock.builder() + .annotationId(annotationId) + .documentId(documentId) + .text(sanitized) + .sortOrder(sortOrder) + .source(BlockSource.OCR) + .createdBy(userId) + .updatedBy(userId) + .build(); + TranscriptionBlock saved = blockRepository.save(block); + saveVersion(saved, userId); + return saved; + } + @Transactional public TranscriptionBlock updateBlock(UUID documentId, UUID blockId, UpdateTranscriptionBlockDTO dto, UUID userId) { @@ -106,6 +125,21 @@ public class TranscriptionService { blockId, annotationId, documentId); } + @Transactional + public void deleteAllBlocksByDocument(UUID documentId) { + List blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); + if (blocks.isEmpty()) return; + + List annotationIds = blocks.stream() + .map(TranscriptionBlock::getAnnotationId) + .toList(); + + blockRepository.deleteAll(blocks); + blockRepository.flush(); + annotationRepository.deleteAllById(annotationIds); + log.info("Bulk-deleted {} transcription blocks for document {}", blocks.size(), documentId); + } + @Transactional public void reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) { List blockIds = dto.getBlockIds(); @@ -116,6 +150,13 @@ public class TranscriptionService { } } + @Transactional + public TranscriptionBlock reviewBlock(UUID documentId, UUID blockId) { + TranscriptionBlock block = getBlock(documentId, blockId); + block.setReviewed(!block.isReviewed()); + return blockRepository.save(block); + } + public List getBlockHistory(UUID documentId, UUID blockId) { getBlock(documentId, blockId); return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId); diff --git a/backend/src/main/resources/db/migration/V23__add_polygon_to_annotations.sql b/backend/src/main/resources/db/migration/V23__add_polygon_to_annotations.sql new file mode 100644 index 00000000..74a4d246 --- /dev/null +++ b/backend/src/main/resources/db/migration/V23__add_polygon_to_annotations.sql @@ -0,0 +1,8 @@ +-- Add optional polygon field for quadrilateral annotation shapes (Kraken OCR output). +-- See ADR-002 for the design decision. + +ALTER TABLE document_annotations ADD COLUMN polygon JSONB; + +ALTER TABLE document_annotations +ADD CONSTRAINT chk_annotation_polygon_quad + CHECK (polygon IS NULL OR jsonb_array_length(polygon) = 4); diff --git a/backend/src/main/resources/db/migration/V24__add_script_type_to_documents.sql b/backend/src/main/resources/db/migration/V24__add_script_type_to_documents.sql new file mode 100644 index 00000000..87a48a00 --- /dev/null +++ b/backend/src/main/resources/db/migration/V24__add_script_type_to_documents.sql @@ -0,0 +1 @@ +ALTER TABLE documents ADD COLUMN script_type VARCHAR(30) NOT NULL DEFAULT 'UNKNOWN'; diff --git a/backend/src/main/resources/db/migration/V25__add_ocr_job_tables.sql b/backend/src/main/resources/db/migration/V25__add_ocr_job_tables.sql new file mode 100644 index 00000000..a9f6945c --- /dev/null +++ b/backend/src/main/resources/db/migration/V25__add_ocr_job_tables.sql @@ -0,0 +1,26 @@ +CREATE TABLE ocr_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + total_documents INT NOT NULL, + processed_documents INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + skipped_count INT NOT NULL DEFAULT 0, + created_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE ocr_job_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES ocr_jobs(id) ON DELETE CASCADE, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + error_message TEXT, + current_page INT DEFAULT 0, + total_pages INT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_ocr_job_documents_job_id ON ocr_job_documents(job_id); +CREATE INDEX idx_ocr_job_documents_document_id ON ocr_job_documents(document_id); diff --git a/backend/src/main/resources/db/migration/V26__add_source_and_reviewed_to_transcription_blocks.sql b/backend/src/main/resources/db/migration/V26__add_source_and_reviewed_to_transcription_blocks.sql new file mode 100644 index 00000000..de655f91 --- /dev/null +++ b/backend/src/main/resources/db/migration/V26__add_source_and_reviewed_to_transcription_blocks.sql @@ -0,0 +1,2 @@ +ALTER TABLE transcription_blocks ADD COLUMN source VARCHAR(10) NOT NULL DEFAULT 'MANUAL'; +ALTER TABLE transcription_blocks ADD COLUMN reviewed BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/main/resources/db/migration/V27__add_progress_message_to_ocr_jobs.sql b/backend/src/main/resources/db/migration/V27__add_progress_message_to_ocr_jobs.sql new file mode 100644 index 00000000..0b8ed4d2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V27__add_progress_message_to_ocr_jobs.sql @@ -0,0 +1 @@ +ALTER TABLE ocr_jobs ADD COLUMN progress_message TEXT; diff --git a/backend/src/main/resources/db/migration/V28__add_unique_constraint_ocr_job_documents.sql b/backend/src/main/resources/db/migration/V28__add_unique_constraint_ocr_job_documents.sql new file mode 100644 index 00000000..8b476381 --- /dev/null +++ b/backend/src/main/resources/db/migration/V28__add_unique_constraint_ocr_job_documents.sql @@ -0,0 +1,2 @@ +ALTER TABLE ocr_job_documents + ADD CONSTRAINT uq_ocr_job_document UNIQUE (job_id, document_id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java new file mode 100644 index 00000000..a7d6d5cf --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java @@ -0,0 +1,135 @@ +package org.raddatz.familienarchiv.controller; + +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.dto.BatchOcrDTO; +import org.raddatz.familienarchiv.dto.OcrStatusDTO; +import org.raddatz.familienarchiv.dto.TriggerOcrDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.*; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.*; +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.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(OcrController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class OcrControllerTest { + + @Autowired MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean OcrService ocrService; + @MockitoBean OcrBatchService ocrBatchService; + @MockitoBean OcrProgressService ocrProgressService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void triggerOcr_returns202_withJobId() throws Exception { + UUID docId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + TriggerOcrDTO dto = new TriggerOcrDTO(ScriptType.TYPEWRITER); + + when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any())).thenReturn(jobId); + + mockMvc.perform(post("/api/documents/{id}/ocr", docId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.jobId").value(jobId.toString())); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void triggerOcr_returns400_whenDocumentNotUploaded() throws Exception { + UUID docId = UUID.randomUUID(); + when(ocrService.startOcr(eq(docId), any(), any())) + .thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded")); + + mockMvc.perform(post("/api/documents/{id}/ocr", docId) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getJobStatus_returns404_whenJobNotFound() throws Exception { + UUID jobId = UUID.randomUUID(); + when(ocrService.getJob(jobId)) + .thenThrow(DomainException.notFound(ErrorCode.OCR_JOB_NOT_FOUND, "OCR job not found")); + + mockMvc.perform(get("/api/ocr/jobs/{jobId}", jobId)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getJobStatus_returnsJobInfo_whenFound() throws Exception { + UUID jobId = UUID.randomUUID(); + OcrJob job = OcrJob.builder() + .id(jobId) + .status(OcrJobStatus.RUNNING) + .totalDocuments(10) + .processedDocuments(3) + .errorCount(1) + .skippedCount(0) + .build(); + when(ocrService.getJob(jobId)).thenReturn(job); + + mockMvc.perform(get("/api/ocr/jobs/{jobId}", jobId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("RUNNING")) + .andExpect(jsonPath("$.totalDocuments").value(10)) + .andExpect(jsonPath("$.processedDocuments").value(3)); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void triggerBatch_returns202_withJobId() throws Exception { + UUID jobId = UUID.randomUUID(); + List docIds = List.of(UUID.randomUUID(), UUID.randomUUID()); + BatchOcrDTO dto = new BatchOcrDTO(docIds); + + when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId); + + mockMvc.perform(post("/api/ocr/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.jobId").value(jobId.toString())); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getDocumentOcrStatus_returnsNone_whenNoOcrJobExists() throws Exception { + UUID docId = UUID.randomUUID(); + when(ocrService.getDocumentOcrStatus(docId)) + .thenReturn(OcrStatusDTO.builder().status("NONE").build()); + + mockMvc.perform(get("/api/documents/{id}/ocr-status", docId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("NONE")); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java index a891413e..54a9be2a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java @@ -356,4 +356,20 @@ class TranscriptionBlockControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$").isEmpty()); } + + // ─── PUT .../review ────────────────────────────────────────────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void reviewBlock_returns200_withToggledBlock() throws Exception { + TranscriptionBlock reviewed = TranscriptionBlock.builder() + .id(BLOCK_ID).documentId(DOC_ID).annotationId(UUID.randomUUID()) + .text("text").sortOrder(0).reviewed(true).build(); + when(transcriptionService.reviewBlock(DOC_ID, BLOCK_ID)).thenReturn(reviewed); + + mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review", + DOC_ID, BLOCK_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reviewed").value(true)); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dto/UniquePointsValidatorTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dto/UniquePointsValidatorTest.java new file mode 100644 index 00000000..be2690c4 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/dto/UniquePointsValidatorTest.java @@ -0,0 +1,124 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class UniquePointsValidatorTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void shouldAcceptNull() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(null); + + Set> violations = validator.validate(dto); + + assertThat(violations).noneMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldAcceptFourUniquePoints() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).noneMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldRejectDuplicatePoints() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1, 0.1), + List.of(0.1, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldRejectPolygonWithThreePoints() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldRejectPolygonWithFivePoints() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1, 0.1), + List.of(0.5, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldRejectCoordinateOutOfRange() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(1.5, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon")); + } + + @Test + void shouldRejectNegativeCoordinate() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(-0.1, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon")); + } + + @Test + void shouldRejectPointWithOneCoordinate() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon")); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/PolygonConverterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/PolygonConverterTest.java new file mode 100644 index 00000000..916cfa2f --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/PolygonConverterTest.java @@ -0,0 +1,65 @@ +package org.raddatz.familienarchiv.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PolygonConverterTest { + + private final PolygonConverter converter = new PolygonConverter(); + + @Test + void convertToDatabaseColumn_returnsNull_whenPolygonIsNull() { + assertThat(converter.convertToDatabaseColumn(null)).isNull(); + } + + @Test + void convertToDatabaseColumn_returnsJsonArray_whenPolygonIsValid() { + List> polygon = List.of( + List.of(0.1, 0.2), + List.of(0.9, 0.2), + List.of(0.9, 0.8), + List.of(0.1, 0.8)); + + String json = converter.convertToDatabaseColumn(polygon); + + assertThat(json).isEqualTo("[[0.1,0.2],[0.9,0.2],[0.9,0.8],[0.1,0.8]]"); + } + + @Test + void convertToEntityAttribute_returnsNull_whenJsonIsNull() { + assertThat(converter.convertToEntityAttribute(null)).isNull(); + } + + @Test + void convertToEntityAttribute_returnsNull_whenJsonIsEmpty() { + assertThat(converter.convertToEntityAttribute("")).isNull(); + } + + @Test + void convertToEntityAttribute_returnsPolygon_whenJsonIsValid() { + String json = "[[0.1,0.2],[0.9,0.2],[0.9,0.8],[0.1,0.8]]"; + + List> polygon = converter.convertToEntityAttribute(json); + + assertThat(polygon).hasSize(4); + assertThat(polygon.get(0)).containsExactly(0.1, 0.2); + assertThat(polygon.get(3)).containsExactly(0.1, 0.8); + } + + @Test + void roundTrip_preservesValues() { + List> original = List.of( + List.of(0.12, 0.08), + List.of(0.88, 0.09), + List.of(0.87, 0.14), + List.of(0.11, 0.13)); + + String json = converter.convertToDatabaseColumn(original); + List> restored = converter.convertToEntityAttribute(json); + + assertThat(restored).isEqualTo(original); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java index 2605cfb1..37652179 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -260,6 +260,55 @@ class AnnotationServiceTest { verify(annotationRepository).save(any()); } + // ─── createOcrAnnotation ────────────────────────────────────────────────── + + @Test + void createOcrAnnotation_skipsOverlapCheck_andSavesWithPolygon() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.8, 0.04, "#00C7B1"); + List> polygon = List.of( + List.of(0.1, 0.1), List.of(0.9, 0.11), + List.of(0.89, 0.14), List.of(0.11, 0.13)); + when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + DocumentAnnotation result = annotationService.createOcrAnnotation( + docId, dto, userId, "filehash", polygon); + + assertThat(result.getPolygon()).isEqualTo(polygon); + assertThat(result.getDocumentId()).isEqualTo(docId); + verify(annotationRepository).save(any()); + verify(annotationRepository, never()).findByDocumentIdAndPageNumber(any(), any(int.class)); + } + + @Test + void createOcrAnnotation_savesWithNullPolygon_whenPolygonNotProvided() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.8, 0.04, "#00C7B1"); + when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + DocumentAnnotation result = annotationService.createOcrAnnotation( + docId, dto, userId, "filehash", null); + + assertThat(result.getPolygon()).isNull(); + verify(annotationRepository).save(any()); + } + + @Test + void createOcrAnnotation_doesNotCheckOverlap_evenWhenOverlappingAnnotationExists() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#00C7B1"); + when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + annotationService.createOcrAnnotation(docId, dto, userId, "hash", null); + + verify(annotationRepository, never()).findByDocumentIdAndPageNumber(any(), any(int.class)); + } + + // ─── overlaps — partial overlap cases ──────────────────────────────────── + @Test void createAnnotation_noConflict_whenAnnotationIsAbove() { // x ranges overlap, y ranges don't — existing is ABOVE the new annotation diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java index 187c144e..e043c3b7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java @@ -32,7 +32,7 @@ class FileServiceTest { @BeforeEach void setUp() { s3Client = mock(S3Client.class); - fileService = new FileService(s3Client, "test-bucket"); + fileService = new FileService(s3Client, null, "test-bucket"); } @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrAsyncRunnerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrAsyncRunnerTest.java new file mode 100644 index 00000000..4c580c19 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrAsyncRunnerTest.java @@ -0,0 +1,269 @@ +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.model.*; +import org.raddatz.familienarchiv.repository.OcrJobDocumentRepository; +import org.raddatz.familienarchiv.repository.OcrJobRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OcrAsyncRunnerTest { + + @Mock OcrClient ocrClient; + @Mock DocumentService documentService; + @Mock TranscriptionService transcriptionService; + @Mock AnnotationService annotationService; + @Mock FileService fileService; + @Mock OcrJobRepository ocrJobRepository; + @Mock OcrJobDocumentRepository ocrJobDocumentRepository; + @Mock OcrProgressService ocrProgressService; + + @InjectMocks OcrAsyncRunner ocrAsyncRunner; + + @Test + void processDocument_clearsExistingBlocks() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Document doc = Document.builder().id(docId).filePath("test.pdf") + .fileHash("hash").scriptType(ScriptType.TYPEWRITER).build(); + + when(fileService.generatePresignedUrl(any())).thenReturn("http://presigned"); + when(ocrClient.extractBlocks(any(), any())).thenReturn(List.of()); + + ocrAsyncRunner.processDocument(docId, doc, userId); + + verify(transcriptionService).deleteAllBlocksByDocument(docId); + } + + @Test + void processDocument_createsAnnotationAndBlock_forEachResult() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID annId = UUID.randomUUID(); + Document doc = Document.builder().id(docId).filePath("test.pdf") + .fileHash("hash").scriptType(ScriptType.TYPEWRITER).build(); + + + when(fileService.generatePresignedUrl(any())).thenReturn("http://presigned"); + when(ocrClient.extractBlocks(any(), any())).thenReturn(List.of( + new OcrBlockResult(0, 0.1, 0.1, 0.8, 0.04, null, "Line 1"), + new OcrBlockResult(0, 0.1, 0.2, 0.8, 0.04, null, "Line 2"))); + DocumentAnnotation ann = DocumentAnnotation.builder().id(annId).build(); + when(annotationService.createOcrAnnotation(any(), any(), any(), any(), any())).thenReturn(ann); + + ocrAsyncRunner.processDocument(docId, doc, userId); + + verify(annotationService, times(2)).createOcrAnnotation( + eq(docId), any(CreateAnnotationDTO.class), eq(userId), eq("hash"), any()); + verify(transcriptionService, times(2)).createOcrBlock( + eq(docId), eq(annId), any(), anyInt(), eq(userId)); + } + + @Test + void processDocument_delegatesBlockCreationToTranscriptionService() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID annId = UUID.randomUUID(); + Document doc = Document.builder().id(docId).filePath("test.pdf") + .fileHash("hash").scriptType(ScriptType.TYPEWRITER).build(); + + + when(fileService.generatePresignedUrl(any())).thenReturn("http://presigned"); + when(ocrClient.extractBlocks(any(), any())).thenReturn(List.of( + new OcrBlockResult(0, 0.1, 0.1, 0.8, 0.04, null, "Test"))); + DocumentAnnotation ann = DocumentAnnotation.builder().id(annId).build(); + when(annotationService.createOcrAnnotation(any(), any(), any(), any(), any())).thenReturn(ann); + + ocrAsyncRunner.processDocument(docId, doc, userId); + + verify(transcriptionService).createOcrBlock(docId, annId, "Test", 0, userId); + } + + @Test + void runSingleDocument_setsJobDone_onSuccess() { + UUID jobId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + OcrJob job = OcrJob.builder().id(jobId).totalDocuments(1).status(OcrJobStatus.PENDING).build(); + OcrJobDocument jobDoc = OcrJobDocument.builder().id(UUID.randomUUID()) + .jobId(jobId).documentId(docId).status(OcrDocumentStatus.PENDING).build(); + Document doc = Document.builder().id(docId).filePath("test.pdf") + .fileHash("hash").scriptType(ScriptType.TYPEWRITER).build(); + + when(ocrJobRepository.findById(jobId)).thenReturn(Optional.of(job)); + when(ocrJobRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(ocrJobDocumentRepository.findByJobIdAndDocumentId(jobId, docId)) + .thenReturn(Optional.of(jobDoc)); + when(documentService.getDocumentById(docId)).thenReturn(doc); + + when(fileService.generatePresignedUrl(any())).thenReturn("http://presigned"); + doAnswer(inv -> { + Consumer handler = inv.getArgument(2); + handler.accept(new OcrStreamEvent.Start(1)); + handler.accept(new OcrStreamEvent.Page(0, List.of())); + handler.accept(new OcrStreamEvent.Done(0, 0)); + return null; + }).when(ocrClient).streamBlocks(any(), any(), any()); + + ocrAsyncRunner.runSingleDocument(jobId, docId, userId); + + assertThat(job.getStatus()).isEqualTo(OcrJobStatus.DONE); + } + + @Test + void runSingleDocument_setsJobFailed_onError() { + UUID jobId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + OcrJob job = OcrJob.builder().id(jobId).totalDocuments(1).status(OcrJobStatus.PENDING).build(); + OcrJobDocument jobDoc = OcrJobDocument.builder().id(UUID.randomUUID()) + .jobId(jobId).documentId(docId).status(OcrDocumentStatus.PENDING).build(); + Document doc = Document.builder().id(docId).filePath("test.pdf") + .fileHash("hash").scriptType(ScriptType.TYPEWRITER).build(); + + when(ocrJobRepository.findById(jobId)).thenReturn(Optional.of(job)); + when(ocrJobRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(ocrJobDocumentRepository.findByJobIdAndDocumentId(jobId, docId)) + .thenReturn(Optional.of(jobDoc)); + when(documentService.getDocumentById(docId)).thenReturn(doc); + + when(fileService.generatePresignedUrl(any())).thenReturn("http://presigned"); + doThrow(new RuntimeException("OCR failed")).when(ocrClient).streamBlocks(any(), any(), any()); + + ocrAsyncRunner.runSingleDocument(jobId, docId, userId); + + assertThat(job.getStatus()).isEqualTo(OcrJobStatus.FAILED); + assertThat(job.getErrorCount()).isEqualTo(1); + } + + @Test + void runSingleDocument_updatesProgressPerPage() { + UUID jobId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + OcrJob job = OcrJob.builder().id(jobId).totalDocuments(1).status(OcrJobStatus.PENDING).build(); + OcrJobDocument jobDoc = OcrJobDocument.builder().id(UUID.randomUUID()) + .jobId(jobId).documentId(docId).status(OcrDocumentStatus.PENDING).build(); + Document doc = Document.builder().id(docId).filePath("test.pdf") + .fileHash("hash").scriptType(ScriptType.TYPEWRITER).build(); + DocumentAnnotation ann = DocumentAnnotation.builder().id(UUID.randomUUID()).build(); + + when(ocrJobRepository.findById(jobId)).thenReturn(Optional.of(job)); + when(ocrJobRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(ocrJobDocumentRepository.findByJobIdAndDocumentId(jobId, docId)) + .thenReturn(Optional.of(jobDoc)); + when(ocrJobDocumentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(documentService.getDocumentById(docId)).thenReturn(doc); + + when(fileService.generatePresignedUrl(any())).thenReturn("http://presigned"); + when(annotationService.createOcrAnnotation(any(), any(), any(), any(), any())).thenReturn(ann); + + List progressMessages = new ArrayList<>(); + doAnswer(inv -> { + Consumer handler = inv.getArgument(2); + handler.accept(new OcrStreamEvent.Start(3)); + handler.accept(new OcrStreamEvent.Page(0, List.of( + new OcrBlockResult(0, 0.1, 0.1, 0.8, 0.04, null, "L1"), + new OcrBlockResult(0, 0.1, 0.2, 0.8, 0.04, null, "L2")))); + progressMessages.add(job.getProgressMessage()); + handler.accept(new OcrStreamEvent.Page(1, List.of( + new OcrBlockResult(1, 0.1, 0.1, 0.8, 0.04, null, "L3")))); + progressMessages.add(job.getProgressMessage()); + handler.accept(new OcrStreamEvent.Done(3, 0)); + return null; + }).when(ocrClient).streamBlocks(any(), any(), any()); + + ocrAsyncRunner.runSingleDocument(jobId, docId, userId); + + assertThat(progressMessages.get(0)).isEqualTo("ANALYZING_PAGE:1:3:2"); + assertThat(progressMessages.get(1)).isEqualTo("ANALYZING_PAGE:2:3:3"); + assertThat(job.getProgressMessage()).isEqualTo("DONE:3:0"); + } + + @Test + void runSingleDocument_includesSkippedPagesInDoneMessage() { + UUID jobId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + OcrJob job = OcrJob.builder().id(jobId).totalDocuments(1).status(OcrJobStatus.PENDING).build(); + OcrJobDocument jobDoc = OcrJobDocument.builder().id(UUID.randomUUID()) + .jobId(jobId).documentId(docId).status(OcrDocumentStatus.PENDING).build(); + Document doc = Document.builder().id(docId).filePath("test.pdf") + .fileHash("hash").scriptType(ScriptType.TYPEWRITER).build(); + + when(ocrJobRepository.findById(jobId)).thenReturn(Optional.of(job)); + when(ocrJobRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(ocrJobDocumentRepository.findByJobIdAndDocumentId(jobId, docId)) + .thenReturn(Optional.of(jobDoc)); + when(ocrJobDocumentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(documentService.getDocumentById(docId)).thenReturn(doc); + + when(fileService.generatePresignedUrl(any())).thenReturn("http://presigned"); + + doAnswer(inv -> { + Consumer handler = inv.getArgument(2); + handler.accept(new OcrStreamEvent.Start(3)); + handler.accept(new OcrStreamEvent.Page(0, List.of())); + handler.accept(new OcrStreamEvent.Error(1, "failed")); + handler.accept(new OcrStreamEvent.Page(2, List.of())); + handler.accept(new OcrStreamEvent.Done(0, 1)); + return null; + }).when(ocrClient).streamBlocks(any(), any(), any()); + + ocrAsyncRunner.runSingleDocument(jobId, docId, userId); + + assertThat(job.getStatus()).isEqualTo(OcrJobStatus.DONE); + assertThat(job.getProgressMessage()).isEqualTo("DONE:0:1"); + } + + @Test + void runSingleDocument_logsStreamErrorAtWarnWithoutSettingJobFailed() { + UUID jobId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + OcrJob job = OcrJob.builder().id(jobId).totalDocuments(1).status(OcrJobStatus.PENDING).build(); + OcrJobDocument jobDoc = OcrJobDocument.builder().id(UUID.randomUUID()) + .jobId(jobId).documentId(docId).status(OcrDocumentStatus.PENDING).build(); + Document doc = Document.builder().id(docId).filePath("test.pdf") + .fileHash("hash").scriptType(ScriptType.TYPEWRITER).build(); + + when(ocrJobRepository.findById(jobId)).thenReturn(Optional.of(job)); + when(ocrJobRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(ocrJobDocumentRepository.findByJobIdAndDocumentId(jobId, docId)) + .thenReturn(Optional.of(jobDoc)); + when(ocrJobDocumentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(documentService.getDocumentById(docId)).thenReturn(doc); + + when(fileService.generatePresignedUrl(any())).thenReturn("http://presigned"); + + doAnswer(inv -> { + Consumer handler = inv.getArgument(2); + handler.accept(new OcrStreamEvent.Start(2)); + handler.accept(new OcrStreamEvent.Error(0, "some python traceback details")); + handler.accept(new OcrStreamEvent.Page(1, List.of())); + handler.accept(new OcrStreamEvent.Done(0, 1)); + return null; + }).when(ocrClient).streamBlocks(any(), any(), any()); + + ocrAsyncRunner.runSingleDocument(jobId, docId, userId); + + // Job should still be DONE, not FAILED (per-page errors don't fail the whole job) + assertThat(job.getStatus()).isEqualTo(OcrJobStatus.DONE); + // Raw error message should not leak to progress + assertThat(job.getProgressMessage()).doesNotContain("python traceback"); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrBatchServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrBatchServiceTest.java new file mode 100644 index 00000000..875b5303 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrBatchServiceTest.java @@ -0,0 +1,80 @@ +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.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.*; +import org.raddatz.familienarchiv.repository.OcrJobDocumentRepository; +import org.raddatz.familienarchiv.repository.OcrJobRepository; + +import java.util.List; +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.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OcrBatchServiceTest { + + @Mock OcrHealthClient ocrHealthClient; + @Mock OcrJobRepository ocrJobRepository; + @Mock OcrJobDocumentRepository ocrJobDocumentRepository; + @Mock OcrAsyncRunner ocrAsyncRunner; + + @InjectMocks OcrBatchService ocrBatchService; + + @Test + void startBatch_throwsServiceUnavailable_whenOcrServiceIsDown() { + when(ocrHealthClient.isHealthy()).thenReturn(false); + + assertThatThrownBy(() -> ocrBatchService.startBatch(List.of(UUID.randomUUID()), UUID.randomUUID())) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.OCR_SERVICE_UNAVAILABLE)); + } + + @Test + void startBatch_createsJobAndDispatchesAsync() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + + when(ocrHealthClient.isHealthy()).thenReturn(true); + when(ocrJobRepository.save(any())).thenAnswer(inv -> { + OcrJob job = inv.getArgument(0); + job.setId(jobId); + return job; + }); + when(ocrJobDocumentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UUID result = ocrBatchService.startBatch(List.of(docId), userId); + + assertThat(result).isEqualTo(jobId); + verify(ocrAsyncRunner).runBatch(jobId, userId); + } + + @Test + void startBatch_createsJobDocumentForEachId() { + UUID doc1 = UUID.randomUUID(); + UUID doc2 = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + when(ocrHealthClient.isHealthy()).thenReturn(true); + when(ocrJobRepository.save(any())).thenAnswer(inv -> { + OcrJob job = inv.getArgument(0); + job.setId(UUID.randomUUID()); + return job; + }); + when(ocrJobDocumentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + ocrBatchService.startBatch(List.of(doc1, doc2), userId); + + verify(ocrJobDocumentRepository, times(2)).save(any()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrClientDefaultStreamTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrClientDefaultStreamTest.java new file mode 100644 index 00000000..42219299 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrClientDefaultStreamTest.java @@ -0,0 +1,55 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.model.ScriptType; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class OcrClientDefaultStreamTest { + + @Test + void defaultStreamBlocksSynthesizesEventsFromExtractBlocks() { + OcrClient client = (pdfUrl, scriptType) -> List.of( + new OcrBlockResult(0, 0.1, 0.1, 0.8, 0.04, null, "Line 1"), + new OcrBlockResult(0, 0.1, 0.2, 0.8, 0.04, null, "Line 2"), + new OcrBlockResult(1, 0.1, 0.1, 0.8, 0.04, null, "Line 3")); + + List events = new ArrayList<>(); + client.streamBlocks("http://test", ScriptType.TYPEWRITER, events::add); + + assertThat(events).hasSize(4); + assertThat(events.get(0)).isInstanceOf(OcrStreamEvent.Start.class); + assertThat(((OcrStreamEvent.Start) events.get(0)).totalPages()).isEqualTo(2); + + assertThat(events.get(1)).isInstanceOf(OcrStreamEvent.Page.class); + var page0 = (OcrStreamEvent.Page) events.get(1); + assertThat(page0.pageNumber()).isEqualTo(0); + assertThat(page0.blocks()).hasSize(2); + + assertThat(events.get(2)).isInstanceOf(OcrStreamEvent.Page.class); + var page1 = (OcrStreamEvent.Page) events.get(2); + assertThat(page1.pageNumber()).isEqualTo(1); + assertThat(page1.blocks()).hasSize(1); + + assertThat(events.get(3)).isInstanceOf(OcrStreamEvent.Done.class); + var done = (OcrStreamEvent.Done) events.get(3); + assertThat(done.totalBlocks()).isEqualTo(3); + assertThat(done.skippedPages()).isEqualTo(0); + } + + @Test + void defaultStreamBlocksHandlesEmptyResults() { + OcrClient client = (pdfUrl, scriptType) -> List.of(); + + List events = new ArrayList<>(); + client.streamBlocks("http://test", ScriptType.TYPEWRITER, events::add); + + assertThat(events).hasSize(2); + assertThat(events.get(0)).isInstanceOf(OcrStreamEvent.Start.class); + assertThat(((OcrStreamEvent.Start) events.get(0)).totalPages()).isEqualTo(0); + assertThat(events.get(1)).isInstanceOf(OcrStreamEvent.Done.class); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrProgressServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrProgressServiceTest.java new file mode 100644 index 00000000..44ed276f --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrProgressServiceTest.java @@ -0,0 +1,33 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class OcrProgressServiceTest { + + private final OcrProgressService progressService = new OcrProgressService(); + + @Test + void register_returnsNonNullEmitter() { + UUID jobId = UUID.randomUUID(); + SseEmitter emitter = progressService.register(jobId); + assertThat(emitter).isNotNull(); + } + + @Test + void emit_doesNotThrow_whenNoEmittersRegistered() { + assertThatCode(() -> progressService.emit(UUID.randomUUID(), "test", "data")) + .doesNotThrowAnyException(); + } + + @Test + void complete_doesNotThrow_whenNoEmittersRegistered() { + assertThatCode(() -> progressService.complete(UUID.randomUUID())) + .doesNotThrowAnyException(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrServiceTest.java new file mode 100644 index 00000000..f9932616 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrServiceTest.java @@ -0,0 +1,165 @@ +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.OcrStatusDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.*; +import org.raddatz.familienarchiv.repository.OcrJobDocumentRepository; +import org.raddatz.familienarchiv.repository.OcrJobRepository; + +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.Mockito.*; +import static org.springframework.http.HttpStatus.*; + +@ExtendWith(MockitoExtension.class) +class OcrServiceTest { + + @Mock OcrHealthClient ocrHealthClient; + @Mock DocumentService documentService; + @Mock OcrJobRepository ocrJobRepository; + @Mock OcrJobDocumentRepository ocrJobDocumentRepository; + @Mock OcrAsyncRunner ocrAsyncRunner; + + @InjectMocks OcrService ocrService; + + // ─── getJob ────────────────────────────────────────────────────────────────── + + @Test + void getJob_returnsJob_whenFound() { + UUID jobId = UUID.randomUUID(); + OcrJob job = OcrJob.builder().id(jobId).status(OcrJobStatus.RUNNING).build(); + when(ocrJobRepository.findById(jobId)).thenReturn(Optional.of(job)); + + OcrJob result = ocrService.getJob(jobId); + + assertThat(result).isEqualTo(job); + } + + @Test + void getJob_throwsNotFound_whenJobDoesNotExist() { + UUID jobId = UUID.randomUUID(); + when(ocrJobRepository.findById(jobId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> ocrService.getJob(jobId)) + .isInstanceOf(DomainException.class) + .satisfies(e -> { + DomainException de = (DomainException) e; + assertThat(de.getStatus()).isEqualTo(NOT_FOUND); + assertThat(de.getCode()).isEqualTo(ErrorCode.OCR_JOB_NOT_FOUND); + }); + } + + // ─── getDocumentOcrStatus ─────────────────────────────────────────────────── + + @Test + void getDocumentOcrStatus_returnsNone_whenNoActiveJob() { + UUID docId = UUID.randomUUID(); + when(ocrJobDocumentRepository.findFirstByDocumentIdAndStatusIn(any(), any())) + .thenReturn(Optional.empty()); + + OcrStatusDTO result = ocrService.getDocumentOcrStatus(docId); + + assertThat(result.getStatus()).isEqualTo("NONE"); + assertThat(result.getJobId()).isNull(); + } + + @Test + void getDocumentOcrStatus_returnsActiveStatus_whenJobExists() { + UUID docId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + OcrJobDocument jobDoc = OcrJobDocument.builder() + .jobId(jobId).documentId(docId) + .status(OcrDocumentStatus.RUNNING) + .currentPage(2).totalPages(5) + .build(); + when(ocrJobDocumentRepository.findFirstByDocumentIdAndStatusIn(any(), any())) + .thenReturn(Optional.of(jobDoc)); + + OcrStatusDTO result = ocrService.getDocumentOcrStatus(docId); + + assertThat(result.getStatus()).isEqualTo("RUNNING"); + assertThat(result.getJobId()).isEqualTo(jobId); + assertThat(result.getCurrentPage()).isEqualTo(2); + assertThat(result.getTotalPages()).isEqualTo(5); + } + + // ─── startOcr ─────────────────────────────────────────────────────────────── + + @Test + void startOcr_throwsBadRequest_whenDocumentIsPlaceholder() { + UUID docId = UUID.randomUUID(); + Document doc = Document.builder().id(docId).status(DocumentStatus.PLACEHOLDER).build(); + when(documentService.getDocumentById(docId)).thenReturn(doc); + + assertThatThrownBy(() -> ocrService.startOcr(docId, null, UUID.randomUUID())) + .isInstanceOf(DomainException.class) + .satisfies(e -> { + DomainException de = (DomainException) e; + assertThat(de.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(de.getCode()).isEqualTo(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED); + }); + } + + @Test + void startOcr_throwsServiceUnavailable_whenOcrServiceIsDown() { + UUID docId = UUID.randomUUID(); + Document doc = Document.builder().id(docId).status(DocumentStatus.UPLOADED) + .filePath("test.pdf").build(); + when(documentService.getDocumentById(docId)).thenReturn(doc); + when(ocrHealthClient.isHealthy()).thenReturn(false); + + assertThatThrownBy(() -> ocrService.startOcr(docId, null, UUID.randomUUID())) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.OCR_SERVICE_UNAVAILABLE)); + } + + @Test + void startOcr_createsJobAndDispatchesAsync() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + Document doc = Document.builder().id(docId).status(DocumentStatus.UPLOADED) + .filePath("test.pdf").scriptType(ScriptType.TYPEWRITER).build(); + when(documentService.getDocumentById(docId)).thenReturn(doc); + when(ocrHealthClient.isHealthy()).thenReturn(true); + when(ocrJobRepository.save(any())).thenAnswer(inv -> { + OcrJob job = inv.getArgument(0); + job.setId(jobId); + return job; + }); + + UUID result = ocrService.startOcr(docId, null, userId); + + assertThat(result).isEqualTo(jobId); + verify(ocrAsyncRunner).runSingleDocument(jobId, docId, userId); + } + + @Test + void startOcr_updatesScriptType_whenProvided() { + UUID docId = UUID.randomUUID(); + Document doc = Document.builder().id(docId).status(DocumentStatus.UPLOADED) + .filePath("test.pdf").scriptType(ScriptType.UNKNOWN).build(); + when(documentService.getDocumentById(docId)).thenReturn(doc); + when(ocrHealthClient.isHealthy()).thenReturn(true); + when(ocrJobRepository.save(any())).thenAnswer(inv -> { + OcrJob job = inv.getArgument(0); + job.setId(UUID.randomUUID()); + return job; + }); + + ocrService.startOcr(docId, ScriptType.HANDWRITING_LATIN, UUID.randomUUID()); + + verify(documentService).updateScriptType(docId, ScriptType.HANDWRITING_LATIN); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrStreamEventTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrStreamEventTest.java new file mode 100644 index 00000000..70dc7866 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrStreamEventTest.java @@ -0,0 +1,51 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class OcrStreamEventTest { + + @Test + void startRecordHoldsTotalPages() { + var start = new OcrStreamEvent.Start(5); + assertThat(start.totalPages()).isEqualTo(5); + assertThat(start).isInstanceOf(OcrStreamEvent.class); + } + + @Test + void pageRecordHoldsBlocksAndPageNumber() { + var block = new OcrBlockResult(0, 0.1, 0.2, 0.8, 0.1, null, "Test"); + var page = new OcrStreamEvent.Page(0, List.of(block)); + assertThat(page.pageNumber()).isEqualTo(0); + assertThat(page.blocks()).hasSize(1); + } + + @Test + void errorRecordHoldsPageAndMessage() { + var error = new OcrStreamEvent.Error(2, "failed"); + assertThat(error.pageNumber()).isEqualTo(2); + assertThat(error.message()).isEqualTo("failed"); + } + + @Test + void doneRecordHoldsTotalBlocksAndSkippedPages() { + var done = new OcrStreamEvent.Done(12, 2); + assertThat(done.totalBlocks()).isEqualTo(12); + assertThat(done.skippedPages()).isEqualTo(2); + } + + @Test + void patternMatchingWorksOnSealedInterface() { + OcrStreamEvent event = new OcrStreamEvent.Start(3); + String result = switch (event) { + case OcrStreamEvent.Start s -> "start:" + s.totalPages(); + case OcrStreamEvent.Page p -> "page:" + p.pageNumber(); + case OcrStreamEvent.Error e -> "error:" + e.pageNumber(); + case OcrStreamEvent.Done d -> "done:" + d.totalBlocks(); + }; + assertThat(result).isEqualTo("start:3"); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/RestClientOcrClientStreamTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/RestClientOcrClientStreamTest.java new file mode 100644 index 00000000..2812f0bf --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/RestClientOcrClientStreamTest.java @@ -0,0 +1,134 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestClientOcrClientStreamTest { + + @Test + void parseNdjsonStream_dispatchesStartPageDoneInOrder() { + String ndjson = """ + {"type":"start","totalPages":2} + {"type":"page","pageNumber":0,"blocks":[{"pageNumber":0,"x":0.1,"y":0.2,"width":0.8,"height":0.1,"polygon":null,"text":"Line 1"}]} + {"type":"page","pageNumber":1,"blocks":[{"pageNumber":1,"x":0.1,"y":0.3,"width":0.8,"height":0.1,"polygon":null,"text":"Line 2"}]} + {"type":"done","totalBlocks":2,"skippedPages":0} + """; + InputStream stream = new ByteArrayInputStream(ndjson.getBytes(StandardCharsets.UTF_8)); + + List events = new ArrayList<>(); + RestClientOcrClient.parseNdjsonStream(stream, events::add); + + assertThat(events).hasSize(4); + assertThat(events.get(0)).isInstanceOf(OcrStreamEvent.Start.class); + assertThat(((OcrStreamEvent.Start) events.get(0)).totalPages()).isEqualTo(2); + + assertThat(events.get(1)).isInstanceOf(OcrStreamEvent.Page.class); + var page0 = (OcrStreamEvent.Page) events.get(1); + assertThat(page0.pageNumber()).isEqualTo(0); + assertThat(page0.blocks()).hasSize(1); + assertThat(page0.blocks().get(0).text()).isEqualTo("Line 1"); + + assertThat(events.get(2)).isInstanceOf(OcrStreamEvent.Page.class); + var page1 = (OcrStreamEvent.Page) events.get(2); + assertThat(page1.pageNumber()).isEqualTo(1); + + assertThat(events.get(3)).isInstanceOf(OcrStreamEvent.Done.class); + var done = (OcrStreamEvent.Done) events.get(3); + assertThat(done.totalBlocks()).isEqualTo(2); + assertThat(done.skippedPages()).isEqualTo(0); + } + + @Test + void parseNdjsonStream_parsesErrorEvents() { + String ndjson = """ + {"type":"start","totalPages":3} + {"type":"page","pageNumber":0,"blocks":[]} + {"type":"error","pageNumber":1,"message":"OCR processing failed on page 1"} + {"type":"page","pageNumber":2,"blocks":[]} + {"type":"done","totalBlocks":0,"skippedPages":1} + """; + InputStream stream = new ByteArrayInputStream(ndjson.getBytes(StandardCharsets.UTF_8)); + + List events = new ArrayList<>(); + RestClientOcrClient.parseNdjsonStream(stream, events::add); + + assertThat(events).hasSize(5); + assertThat(events.get(2)).isInstanceOf(OcrStreamEvent.Error.class); + var error = (OcrStreamEvent.Error) events.get(2); + assertThat(error.pageNumber()).isEqualTo(1); + assertThat(error.message()).contains("OCR processing failed"); + } + + @Test + void parseNdjsonStream_skipsBlankLines() { + String ndjson = """ + {"type":"start","totalPages":1} + + {"type":"page","pageNumber":0,"blocks":[]} + + {"type":"done","totalBlocks":0,"skippedPages":0} + """; + InputStream stream = new ByteArrayInputStream(ndjson.getBytes(StandardCharsets.UTF_8)); + + List events = new ArrayList<>(); + RestClientOcrClient.parseNdjsonStream(stream, events::add); + + assertThat(events).hasSize(3); + } + + @Test + void parseNdjsonStream_ignoresUnknownEventTypes() { + String ndjson = """ + {"type":"start","totalPages":1} + {"type":"unknown","foo":"bar"} + {"type":"done","totalBlocks":0,"skippedPages":0} + """; + InputStream stream = new ByteArrayInputStream(ndjson.getBytes(StandardCharsets.UTF_8)); + + List events = new ArrayList<>(); + RestClientOcrClient.parseNdjsonStream(stream, events::add); + + assertThat(events).hasSize(2); + } + + @Test + void parseNdjsonStream_handlesUnknownFieldsInBlocks() { + String ndjson = """ + {"type":"start","totalPages":1} + {"type":"page","pageNumber":0,"blocks":[{"pageNumber":0,"x":0.1,"y":0.2,"width":0.8,"height":0.1,"polygon":null,"text":"Line 1","confidence":0.95,"newFutureField":"ignored"}]} + {"type":"done","totalBlocks":1,"skippedPages":0} + """; + InputStream stream = new ByteArrayInputStream(ndjson.getBytes(StandardCharsets.UTF_8)); + + List events = new ArrayList<>(); + RestClientOcrClient.parseNdjsonStream(stream, events::add); + + assertThat(events).hasSize(3); + var page = (OcrStreamEvent.Page) events.get(1); + assertThat(page.blocks().get(0).text()).isEqualTo("Line 1"); + } + + @Test + void parseNdjsonStream_parsesPageWithPolygon() { + String ndjson = """ + {"type":"start","totalPages":1} + {"type":"page","pageNumber":0,"blocks":[{"pageNumber":0,"x":0.1,"y":0.2,"width":0.8,"height":0.1,"polygon":[[0.1,0.2],[0.9,0.2],[0.9,0.3],[0.1,0.3]],"text":"With polygon"}]} + {"type":"done","totalBlocks":1,"skippedPages":0} + """; + InputStream stream = new ByteArrayInputStream(ndjson.getBytes(StandardCharsets.UTF_8)); + + List events = new ArrayList<>(); + RestClientOcrClient.parseNdjsonStream(stream, events::add); + + var page = (OcrStreamEvent.Page) events.get(1); + assertThat(page.blocks().get(0).polygon()).hasSize(4); + assertThat(page.blocks().get(0).text()).isEqualTo("With polygon"); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java index ebe02d10..f8ca7753 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -10,6 +10,7 @@ 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.BlockSource; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.TranscriptionBlock; @@ -26,8 +27,8 @@ 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.mockito.Mockito.*; + import static org.springframework.http.HttpStatus.NOT_FOUND; @ExtendWith(MockitoExtension.class) @@ -99,6 +100,50 @@ class TranscriptionServiceTest { verify(versionRepository).save(any(TranscriptionBlockVersion.class)); } + // ─── createOcrBlock ────────────────────────────────────────────────────────── + + @Test + void createOcrBlock_createsBlockWithOcrSourceAndSavesVersion() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + when(blockRepository.save(any())).thenAnswer(inv -> { + TranscriptionBlock b = inv.getArgument(0); + b.setId(UUID.randomUUID()); + return b; + }); + + TranscriptionBlock result = transcriptionService.createOcrBlock( + docId, annotId, "OCR text", 3, userId); + + assertThat(result.getAnnotationId()).isEqualTo(annotId); + assertThat(result.getDocumentId()).isEqualTo(docId); + assertThat(result.getText()).isEqualTo("OCR text"); + assertThat(result.getSortOrder()).isEqualTo(3); + assertThat(result.getSource()).isEqualTo(BlockSource.OCR); + assertThat(result.getCreatedBy()).isEqualTo(userId); + verify(versionRepository).save(any(TranscriptionBlockVersion.class)); + } + + @Test + void createOcrBlock_sanitizesNullText() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + when(blockRepository.save(any())).thenAnswer(inv -> { + TranscriptionBlock b = inv.getArgument(0); + b.setId(UUID.randomUUID()); + return b; + }); + + TranscriptionBlock result = transcriptionService.createOcrBlock( + docId, annotId, null, 0, userId); + + assertThat(result.getText()).isEmpty(); + } + // ─── updateBlock ───────────────────────────────────────────────────────────── @Test @@ -168,6 +213,39 @@ class TranscriptionServiceTest { .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); } + // ─── deleteAllBlocksByDocument ───────────────────────────────────────────── + + @Test + void deleteAllBlocksByDocument_deletesAllBlocksAndAnnotations() { + UUID docId = UUID.randomUUID(); + UUID annId1 = UUID.randomUUID(); + UUID annId2 = UUID.randomUUID(); + + TranscriptionBlock block1 = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).annotationId(annId1).sortOrder(0).build(); + TranscriptionBlock block2 = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).annotationId(annId2).sortOrder(1).build(); + + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)) + .thenReturn(List.of(block1, block2)); + + transcriptionService.deleteAllBlocksByDocument(docId); + + verify(blockRepository).deleteAll(List.of(block1, block2)); + verify(blockRepository).flush(); + verify(annotationRepository).deleteAllById(List.of(annId1, annId2)); + } + + @Test + void deleteAllBlocksByDocument_doesNothing_whenNoBlocksExist() { + UUID docId = UUID.randomUUID(); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of()); + + transcriptionService.deleteAllBlocksByDocument(docId); + + verify(blockRepository, never()).deleteAll(any()); + } + // ─── reorderBlocks ─────────────────────────────────────────────────────────── @Test @@ -243,4 +321,47 @@ class TranscriptionServiceTest { assertThat(transcriptionService.listBlocks(docId)).containsExactly(b); } + + // ─── reviewBlock ───────────────────────────────────────────────────────── + + @Test + void reviewBlock_setsReviewedTrue() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).annotationId(UUID.randomUUID()) + .text("corrected text").sortOrder(0).reviewed(false).build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + TranscriptionBlock result = transcriptionService.reviewBlock(docId, blockId); + + assertThat(result.isReviewed()).isTrue(); + verify(blockRepository).save(block); + } + + @Test + void reviewBlock_togglesReviewedFalse_whenAlreadyReviewed() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).annotationId(UUID.randomUUID()) + .text("corrected text").sortOrder(0).reviewed(true).build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + TranscriptionBlock result = transcriptionService.reviewBlock(docId, blockId); + + assertThat(result.isReviewed()).isFalse(); + } + + @Test + void reviewBlock_throwsNotFound_whenBlockMissing() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> transcriptionService.reviewBlock(docId, blockId)) + .isInstanceOf(DomainException.class); + } } diff --git a/docker-compose.yml b/docker-compose.yml index 7ceabc66..46ed94b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,35 @@ services: networks: - archive-net + # --- OCR: Python microservice (Surya + Kraken) --- + ocr-service: + build: + context: ./ocr-service + dockerfile: Dockerfile + container_name: archive-ocr + restart: unless-stopped + expose: + - "8000" + mem_limit: 8g + memswap_limit: 8g + volumes: + - ocr_models:/app/models + - ocr_cache:/root/.cache + environment: + KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel + OCR_CONFIDENCE_THRESHOLD: "0.3" + OCR_CONFIDENCE_THRESHOLD_KURRENT: "0.5" + RECOGNITION_BATCH_SIZE: "16" + DETECTOR_BATCH_SIZE: "8" + networks: + - archive-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + # --- Backend: Spring Boot --- backend: build: @@ -89,6 +118,8 @@ services: condition: service_healthy mailpit: condition: service_started + ocr-service: + condition: service_started environment: SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} @@ -109,6 +140,7 @@ services: # Mailpit needs no auth or STARTTLS; production SMTP overrides these via .env SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-false} SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false} + APP_OCR_BASE_URL: http://ocr-service:8000 ports: - "${PORT_BACKEND}:8080" networks: @@ -155,3 +187,5 @@ networks: volumes: frontend_node_modules: maven_cache: + ocr_models: + ocr_cache: diff --git a/docs/adr/001-ocr-python-microservice.md b/docs/adr/001-ocr-python-microservice.md new file mode 100644 index 00000000..869ff950 --- /dev/null +++ b/docs/adr/001-ocr-python-microservice.md @@ -0,0 +1,84 @@ +# ADR-001: OCR Python Microservice + +## Status + +Accepted + +## Context + +The Familienarchiv needs OCR capability to pre-populate transcription blocks from scanned documents. Two OCR engines are required: + +- **Surya** — transformer-based, handles typewritten and modern Latin handwriting +- **Kraken** — historical HTR model support, required for pre-1941 German Kurrent/Suetterlin scripts + +Both engines exist exclusively in the Python ecosystem. There are no production-quality Java bindings for either engine. Tess4J (Tesseract for Java) was considered but rejected: Tesseract has poor accuracy on degraded historical handwriting and no HTR-United model support. + +The server has no GPU. CPU-only inference is the target (16-32 GB system RAM). + +## Decision + +Introduce a separate Python container (`ocr-service`) that exposes a simple HTTP API. Spring Boot calls this service via `RestClient`. The Python service is stateless — all job tracking and business logic remain in Spring Boot. + +**Interface contract:** + +Request: +```json +{ + "pdfUrl": "http://minio:9000/archive-documents/abc.pdf?presigned...", + "scriptType": "HANDWRITING_KURRENT", + "language": "de" +} +``` + +Response: +```json +[ + { + "pageNumber": 0, + "x": 0.12, "y": 0.08, "width": 0.76, "height": 0.04, + "polygon": [[0.12,0.08],[0.88,0.09],[0.87,0.12],[0.13,0.11]], + "text": "Sehr geehrter Herr ..." + } +] +``` + +Coordinates are normalized (0-1) relative to page dimensions. + +**Java-side integration:** + +- `OcrClient` interface with `extractBlocks()` method — mockable for unit tests +- `OcrHealthClient` interface with `isHealthy()` — separate concern from block extraction +- `RestClientOcrClient` implements both interfaces +- `OcrService` orchestrates: presigned URL generation, OCR call, block mapping, TranscriptionService delegation + +**Docker networking:** + +- `ocr-service` is on the internal Docker network only — no host port mapping +- Spring Boot reaches it via `http://ocr-service:8000` +- Health check with `start_period: 60s` to account for model loading (~30-60s on CPU) + +## Alternatives Considered + +| Alternative | Why rejected | +|---|---| +| Tess4J (Tesseract in Java) | No HTR-United model support; poor Kurrent accuracy | +| Calling Python via ProcessBuilder | Fragile, no health checks, model reloading on every call | +| Embedding Python via GraalVM | Experimental, complex dependency management for ML libraries | +| External SaaS OCR (Google Vision, AWS Textract) | Data sovereignty concern for private family documents; no Kurrent support | + +## Consequences + +**Easier:** +- Each engine is used via its native Python API — no bridging complexity +- OCR service can be updated independently of the main application +- Models can be swapped via volume mount without code changes + +**Harder:** +- One additional container to operate (memory, health checks, restarts) +- Integration tests require WireMock stub — real OCR service is too slow for CI +- Presigned URL TTL must be managed (15-30 min recommended) + +## Future Direction + +- LISTEN/NOTIFY from PostgreSQL to push progress events when scaling to multiple instances +- GPU acceleration if the server is upgraded — only the Docker image needs to change diff --git a/docs/adr/002-polygon-jsonb-storage.md b/docs/adr/002-polygon-jsonb-storage.md new file mode 100644 index 00000000..6383759c --- /dev/null +++ b/docs/adr/002-polygon-jsonb-storage.md @@ -0,0 +1,52 @@ +# ADR-002: Polygon JSONB Storage for Annotations + +## Status + +Accepted + +## Context + +Document annotations currently store axis-aligned bounding boxes (`x, y, width, height`). Kraken OCR outputs polygon boundaries for text lines — historical handwriting (Kurrent, Suetterlin) produces rotated and curved text that axis-aligned rectangles approximate poorly. + +We need to store an optional quadrilateral (4 corner points) per annotation to represent the precise text region. The polygon is display-only — overlap detection and all server-side geometry logic continues to use the AABB fields. + +## Decision + +Add a `polygon JSONB` column to `document_annotations`: + +```sql +ALTER TABLE document_annotations ADD COLUMN polygon JSONB; +ALTER TABLE document_annotations +ADD CONSTRAINT chk_annotation_polygon_quad + CHECK (polygon IS NULL OR jsonb_array_length(polygon) = 4); +``` + +- `null` means rectangle — render using existing `x, y, width, height` fields (fully backward compatible) +- Non-null value is a normalized 4-point quadrilateral: `[[x1,y1],[x2,y2],[x3,y3],[x4,y4]]` with coordinates in the 0-1 range relative to page dimensions + +The existing AABB fields are always populated (even when a polygon is present) and remain the authoritative geometry for overlap detection. + +**Java entity:** `List> polygon` backed by a custom `AttributeConverter>, String>`. No new dependency (Hypersistence Utils is not in the project and won't be added for a single column). + +**Semantic invariant:** `polygon`, if present, is a 4-point quadrilateral with coordinates normalized to [0, 1] relative to page dimensions. It may originate from OCR engine output (Kraken) or from a future manual drawing tool. The AABB fields remain the geometry source of truth for server-side logic. + +## Alternatives Considered + +| Alternative | Why rejected | +|---|---| +| 8 `NUMERIC(8,6)` columns (x1,y1,...,x4,y4) | Verbose, no structural enforcement, awkward to query or extend | +| Separate `annotation_polygons` join table | Unnecessary complexity for a 1:1 optional relationship | +| PostGIS geometry column | Adds a heavyweight extension for a display-only field with no spatial queries | +| `String polygon` on the entity | Requires manual parsing at every callsite; error-prone | + +## Consequences + +**Easier:** +- Backward compatible — all existing annotations continue to work unchanged +- Frontend renders `` or `` based on a simple null check +- Schema can accommodate N-point polygons in the future (JSONB is flexible), though the CHECK constraint currently enforces exactly 4 + +**Harder:** +- Cannot express range checks (`0 <= x <= 1`) as database constraints without a PL/pgSQL function — validated at the DTO layer instead +- No server-side geometry queries on polygon coordinates (acceptable — polygon is display-only) +- AttributeConverter adds a small amount of serialization code to maintain diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 53f8ed96..2221634e 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -500,5 +500,37 @@ "person_alias_delete_title": "Alias entfernen?", "person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.", "person_alias_btn_delete": "Entfernen", - "error_alias_not_found": "Der Namensalias wurde nicht gefunden." + "error_alias_not_found": "Der Namensalias wurde nicht gefunden.", + "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", + "error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.", + "error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.", + "error_ocr_processing_failed": "Die OCR-Verarbeitung ist fehlgeschlagen.", + "ocr_script_type_typewriter": "Schreibmaschine", + "ocr_script_type_handwriting_latin": "Handschrift (lateinisch)", + "ocr_script_type_handwriting_kurrent": "Handschrift (Kurrent/Sütterlin)", + "ocr_trigger_label": "Schrifttyp", + "ocr_trigger_select_placeholder": "Schrifttyp wählen…", + "ocr_trigger_btn": "OCR starten", + "ocr_trigger_btn_disabled": "Bitte wählen Sie einen Schrifttyp", + "ocr_confirm_title": "Vorhandene Transkription ersetzen?", + "ocr_confirm_body": "Alle {count} vorhandenen Blöcke werden gelöscht und durch die OCR-Ergebnisse ersetzt. Diese Aktion kann nicht rückgängig gemacht werden.", + "ocr_confirm_btn": "Ersetzen", + "ocr_rerun_label": "OCR erneut ausführen…", + "ocr_progress_heading": "OCR läuft", + "ocr_progress_page": "Seite {current} von {total}", + "ocr_error_heading": "OCR fehlgeschlagen", + "ocr_error_retry": "Erneut versuchen", + "ocr_batch_running": "OCR läuft · {processed} von {total} Dokumente abgeschlossen", + "ocr_batch_done": "OCR abgeschlossen · {processed} erfolgreich · {errors} fehlgeschlagen", + "ocr_status_preparing": "Dokument wird vorbereitet…", + "ocr_status_loading": "Lade Modell und Dokument…", + "ocr_status_analyzing": "OCR-Analyse läuft — dies kann einige Minuten dauern…", + "ocr_status_creating_blocks": "{count} Textblöcke erkannt — erstelle Transkription…", + "ocr_status_done_blocks": "{count} Blöcke erstellt", + "ocr_status_analyzing_page": "Seite {current} von {total} wird analysiert…", + "ocr_status_done_skipped": "{count} Blöcke erstellt, {skipped} Seite(n) übersprungen", + "ocr_status_error": "OCR fehlgeschlagen", + "transcription_block_review": "Als geprüft markieren", + "transcription_block_unreview": "Markierung aufheben", + "transcription_reviewed_count": "{reviewed} von {total} geprüft" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7c535417..8dcfb42e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -500,5 +500,37 @@ "person_alias_delete_title": "Remove alias?", "person_alias_delete_body": "This name will be removed from search results.", "person_alias_btn_delete": "Remove", - "error_alias_not_found": "The name alias was not found." + "error_alias_not_found": "The name alias was not found.", + "error_ocr_service_unavailable": "The OCR service is not available.", + "error_ocr_job_not_found": "The OCR job was not found.", + "error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.", + "error_ocr_processing_failed": "OCR processing failed.", + "ocr_script_type_typewriter": "Typewriter", + "ocr_script_type_handwriting_latin": "Handwriting (Latin)", + "ocr_script_type_handwriting_kurrent": "Handwriting (Kurrent/Sütterlin)", + "ocr_trigger_label": "Script type", + "ocr_trigger_select_placeholder": "Select script type…", + "ocr_trigger_btn": "Start OCR", + "ocr_trigger_btn_disabled": "Please select a script type", + "ocr_confirm_title": "Replace existing transcription?", + "ocr_confirm_body": "All {count} existing blocks will be deleted and replaced with OCR results. This action cannot be undone.", + "ocr_confirm_btn": "Replace", + "ocr_rerun_label": "Re-run OCR…", + "ocr_progress_heading": "OCR running", + "ocr_progress_page": "Page {current} of {total}", + "ocr_error_heading": "OCR failed", + "ocr_error_retry": "Try again", + "ocr_batch_running": "OCR running · {processed} of {total} documents complete", + "ocr_batch_done": "OCR complete · {processed} successful · {errors} failed", + "ocr_status_preparing": "Preparing document…", + "ocr_status_loading": "Loading model and document…", + "ocr_status_analyzing": "OCR analysis running — this may take a few minutes…", + "ocr_status_creating_blocks": "{count} text blocks detected — creating transcription…", + "ocr_status_done_blocks": "{count} blocks created", + "ocr_status_analyzing_page": "Analyzing page {current} of {total}…", + "ocr_status_done_skipped": "{count} blocks created, {skipped} page(s) skipped", + "ocr_status_error": "OCR failed", + "transcription_block_review": "Mark as reviewed", + "transcription_block_unreview": "Unmark as reviewed", + "transcription_reviewed_count": "{reviewed} of {total} reviewed" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 52502800..1737621b 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -500,5 +500,37 @@ "person_alias_delete_title": "Eliminar alias?", "person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.", "person_alias_btn_delete": "Eliminar", - "error_alias_not_found": "No se encontro el alias de nombre." + "error_alias_not_found": "No se encontro el alias de nombre.", + "error_ocr_service_unavailable": "El servicio OCR no está disponible.", + "error_ocr_job_not_found": "No se encontró el trabajo OCR.", + "error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.", + "error_ocr_processing_failed": "El procesamiento OCR ha fallado.", + "ocr_script_type_typewriter": "Máquina de escribir", + "ocr_script_type_handwriting_latin": "Escritura manuscrita (latina)", + "ocr_script_type_handwriting_kurrent": "Escritura manuscrita (Kurrent/Sütterlin)", + "ocr_trigger_label": "Tipo de escritura", + "ocr_trigger_select_placeholder": "Seleccionar tipo de escritura…", + "ocr_trigger_btn": "Iniciar OCR", + "ocr_trigger_btn_disabled": "Por favor seleccione un tipo de escritura", + "ocr_confirm_title": "¿Reemplazar transcripción existente?", + "ocr_confirm_body": "Los {count} bloques existentes serán eliminados y reemplazados con los resultados del OCR. Esta acción no se puede deshacer.", + "ocr_confirm_btn": "Reemplazar", + "ocr_rerun_label": "Ejecutar OCR de nuevo…", + "ocr_progress_heading": "OCR en curso", + "ocr_progress_page": "Página {current} de {total}", + "ocr_error_heading": "OCR fallido", + "ocr_error_retry": "Intentar de nuevo", + "ocr_batch_running": "OCR en curso · {processed} de {total} documentos completados", + "ocr_batch_done": "OCR completado · {processed} exitosos · {errors} fallidos", + "ocr_status_preparing": "Preparando documento…", + "ocr_status_loading": "Cargando modelo y documento…", + "ocr_status_analyzing": "Análisis OCR en curso — esto puede tardar unos minutos…", + "ocr_status_creating_blocks": "{count} bloques de texto detectados — creando transcripción…", + "ocr_status_done_blocks": "{count} bloques creados", + "ocr_status_analyzing_page": "Analizando página {current} de {total}…", + "ocr_status_done_skipped": "{count} bloques creados, {skipped} página(s) omitida(s)", + "ocr_status_error": "OCR fallido", + "transcription_block_review": "Marcar como revisado", + "transcription_block_unreview": "Desmarcar como revisado", + "transcription_reviewed_count": "{reviewed} de {total} revisados" } diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index ec1c29c0..65e87b42 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -1,5 +1,6 @@ + +
{ + if (e.key === 'Enter' || e.key === ' ') onclick(); + }} + onpointerenter={onpointerenter} + onpointerleave={onpointerleave} + style={shapeStyle} +> + {#if !dimmed && blockNumber} +
+ {blockNumber} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/OcrProgress.svelte b/frontend/src/lib/components/OcrProgress.svelte new file mode 100644 index 00000000..c9bbc124 --- /dev/null +++ b/frontend/src/lib/components/OcrProgress.svelte @@ -0,0 +1,91 @@ + + +{#if status === 'running'} +
+

+ {m.ocr_progress_heading()} +

+
+
+
+

+ {m.ocr_progress_page({ current: String(currentPage), total: String(totalPages) })} +

+
+{:else if status === 'error'} +
+

+ {m.ocr_error_heading()} +

+ +
+{/if} diff --git a/frontend/src/lib/components/OcrTrigger.svelte b/frontend/src/lib/components/OcrTrigger.svelte new file mode 100644 index 00000000..e00fda82 --- /dev/null +++ b/frontend/src/lib/components/OcrTrigger.svelte @@ -0,0 +1,49 @@ + + +
+ + +
diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 7705620b..2574e8d0 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -145,6 +145,7 @@ async function renderPage(doc: PDFDocumentProxy, pageNum: number) { // Text layer const textDiv = textLayerEl; + if (!textDiv) return; textDiv.innerHTML = ''; textDiv.style.width = `${viewport.width / dpr}px`; textDiv.style.height = `${viewport.height / dpr}px`; diff --git a/frontend/src/lib/components/ScriptTypeSelect.svelte b/frontend/src/lib/components/ScriptTypeSelect.svelte new file mode 100644 index 00000000..238ffd7d --- /dev/null +++ b/frontend/src/lib/components/ScriptTypeSelect.svelte @@ -0,0 +1,27 @@ + + +
+ + +
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index ce15ff66..41598b15 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -14,6 +14,7 @@ type Props = { text: string; label: string | null; active: boolean; + reviewed: boolean; saveState: SaveState; canComment: boolean; currentUserId: string | null; @@ -21,6 +22,7 @@ type Props = { onFocus: () => void; onDeleteClick: () => void; onRetry: () => void; + onReviewToggle: () => void; onMoveUp?: () => void; onMoveDown?: () => void; isFirst?: boolean; @@ -34,6 +36,7 @@ let { text, label = null, active, + reviewed, saveState, canComment, currentUserId, @@ -41,6 +44,7 @@ let { onFocus, onDeleteClick, onRetry, + onReviewToggle, onMoveUp, onMoveDown, isFirst = false, @@ -239,6 +243,29 @@ function handleTextareaMouseUp() { {/if} + + +