diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index 6252a5ad..92d853c7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -21,6 +21,7 @@ import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.TrainingLabel; import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; @@ -35,9 +36,11 @@ import org.springframework.core.io.InputStreamResource; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; @@ -208,6 +211,29 @@ public class DocumentController { return ResponseEntity.ok(DocumentSearchResult.of(results)); } + // --- TRAINING LABELS --- + + public record TrainingLabelRequest(String label, boolean enrolled) {} + + @PatchMapping("/{id}/training-labels") + @RequirePermission(Permission.WRITE_ALL) + public ResponseEntity patchTrainingLabel( + @PathVariable UUID id, + @RequestBody TrainingLabelRequest req) { + TrainingLabel label; + try { + label = TrainingLabel.valueOf(req.label()); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown training label: " + req.label()); + } + if (req.enrolled()) { + documentService.addTrainingLabel(id, label); + } else { + documentService.removeTrainingLabel(id, label); + } + return ResponseEntity.noContent().build(); + } + // --- VERSIONS --- @GetMapping("/{id}/versions") 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 e5be77a3..5a5ca54a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -110,4 +110,11 @@ public class Document { @JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id")) @Builder.Default private Set tags = new HashSet<>(); + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id")) + @Column(name = "label") + @Enumerated(EnumType.STRING) + @Builder.Default + private Set trainingLabels = new HashSet<>(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingLabel.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingLabel.java new file mode 100644 index 00000000..d78585aa --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingLabel.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.model; + +public enum TrainingLabel { + KURRENT_RECOGNITION, + KURRENT_SEGMENTATION +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java index 2e6c3365..9b924ed5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.repository; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; import java.util.Optional; @@ -14,4 +15,22 @@ public interface TranscriptionBlockRepository extends JpaRepository findByIdAndDocumentId(UUID id, UUID documentId); int countByDocumentId(UUID documentId); + + @Query(""" + SELECT b FROM TranscriptionBlock b + JOIN DocumentAnnotation a ON a.id = b.annotationId + JOIN Document d ON d.id = b.documentId + WHERE (b.source = 'MANUAL' OR (b.source = 'OCR' AND b.reviewed = true)) + AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels + """) + List findEligibleKurrentBlocks(); + + @Query(""" + SELECT b FROM TranscriptionBlock b + JOIN DocumentAnnotation a ON a.id = b.annotationId + JOIN Document d ON d.id = b.documentId + WHERE b.source = 'MANUAL' AND (b.text IS NULL OR b.text = '') + AND 'KURRENT_SEGMENTATION' MEMBER OF d.trainingLabels + """) + List findSegmentationBlocks(); } 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 f06a9922..db2d6db6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -9,6 +9,7 @@ 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.TrainingLabel; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; @@ -385,6 +386,20 @@ public class DocumentService { documentRepository.save(doc); } + @Transactional + public void addTrainingLabel(UUID documentId, TrainingLabel label) { + Document doc = getDocumentById(documentId); + doc.getTrainingLabels().add(label); + documentRepository.save(doc); + } + + @Transactional + public void removeTrainingLabel(UUID documentId, TrainingLabel label) { + Document doc = getDocumentById(documentId); + doc.getTrainingLabels().remove(label); + 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/OcrService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java index dcc14dd1..cacf61af 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java @@ -66,6 +66,9 @@ public class OcrService { if (scriptTypeOverride != null) { documentService.updateScriptType(documentId, scriptTypeOverride); + if (scriptTypeOverride == ScriptType.HANDWRITING_KURRENT) { + documentService.addTrainingLabel(documentId, TrainingLabel.KURRENT_RECOGNITION); + } } OcrJob job = OcrJob.builder() diff --git a/backend/src/main/resources/db/migration/V29__add_document_training_labels.sql b/backend/src/main/resources/db/migration/V29__add_document_training_labels.sql new file mode 100644 index 00000000..36c20f10 --- /dev/null +++ b/backend/src/main/resources/db/migration/V29__add_document_training_labels.sql @@ -0,0 +1,5 @@ +CREATE TABLE document_training_labels ( + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + label VARCHAR(50) NOT NULL, + PRIMARY KEY (document_id, label) +); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index d8f93e4e..58cd78a6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -14,6 +14,7 @@ import org.raddatz.familienarchiv.service.FileService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.raddatz.familienarchiv.config.SecurityConfig; +import org.springframework.http.MediaType; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithMockUser; @@ -31,8 +32,10 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.eq; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -485,6 +488,58 @@ class DocumentControllerTest { .andExpect(jsonPath("$[0].editorName").value("Emma Müller")); } + // ─── PATCH /api/documents/{id}/training-labels ─────────────────────────── + + @Test + void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchTrainingLabels_returns204_whenAddingLabel() throws Exception { + UUID id = UUID.randomUUID(); + mockMvc.perform(patch("/api/documents/" + id + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) + .andExpect(status().isNoContent()); + + verify(documentService).addTrainingLabel(eq(id), any()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception { + UUID id = UUID.randomUUID(); + mockMvc.perform(patch("/api/documents/" + id + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}")) + .andExpect(status().isNoContent()); + + verify(documentService).removeTrainingLabel(eq(id), any()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}")) + .andExpect(status().isBadRequest()); + } + // ─── GET /api/documents/{id}/versions/{versionId} ──────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/TrainingBlockQueryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/TrainingBlockQueryTest.java new file mode 100644 index 00000000..d7406ba0 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/TrainingBlockQueryTest.java @@ -0,0 +1,124 @@ +package org.raddatz.familienarchiv.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.*; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TrainingBlockQueryTest { + + @Autowired TranscriptionBlockRepository blockRepository; + @Autowired DocumentRepository documentRepository; + @Autowired AnnotationRepository annotationRepository; + + private UUID kurrentDocId; + private UUID typewriterDocId; + private UUID kurrentAnnotationId; + private UUID typewriterAnnotationId; + + @BeforeEach + void setUp() { + Document kurrentDoc = documentRepository.save(Document.builder() + .title("Kurrent Brief") + .originalFilename("kurrent.pdf") + .status(DocumentStatus.UPLOADED) + .trainingLabels(new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION))) + .build()); + kurrentDocId = kurrentDoc.getId(); + + Document typewriterDoc = documentRepository.save(Document.builder() + .title("Getippter Brief") + .originalFilename("typed.pdf") + .status(DocumentStatus.UPLOADED) + .build()); + typewriterDocId = typewriterDoc.getId(); + + kurrentAnnotationId = annotationRepository.save(annotation(kurrentDocId)).getId(); + typewriterAnnotationId = annotationRepository.save(annotation(typewriterDocId)).getId(); + } + + @Test + void findEligibleKurrentBlocks_includesManualBlock() { + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.MANUAL, false)); + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getSource()).isEqualTo(BlockSource.MANUAL); + } + + @Test + void findEligibleKurrentBlocks_includesReviewedOcrBlock() { + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, true)); + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).isReviewed()).isTrue(); + } + + @Test + void findEligibleKurrentBlocks_excludesUnreviewedOcrBlock() { + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, false)); + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).isEmpty(); + } + + @Test + void findEligibleKurrentBlocks_excludesNonEnrolledDocument() { + blockRepository.save(block(typewriterDocId, typewriterAnnotationId, BlockSource.MANUAL, false)); + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).isEmpty(); + } + + @Test + void findEligibleKurrentBlocks_returnsAllEligibleAcrossBothSources() { + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.MANUAL, false)); + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, true)); + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, false)); // excluded + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).hasSize(2); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private DocumentAnnotation annotation(UUID docId) { + return DocumentAnnotation.builder() + .documentId(docId) + .pageNumber(1) + .x(0.1).y(0.2).width(0.3).height(0.4) + .color("#00C7B1") + .build(); + } + + private TranscriptionBlock block(UUID docId, UUID annotId, BlockSource source, boolean reviewed) { + return TranscriptionBlock.builder() + .annotationId(annotId) + .documentId(docId) + .text("Liebe Tante") + .sortOrder(0) + .source(source) + .reviewed(reviewed) + .build(); + } +} diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index e93471ac..f49ea411 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -20,6 +20,9 @@ type Props = { onDeleteBlock: (blockId: string) => Promise; onReviewToggle: (blockId: string) => Promise; onTriggerOcr?: (scriptType: string) => void; + canWrite?: boolean; + trainingLabels?: string[]; + onToggleTrainingLabel?: (label: string, enrolled: boolean) => Promise; }; let { @@ -34,10 +37,14 @@ let { onSaveBlock, onDeleteBlock, onReviewToggle, - onTriggerOcr + onTriggerOcr, + canWrite = false, + trainingLabels = [], + onToggleTrainingLabel }: Props = $props(); let activeBlockId: string | null = $state(null); +let localLabels: string[] = $derived.by(() => [...trainingLabels]); // Sync: when an annotation is clicked on the PDF, activate the corresponding block $effect(() => { @@ -188,7 +195,7 @@ let dropTargetIdx: number | null = $state(null); let dragOffsetY: number = $state(0); let dragStartY = 0; let capturedEl: HTMLElement | null = null; -let listEl: HTMLElement | null = null; +let listEl: HTMLElement | null = $state(null); function handleGripDown(e: PointerEvent, blockId: string) { if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return; @@ -240,6 +247,23 @@ function handlePointerUp() { capturedEl = null; } +async function handleLabelToggle(label: string) { + if (!onToggleTrainingLabel) return; + const enrolled = !localLabels.includes(label); + // Optimistic update + if (enrolled) { + localLabels = [...localLabels, label]; + } else { + localLabels = localLabels.filter((l) => l !== label); + } + try { + await onToggleTrainingLabel(label, enrolled); + } catch { + // Revert on failure + localLabels = [...trainingLabels]; + } +} + function flushViaBeacon() { for (const [blockId, text] of pendingTexts) { clearDebounce(blockId); @@ -390,4 +414,23 @@ $effect(() => { {/if} {/if} + + {#if canWrite} +
+

Für Training vormerken

+
+ {#each [{ label: 'KURRENT_RECOGNITION', display: 'Kurrent-Erkennung' }, { label: 'KURRENT_SEGMENTATION', display: 'Segmentierung' }] as chip (chip.label)} + + {/each} +
+
+ {/if} diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index ea330706..16961ab4 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -116,6 +116,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/transcription-blocks/{blockId}/review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["reviewBlock"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/transcription-blocks/reorder": { parameters: { query?: never; @@ -212,6 +228,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/ocr/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["triggerBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/notifications/read-all": { parameters: { query?: never; @@ -308,6 +340,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/ocr": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["triggerOcr"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/comments": { parameters: { query?: never; @@ -628,6 +676,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/ocr/jobs/{jobId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getJobStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/ocr/jobs/{jobId}/progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["streamProgress"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/notifications": { parameters: { query?: never; @@ -740,6 +820,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/ocr-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getDocumentOcrStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/search": { parameters: { query?: never; @@ -940,6 +1036,7 @@ export interface components { name: string; }; PersonUpdateDTO: { + title?: string; firstName?: string; lastName?: string; alias?: string; @@ -978,6 +1075,8 @@ export interface components { receiverIds?: string[]; tags?: string; metadataComplete?: boolean; + /** @enum {string} */ + scriptType?: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT"; }; Document: { /** Format: uuid */ @@ -1002,9 +1101,13 @@ export interface components { /** Format: date-time */ updatedAt: string; metadataComplete: boolean; + /** @enum {string} */ + scriptType: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT"; receivers?: components["schemas"]["Person"][]; sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; + /** @enum {string} */ + trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; }; UpdateTranscriptionBlockDTO: { text?: string; @@ -1021,6 +1124,9 @@ export interface components { label?: string; /** Format: int32 */ sortOrder: number; + /** @enum {string} */ + source: "MANUAL" | "OCR"; + reviewed: boolean; /** Format: int32 */ version: number; /** Format: uuid */ @@ -1068,6 +1174,9 @@ export interface components { /** Format: date-time */ createdAt: string; }; + BatchOcrDTO: { + documentIds: string[]; + }; GroupDTO: { name?: string; permissions?: string[]; @@ -1118,6 +1227,10 @@ export interface components { firstName: string; lastName: string; }; + TriggerOcrDTO: { + /** @enum {string} */ + scriptType?: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT"; + }; CreateAnnotationDTO: { /** Format: int32 */ pageNumber?: number; @@ -1130,6 +1243,7 @@ export interface components { /** Format: double */ height?: number; color?: string; + polygon?: number[][]; }; DocumentAnnotation: { /** Format: uuid */ @@ -1148,6 +1262,7 @@ export interface components { height: number; color: string; fileHash?: string; + polygon?: number[][]; /** Format: uuid */ createdBy?: string; /** Format: date-time */ @@ -1210,6 +1325,8 @@ export interface components { /** Format: uuid */ id?: string; displayName?: string; + /** Format: int64 */ + documentCount?: number; firstName?: string; lastName?: string; /** Format: int32 */ @@ -1219,14 +1336,37 @@ export interface components { alias?: string; notes?: string; personType?: string; + }; + OcrJob: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "PENDING" | "RUNNING" | "DONE" | "FAILED"; + /** Format: int32 */ + totalDocuments: number; + /** Format: int32 */ + processedDocuments: number; + /** Format: int32 */ + errorCount: number; + /** Format: int32 */ + skippedCount: number; + progressMessage?: string; + /** Format: uuid */ + createdBy?: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + SseEmitter: { /** Format: int64 */ - documentCount?: number; + timeout?: number; }; PageNotificationDTO: { - /** Format: int64 */ - totalElements?: number; /** Format: int32 */ totalPages?: number; + /** Format: int64 */ + totalElements?: number; pageable?: components["schemas"]["PageableObject"]; /** Format: int32 */ size?: number; @@ -1234,10 +1374,10 @@ export interface components { /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; - /** Format: int32 */ - numberOfElements?: number; first?: boolean; last?: boolean; + /** Format: int32 */ + numberOfElements?: number; empty?: boolean; }; PageableObject: { @@ -1256,10 +1396,6 @@ export interface components { empty?: boolean; unsorted?: boolean; }; - SseEmitter: { - /** Format: int64 */ - timeout?: number; - }; DocumentVersionSummary: { /** Format: uuid */ id: string; @@ -1292,6 +1428,15 @@ export interface components { /** Format: date-time */ changedAt: string; }; + OcrStatusDTO: { + status?: string; + /** Format: uuid */ + jobId?: string; + /** Format: int32 */ + currentPage?: number; + /** Format: int32 */ + totalPages?: number; + }; DocumentSearchResult: { documents?: components["schemas"]["Document"][]; /** Format: int64 */ @@ -1702,6 +1847,29 @@ export interface operations { }; }; }; + reviewBlock: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + blockId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TranscriptionBlock"]; + }; + }; + }; + }; reorderBlocks: { parameters: { query?: never; @@ -1914,6 +2082,32 @@ export interface operations { }; }; }; + triggerBatch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchOcrDTO"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; markAllRead: { parameters: { query?: never; @@ -2124,6 +2318,34 @@ export interface operations { }; }; }; + triggerOcr: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TriggerOcrDTO"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; getDocumentComments: { parameters: { query?: never; @@ -2701,6 +2923,50 @@ export interface operations { }; }; }; + getJobStatus: { + parameters: { + query?: never; + header?: never; + path: { + jobId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OcrJob"]; + }; + }; + }; + }; + streamProgress: { + parameters: { + query?: never; + header?: never; + path: { + jobId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": components["schemas"]["SseEmitter"]; + }; + }; + }; + }; getNotifications: { parameters: { query?: { @@ -2860,6 +3126,28 @@ export interface operations { }; }; }; + getDocumentOcrStatus: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OcrStatusDTO"]; + }; + }; + }; + }; search_1: { parameters: { query?: { diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 11b4f185..702a465a 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -129,6 +129,15 @@ async function reviewToggle(blockId: string) { transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); } +async function toggleTrainingLabel(label: string, enrolled: boolean) { + const res = await fetch(`/api/documents/${doc.id}/training-labels`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label, enrolled }) + }); + if (!res.ok) throw new Error('Failed to update training label'); +} + let ocrRunning = $state(false); let ocrProgressMessage = $state(''); let ocrErrorMessage = $state(''); @@ -449,11 +458,14 @@ onMount(() => { activeAnnotationId={activeAnnotationId} storedScriptType={doc.scriptType ?? ''} canRunOcr={canWrite && !!doc.filePath} + canWrite={canWrite} + trainingLabels={doc.trainingLabels ?? []} onBlockFocus={handleBlockFocus} onSaveBlock={saveBlock} onDeleteBlock={deleteBlock} onReviewToggle={reviewToggle} onTriggerOcr={triggerOcr} + onToggleTrainingLabel={toggleTrainingLabel} /> {/if}