From b12ff9c808e7c2c509e64015e6ab3e5a78b1a7a6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:08:49 +0200 Subject: [PATCH 01/30] feat(documents): V53 add thumbnail_aspect + page_count columns Adds two nullable metadata columns to documents, populated by ThumbnailService when it generates the JPEG preview. Both remain null until the existing admin backfill endpoint reruns the service. Refs #305 Co-Authored-By: Claude Opus 4.7 --- ...3__add_thumbnail_aspect_and_page_count.sql | 8 ++++ .../repository/MigrationIntegrationTest.java | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V53__add_thumbnail_aspect_and_page_count.sql diff --git a/backend/src/main/resources/db/migration/V53__add_thumbnail_aspect_and_page_count.sql b/backend/src/main/resources/db/migration/V53__add_thumbnail_aspect_and_page_count.sql new file mode 100644 index 00000000..53d39dc2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V53__add_thumbnail_aspect_and_page_count.sql @@ -0,0 +1,8 @@ +-- Adds two nullable metadata columns populated by ThumbnailService when it +-- generates the JPEG preview: thumbnail_aspect (PORTRAIT | LANDSCAPE, from the +-- source image w/h ratio with threshold 1.1) and page_count (from PDDocument +-- for PDFs, 1 for image uploads). Both are null until the existing admin +-- backfill endpoint (/api/admin/generate-thumbnails) reruns the service. +ALTER TABLE documents + ADD COLUMN thumbnail_aspect VARCHAR(16), + ADD COLUMN page_count INTEGER; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java index b482a0be..e751b85d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java @@ -302,6 +302,51 @@ class MigrationIntegrationTest { ).isInstanceOf(DataIntegrityViolationException.class); } + // ─── V53: add thumbnail_aspect + page_count columns to documents ───────── + + @Test + void v53_thumbnailAspectColumn_existsAndIsNullable() { + UUID docId = createDocument(); + + // Column must exist and accept NULL (freshly-created doc has no thumbnail yet) + String aspect = jdbc.queryForObject( + "SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId); + assertThat(aspect).isNull(); + } + + @Test + void v53_pageCountColumn_existsAndIsNullable() { + UUID docId = createDocument(); + + Integer pageCount = jdbc.queryForObject( + "SELECT page_count FROM documents WHERE id = ?", Integer.class, docId); + assertThat(pageCount).isNull(); + } + + @Test + void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() { + UUID docId = createDocument(); + + int portraitRows = jdbc.update( + "UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId); + assertThat(portraitRows).isEqualTo(1); + + int landscapeRows = jdbc.update( + "UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId); + assertThat(landscapeRows).isEqualTo(1); + } + + @Test + void v53_pageCountColumn_storesInteger() { + UUID docId = createDocument(); + + jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId); + + Integer stored = jdbc.queryForObject( + "SELECT page_count FROM documents WHERE id = ?", Integer.class, docId); + assertThat(stored).isEqualTo(4); + } + // ─── V51: backfill annotation_id on block comments and notifications ───── @Test -- 2.49.1 From f41729885bdc545c837a2699edfa04633a38d94f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:15:28 +0200 Subject: [PATCH 02/30] feat(documents): expose thumbnailAspect + pageCount on Document entity Adds ThumbnailAspect enum (PORTRAIT | LANDSCAPE) and maps the two nullable columns from V53 as JPA fields so ThumbnailService can populate them and the API can return them unchanged to the frontend. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../familienarchiv/model/Document.java | 7 ++++ .../familienarchiv/model/ThumbnailAspect.java | 6 ++++ .../repository/DocumentRepositoryTest.java | 35 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/ThumbnailAspect.java 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 d2cea5da..dc66fb5d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -50,6 +50,13 @@ public class Document { @Column(name = "thumbnail_generated_at") private LocalDateTime thumbnailGeneratedAt; + @Enumerated(EnumType.STRING) + @Column(name = "thumbnail_aspect", length = 16) + private ThumbnailAspect thumbnailAspect; + + @Column(name = "page_count") + private Integer pageCount; + // Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf") @Column(name = "original_filename", nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/ThumbnailAspect.java b/backend/src/main/java/org/raddatz/familienarchiv/model/ThumbnailAspect.java new file mode 100644 index 00000000..630247fd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/ThumbnailAspect.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.model; + +public enum ThumbnailAspect { + PORTRAIT, + LANDSCAPE +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java index 8d12a678..28728bb7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java @@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; @@ -65,6 +66,40 @@ class DocumentRepositoryTest { assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER); } + // ─── thumbnailAspect + pageCount round-trip ─────────────────────────────── + + @Test + void save_persistsThumbnailAspectAndPageCount() { + Document document = Document.builder() + .title("Mit Aspekt") + .originalFilename("aspect.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailAspect(ThumbnailAspect.LANDSCAPE) + .pageCount(7) + .build(); + + Document saved = documentRepository.save(document); + Document found = documentRepository.findById(saved.getId()).orElseThrow(); + + assertThat(found.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE); + assertThat(found.getPageCount()).isEqualTo(7); + } + + @Test + void save_thumbnailAspectAndPageCount_defaultToNull() { + Document document = Document.builder() + .title("Ohne Aspekt") + .originalFilename("no_aspect.pdf") + .status(DocumentStatus.PLACEHOLDER) + .build(); + + Document saved = documentRepository.save(document); + Document found = documentRepository.findById(saved.getId()).orElseThrow(); + + assertThat(found.getThumbnailAspect()).isNull(); + assertThat(found.getPageCount()).isNull(); + } + // ─── findByStatus ───────────────────────────────────────────────────────── @Test -- 2.49.1 From edd7e8f5b0f96a25f52787408ba8d76a57115c10 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:18:04 +0200 Subject: [PATCH 03/30] test(thumbnails): lock corrupt-image + corrupt-pdf failure paths Both cases already return FAILED via the existing catch-Exception blocks in readSourceImage. Pinning the behavior with regression tests before thumbnailAspect and pageCount computation is added, so a future refactor that removes the safety net is caught at compile/test time. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailServiceTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java index 90b0e8c8..a2074891 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -167,6 +167,38 @@ class ThumbnailServiceTest { verify(documentRepository, never()).save(any()); } + @Test + void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException { + // Truncated JPEG header — ImageIO returns null rather than throwing. + // Without the corrupt-image guard this would later NPE inside the aspect / + // dimension computation in scaleToWidth. + Document doc = makeDoc("image/jpeg", "documents/corrupt.jpg"); + byte[] truncated = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0}; + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(truncated)); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + verifyNoInteractions(s3Client); + verify(documentRepository, never()).save(any()); + } + + @Test + void generate_returnsFailed_whenPdfBytesAreCorrupt() throws IOException { + // "PDF" header but no body — PDFBox throws IOException while loading. + Document doc = makeDoc("application/pdf", "documents/corrupt.pdf"); + byte[] fakePdf = "%PDF-1.4\n".getBytes(); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(fakePdf)); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + verifyNoInteractions(s3Client); + verify(documentRepository, never()).save(any()); + } + @Test void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException { // Covers the "orphan thumbnail" edge case: S3 upload succeeded but the -- 2.49.1 From 01dbf6de86b31dc4f8121daf9949482366da3ef2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:21:17 +0200 Subject: [PATCH 04/30] feat(thumbnails): persist thumbnailAspect from source image dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Computes aspect at generate-time from the loaded BufferedImage: w/h above 1.1 → LANDSCAPE, otherwise PORTRAIT. The threshold keeps near-square A4 scans in the portrait tile (ratio ≈ 1.0) rather than flipping to landscape on a rounding error. Also hardens the pipeline with an explicit dimension guard so width=0 / height=0 edge cases fail cleanly instead of dividing by zero when the aspect is computed. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailService.java | 20 +++++-- .../service/ThumbnailServiceTest.java | 54 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java index 3b4cb48e..8fbf8d92 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java @@ -7,6 +7,7 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -45,6 +46,9 @@ public class ThumbnailService { private static final int THUMBNAIL_WIDTH = 240; private static final float JPEG_QUALITY = 0.85f; private static final int PDF_RENDER_DPI = 100; + // Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should + // render in the portrait tile rather than flipping to landscape at 1.01. + private static final float LANDSCAPE_THRESHOLD = 1.1f; private static final String PDF_CONTENT_TYPE = "application/pdf"; private static final Set IMAGE_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/tiff"); @@ -83,7 +87,10 @@ public class ThumbnailService { } BufferedImage source = readSourceImage(doc, contentType); - if (source == null) return Outcome.FAILED; + if (source == null || source.getWidth() <= 0 || source.getHeight() <= 0) { + log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId()); + return Outcome.FAILED; + } byte[] jpeg = encodeThumbnail(source, doc.getId()); if (jpeg == null) return Outcome.FAILED; @@ -91,7 +98,13 @@ public class ThumbnailService { String thumbnailKey = thumbnailKeyFor(doc.getId()); if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED; - return persistThumbnailMetadata(doc, thumbnailKey); + ThumbnailAspect aspect = aspectOf(source); + return persistThumbnailMetadata(doc, thumbnailKey, aspect); + } + + private static ThumbnailAspect aspectOf(BufferedImage source) { + float ratio = (float) source.getWidth() / source.getHeight(); + return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT; } private static String thumbnailKeyFor(UUID documentId) { @@ -138,10 +151,11 @@ public class ThumbnailService { } } - private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey) { + private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey, ThumbnailAspect aspect) { try { doc.setThumbnailKey(thumbnailKey); doc.setThumbnailGeneratedAt(LocalDateTime.now()); + doc.setThumbnailAspect(aspect); documentRepository.save(doc); return Outcome.SUCCESS; } catch (Exception e) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java index a2074891..df1f62b0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.test.util.ReflectionTestUtils; import software.amazon.awssdk.core.sync.RequestBody; @@ -167,6 +168,59 @@ class ThumbnailServiceTest { verify(documentRepository, never()).save(any()); } + @Test + void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException { + // 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT. + Document doc = makeDoc("image/png", "documents/portrait.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + + @Test + void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException { + // 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE. + Document doc = makeDoc("image/jpeg", "documents/wide.jpg"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE); + } + + @Test + void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException { + // 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often + // come in at near-square and we want them to live in the portrait tile). + Document doc = makeDoc("image/png", "documents/square.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(500, 500))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + + @Test + void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException { + // 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT. + Document doc = makeDoc("image/png", "documents/near_threshold.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + @Test void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException { // Truncated JPEG header — ImageIO returns null rather than throwing. -- 2.49.1 From 5cdf52be676763f6df569cf5b492ae18da8b0193 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:25:29 +0200 Subject: [PATCH 05/30] feat(thumbnails): persist pageCount from PDDocument / 1 for images Groups the first-page BufferedImage and the source's total page count into a SourcePreview record so both values travel through generate() together. PDFs get pdf.getNumberOfPages(); image uploads always get 1 (a scan is one page from the user's perspective). The page badge on the thumbnail row uses this value to show "1 / N" for multi-page letters without a separate round-trip. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailService.java | 28 +++++++---- .../service/ThumbnailServiceTest.java | 47 +++++++++++++++---- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java index 8fbf8d92..620a36c8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java @@ -86,20 +86,21 @@ public class ThumbnailService { return Outcome.SKIPPED; } - BufferedImage source = readSourceImage(doc, contentType); - if (source == null || source.getWidth() <= 0 || source.getHeight() <= 0) { + SourcePreview preview = readSourcePreview(doc, contentType); + if (preview == null + || preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) { log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId()); return Outcome.FAILED; } - byte[] jpeg = encodeThumbnail(source, doc.getId()); + byte[] jpeg = encodeThumbnail(preview.image(), doc.getId()); if (jpeg == null) return Outcome.FAILED; String thumbnailKey = thumbnailKeyFor(doc.getId()); if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED; - ThumbnailAspect aspect = aspectOf(source); - return persistThumbnailMetadata(doc, thumbnailKey, aspect); + ThumbnailAspect aspect = aspectOf(preview.image()); + return persistThumbnailMetadata(doc, thumbnailKey, aspect, preview.pageCount()); } private static ThumbnailAspect aspectOf(BufferedImage source) { @@ -107,15 +108,19 @@ public class ThumbnailService { return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT; } + // First-page image + total page count for the source file. Page count is always + // 1 for image uploads; for PDFs it comes straight from PDDocument. + private record SourcePreview(BufferedImage image, int pageCount) {} + private static String thumbnailKeyFor(UUID documentId) { return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX; } - private BufferedImage readSourceImage(Document doc, String contentType) { + private SourcePreview readSourcePreview(Document doc, String contentType) { try { return PDF_CONTENT_TYPE.equals(contentType) ? renderPdfFirstPage(doc.getFilePath()) - : readImage(doc.getFilePath()); + : new SourcePreview(readImage(doc.getFilePath()), 1); } catch (Exception e) { log.warn("Thumbnail source read failed for doc={} reason={}", doc.getId(), e.getMessage()); @@ -151,11 +156,13 @@ public class ThumbnailService { } } - private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey, ThumbnailAspect aspect) { + private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey, + ThumbnailAspect aspect, int pageCount) { try { doc.setThumbnailKey(thumbnailKey); doc.setThumbnailGeneratedAt(LocalDateTime.now()); doc.setThumbnailAspect(aspect); + doc.setPageCount(pageCount); documentRepository.save(doc); return Outcome.SUCCESS; } catch (Exception e) { @@ -174,11 +181,12 @@ public class ThumbnailService { return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType); } - private BufferedImage renderPdfFirstPage(String s3Key) throws IOException { + private SourcePreview renderPdfFirstPage(String s3Key) throws IOException { try (InputStream in = fileService.downloadFileStream(s3Key); PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) { PDFRenderer renderer = new PDFRenderer(pdf); - return renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB); + BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB); + return new SourcePreview(image, pdf.getNumberOfPages()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java index df1f62b0..ad423401 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -168,6 +168,31 @@ class ThumbnailServiceTest { verify(documentRepository, never()).save(any()); } + @Test + void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException { + // Image uploads are always a single page from the user's perspective. + Document doc = makeDoc("image/png", "documents/scan.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getPageCount()).isEqualTo(1); + } + + @Test + void generate_persistsPageCount_fromPdfDocument() throws IOException { + Document doc = makeDoc("application/pdf", "documents/multi.pdf"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePdf(3))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getPageCount()).isEqualTo(3); + } + @Test void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException { // 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT. @@ -288,15 +313,21 @@ class ThumbnailServiceTest { } private static byte[] createSamplePdf() throws IOException { + return createSamplePdf(1); + } + + private static byte[] createSamplePdf(int pageCount) throws IOException { try (PDDocument doc = new PDDocument()) { - PDPage page = new PDPage(PDRectangle.A4); - doc.addPage(page); - try (PDPageContentStream content = new PDPageContentStream(doc, page)) { - content.beginText(); - content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24); - content.newLineAtOffset(100, 700); - content.showText("Lieber Hans,"); - content.endText(); + for (int i = 0; i < pageCount; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream content = new PDPageContentStream(doc, page)) { + content.beginText(); + content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24); + content.newLineAtOffset(100, 700); + content.showText("Lieber Hans,"); + content.endText(); + } } ByteArrayOutputStream bos = new ByteArrayOutputStream(); doc.save(bos); -- 2.49.1 From ce1d118882ed334992a941fc271097132cd0db84 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:28:35 +0200 Subject: [PATCH 06/30] feat(api): surface thumbnailAspect + pageCount on the Document type Mirrors the backend entity additions so the frontend row components can consume the aspect (portrait vs landscape tile) and the page count (badge on the thumbnail) without any runtime guessing. Refs #305 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/generated/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index f2a0f090..351e1597 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1386,6 +1386,10 @@ export interface components { thumbnailKey?: string; /** Format: date-time */ thumbnailGeneratedAt?: string; + /** @enum {string} */ + thumbnailAspect?: "PORTRAIT" | "LANDSCAPE"; + /** Format: int32 */ + pageCount?: number; originalFilename: string; /** @enum {string} */ status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; -- 2.49.1 From bc337fb445ad613544b39c9af5144fc8e3503fa5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:32:04 +0200 Subject: [PATCH 07/30] refactor(briefwechsel): extract bilateral DistributionBar component Lifts the inline distribution bar out of ConversationTimeline so the same two-tone ratio widget can be reused on other bilateral surfaces (e.g. the person detail page). Markup/styling is byte-identical to the inline version; only the prop interface is new. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../src/lib/components/DistributionBar.svelte | 54 +++++++++++++++++ .../components/DistributionBar.svelte.spec.ts | 58 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 frontend/src/lib/components/DistributionBar.svelte create mode 100644 frontend/src/lib/components/DistributionBar.svelte.spec.ts diff --git a/frontend/src/lib/components/DistributionBar.svelte b/frontend/src/lib/components/DistributionBar.svelte new file mode 100644 index 00000000..d0e52292 --- /dev/null +++ b/frontend/src/lib/components/DistributionBar.svelte @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/lib/components/DistributionBar.svelte.spec.ts b/frontend/src/lib/components/DistributionBar.svelte.spec.ts new file mode 100644 index 00000000..3af8bca0 --- /dev/null +++ b/frontend/src/lib/components/DistributionBar.svelte.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; + +import DistributionBar from './DistributionBar.svelte'; + +afterEach(() => { + cleanup(); +}); + +describe('DistributionBar', () => { + it('renders both counts and short names, and the two-tone fill bar', async () => { + render(DistributionBar, { + outCount: 3, + inCount: 7, + senderName: 'Hans Müller', + receiverName: 'Anna Schmidt' + }); + + const container = document.querySelector('[role="img"]') as HTMLElement; + expect(container).toBeTruthy(); + expect(container.getAttribute('aria-label')).toContain('3 von Hans Müller'); + expect(container.getAttribute('aria-label')).toContain('7 von Anna Schmidt'); + + expect(container.textContent).toContain('3 von Hans'); + expect(container.textContent).toContain('7 von Anna'); + + // 3/10 → 30% / 70% split on the two segments + const segments = container.querySelectorAll('[data-testid="dist-bar-segment"]'); + expect(segments).toHaveLength(2); + expect((segments[0] as HTMLElement).style.width).toBe('30%'); + expect((segments[1] as HTMLElement).style.width).toBe('70%'); + }); + + it('falls back to the full name when it has no space to split', async () => { + render(DistributionBar, { + outCount: 1, + inCount: 0, + senderName: 'SingleWord', + receiverName: 'Another' + }); + + const container = document.querySelector('[role="img"]') as HTMLElement; + expect(container.textContent).toContain('1 von SingleWord'); + }); + + it('renders a zero-percent left segment when outCount is zero', async () => { + render(DistributionBar, { + outCount: 0, + inCount: 4, + senderName: 'Hans', + receiverName: 'Anna' + }); + + const segments = document.querySelectorAll('[data-testid="dist-bar-segment"]'); + expect((segments[0] as HTMLElement).style.width).toBe('0%'); + expect((segments[1] as HTMLElement).style.width).toBe('100%'); + }); +}); -- 2.49.1 From 10be13e6cdc848217e41974ff0aafab0e7459529 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:33:53 +0200 Subject: [PATCH 08/30] refactor(briefwechsel): ConversationTimeline consumes DistributionBar Drops the inline bilateral-distribution markup and the short-name / percentage helpers that only existed to feed it. ConversationTimeline now hands senderName, receiverName, and the two counts to the shared component and lets it own the rendering. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../briefwechsel/ConversationTimeline.svelte | 41 ++++--------------- 1 file changed, 7 insertions(+), 34 deletions(-) diff --git a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte index 31b45467..68d6a799 100644 --- a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte +++ b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte @@ -1,6 +1,7 @@ {#if isBilateral && documents.length > 0} - + {/if}
-- 2.49.1 From 9b5c1f64bab1d9af5ed81f2c944276f78d406862 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:36:43 +0200 Subject: [PATCH 09/30] feat(relativeTime): add relativeYearsDe helper for historical letter dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The correspondence timeline labels each row with its distance from today ("vor 86 Jahren"). Uses calendar-field math so the anniversary day flips exactly — an ms-based 365.25d average misses by a day on leap years. Invalid / future dates return "" so the caller can hide the label rather than print "vor 0 Jahren". Refs #305 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/relativeTime.spec.ts | 30 ++++++++++++++++++++++++++- frontend/src/lib/relativeTime.ts | 19 +++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/relativeTime.spec.ts b/frontend/src/lib/relativeTime.spec.ts index 1855d07d..2a39f8ac 100644 --- a/frontend/src/lib/relativeTime.spec.ts +++ b/frontend/src/lib/relativeTime.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { relativeTimeDe } from './relativeTime'; +import { relativeTimeDe, relativeYearsDe } from './relativeTime'; const NOW = new Date('2026-04-20T12:00:00Z'); @@ -39,3 +39,31 @@ describe('relativeTimeDe', () => { expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i); }); }); + +describe('relativeYearsDe', () => { + it('returns singular "vor 1 Jahr" for exactly one whole year ago', () => { + const from = new Date('2025-04-20T12:00:00Z'); + expect(relativeYearsDe(from, NOW)).toBe('vor 1 Jahr'); + }); + + it('returns plural "vor N Jahren" for more than one year', () => { + const from = new Date('1940-04-20T12:00:00Z'); + expect(relativeYearsDe(from, NOW)).toBe('vor 86 Jahren'); + }); + + it('floors a partial year down (eleven months ago = 0 years)', () => { + const from = new Date('2025-06-01T00:00:00Z'); + // We show "vor weniger als 1 Jahr" rather than rounding up to 1. + expect(relativeYearsDe(from, NOW)).toBe('vor weniger als 1 Jahr'); + }); + + it('returns empty string when the input Date is invalid', () => { + const invalid = new Date('not-a-real-date'); + expect(relativeYearsDe(invalid, NOW)).toBe(''); + }); + + it('returns empty string for future dates', () => { + const future = new Date('2030-01-01T00:00:00Z'); + expect(relativeYearsDe(future, NOW)).toBe(''); + }); +}); diff --git a/frontend/src/lib/relativeTime.ts b/frontend/src/lib/relativeTime.ts index f2e45a7e..ef6f80d7 100644 --- a/frontend/src/lib/relativeTime.ts +++ b/frontend/src/lib/relativeTime.ts @@ -9,3 +9,22 @@ export function relativeTimeDe(from: Date, now: Date = new Date()): string { if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) }); return m.comment_time_days({ count: Math.round(minutes / 1440) }); } + +// "vor N Jahren" for a historical letter date relative to now. Computed from +// calendar fields (not a constant ms-per-year) so that a letter from exactly +// one year ago reports "vor 1 Jahr" rather than falling on the wrong side of +// a leap-year rounding. Returns "" for invalid or future dates — the caller +// should then hide the relative-time label rather than render a misleading +// "vor 0 Jahren". +export function relativeYearsDe(from: Date, now: Date = new Date()): string { + if (Number.isNaN(from.getTime()) || Number.isNaN(now.getTime())) return ''; + if (from.getTime() > now.getTime()) return ''; + let years = now.getUTCFullYear() - from.getUTCFullYear(); + const beforeAnniversary = + now.getUTCMonth() < from.getUTCMonth() || + (now.getUTCMonth() === from.getUTCMonth() && now.getUTCDate() < from.getUTCDate()); + if (beforeAnniversary) years -= 1; + if (years < 1) return 'vor weniger als 1 Jahr'; + if (years === 1) return 'vor 1 Jahr'; + return `vor ${years} Jahren`; +} -- 2.49.1 From 15d9bb1b7829b71c09dcb9a7a9c448f077328281 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:40:15 +0200 Subject: [PATCH 10/30] feat(briefwechsel): add ConversationThumbnail with aspect + page badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads thumbnailAspect from the backend and swaps between a 120×168 portrait tile and a 168×120 landscape tile so postcards and photos don't get cropped into a portrait frame. Shows a page-count badge top-right for multi-page PDFs, and a pulsing skeleton while the async thumbnail job hasn't run yet. URL assembly goes through the existing thumbnailUrl helper so cache-busting stays consistent with DocumentThumbnail. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../components/ConversationThumbnail.svelte | 48 ++++++++ .../ConversationThumbnail.svelte.spec.ts | 110 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 frontend/src/lib/components/ConversationThumbnail.svelte create mode 100644 frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts diff --git a/frontend/src/lib/components/ConversationThumbnail.svelte b/frontend/src/lib/components/ConversationThumbnail.svelte new file mode 100644 index 00000000..61c62c00 --- /dev/null +++ b/frontend/src/lib/components/ConversationThumbnail.svelte @@ -0,0 +1,48 @@ + + +
+ {#if url} + + {:else} + + {/if} + + {#if pageCount > 1} + {pageCount} + {/if} +
diff --git a/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts b/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts new file mode 100644 index 00000000..e8874710 --- /dev/null +++ b/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; + +import ConversationThumbnail from './ConversationThumbnail.svelte'; + +afterEach(() => { + cleanup(); +}); + +describe('ConversationThumbnail', () => { + it('renders the thumbnail image with a cache-busting v= query param', () => { + render(ConversationThumbnail, { + doc: { + id: '1111', + thumbnailKey: 'thumbnails/1111.jpg', + thumbnailGeneratedAt: '2026-04-10T09:00:00Z', + thumbnailAspect: 'PORTRAIT', + pageCount: 1 + } + }); + + const img = document.querySelector('img') as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img!.getAttribute('src')).toContain('/api/documents/1111/thumbnail'); + expect(img!.getAttribute('src')).toContain('v='); + }); + + it('uses portrait dimensions when aspect is PORTRAIT', () => { + render(ConversationThumbnail, { + doc: { + id: 'p1', + thumbnailKey: 'thumbnails/p1.jpg', + thumbnailAspect: 'PORTRAIT', + pageCount: 1 + } + }); + + const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement; + expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT'); + }); + + it('uses landscape dimensions when aspect is LANDSCAPE', () => { + render(ConversationThumbnail, { + doc: { + id: 'l1', + thumbnailKey: 'thumbnails/l1.jpg', + thumbnailAspect: 'LANDSCAPE', + pageCount: 1 + } + }); + + const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement; + expect(tile.getAttribute('data-aspect')).toBe('LANDSCAPE'); + }); + + it('falls back to PORTRAIT when thumbnailAspect is missing', () => { + render(ConversationThumbnail, { + doc: { + id: 'n1', + thumbnailKey: 'thumbnails/n1.jpg' + } + }); + + const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement; + expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT'); + }); + + it('renders the page badge when pageCount is greater than 1', () => { + render(ConversationThumbnail, { + doc: { + id: 'm1', + thumbnailKey: 'thumbnails/m1.jpg', + thumbnailAspect: 'PORTRAIT', + pageCount: 4 + } + }); + + const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]') as HTMLElement; + expect(badge).not.toBeNull(); + expect(badge.textContent).toContain('4'); + }); + + it('hides the page badge when pageCount is 1 or missing', () => { + render(ConversationThumbnail, { + doc: { + id: 's1', + thumbnailKey: 'thumbnails/s1.jpg', + thumbnailAspect: 'PORTRAIT', + pageCount: 1 + } + }); + + const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]'); + expect(badge).toBeNull(); + }); + + it('renders a skeleton placeholder when no thumbnailKey is set yet', () => { + render(ConversationThumbnail, { + doc: { + id: 'blank', + thumbnailAspect: 'PORTRAIT' + } + }); + + expect(document.querySelector('img')).toBeNull(); + const skeleton = document.querySelector('[data-testid="conv-thumb-skeleton"]'); + expect(skeleton).not.toBeNull(); + expect(skeleton!.className).toContain('motion-safe:animate-pulse'); + }); +}); -- 2.49.1 From dc60d27f200bd2e0117349b0764fe7160cedf13f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:45:08 +0200 Subject: [PATCH 11/30] feat(briefwechsel): add ThumbnailRow for the new correspondence row layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines ConversationThumbnail with a quote-styled summary, truncated meta line, and up to three tag chips (the rest collapsed into "+N"). The colored left border tells a reader at a glance whether this letter left or entered the perspective person's mailbox — replacing the previous status dot + script-type icons that were too busy for the list view. Relative-year label ("vor 76 Jahren") is derived from documentDate so the list carries temporal context without a full date column. Rendering rules: - title falls back to originalFilename when empty - summary uses a text expression, never {@html}, so inline markup in the summary field is escaped (XSS regression test locks this) - focus-visible outline + focus-within hover keep keyboard-only users in sync with mouse hover feedback - aria-label always pairs title with the formatted date so screen readers hear both identifiers Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../src/lib/components/ThumbnailRow.svelte | 110 +++++++++++ .../components/ThumbnailRow.svelte.spec.ts | 177 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 frontend/src/lib/components/ThumbnailRow.svelte create mode 100644 frontend/src/lib/components/ThumbnailRow.svelte.spec.ts diff --git a/frontend/src/lib/components/ThumbnailRow.svelte b/frontend/src/lib/components/ThumbnailRow.svelte new file mode 100644 index 00000000..ef9a8ccb --- /dev/null +++ b/frontend/src/lib/components/ThumbnailRow.svelte @@ -0,0 +1,110 @@ + + + + + +
+
+
+ {title} +
+ {#if relativeYearLabel} +
{relativeYearLabel}
+ {/if} +
+ + {#if doc.summary} +
+ “{doc.summary}” +
+ {/if} + +
+ {doc.documentDate ? formatDate(doc.documentDate) : '—'} + {#if doc.location} + · + {doc.location} + {/if} + {#if otherPartyName} + · + {otherPartyName} + {/if} +
+ + {#if displayedTags.length > 0} +
+ {#each displayedTags as tag (tag.id)} + {tag.name} + {/each} + {#if hiddenTagCount > 0} + +{hiddenTagCount} + {/if} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts b/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts new file mode 100644 index 00000000..e3c6572d --- /dev/null +++ b/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; + +import ThumbnailRow from './ThumbnailRow.svelte'; + +afterEach(() => { + cleanup(); +}); + +const baseDoc = { + id: 'd1', + title: 'Liebe Anna', + originalFilename: 'liebe_anna.pdf', + documentDate: '1950-06-01', + location: 'Berlin', + summary: 'Heute schreibe ich Dir, weil die Kinder gesund sind.', + contentType: 'application/pdf', + thumbnailKey: 'thumbnails/d1.jpg', + thumbnailGeneratedAt: '2026-04-01T12:00:00Z', + thumbnailAspect: 'PORTRAIT' as const, + pageCount: 2, + sender: { id: 'hans', firstName: 'Hans', lastName: 'Müller', displayName: 'Hans Müller' }, + receivers: [{ id: 'anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }], + tags: [ + { id: 't1', name: 'Familie' }, + { id: 't2', name: 'Krieg' }, + { id: 't3', name: 'Reise' }, + { id: 't4', name: 'Arbeit' }, + { id: 't5', name: 'Zuhause' } + ] +}; + +describe('ThumbnailRow', () => { + it('renders the title, date, location, and summary quote', () => { + render(ThumbnailRow, { + doc: baseDoc, + isOut: true, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + expect(document.body.textContent).toContain('Liebe Anna'); + expect(document.body.textContent).toContain('Berlin'); + expect(document.body.textContent).toContain('Heute schreibe ich Dir'); + }); + + it('falls back to originalFilename when title is empty', () => { + render(ThumbnailRow, { + doc: { ...baseDoc, title: '' }, + isOut: true, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + expect(document.body.textContent).toContain('liebe_anna.pdf'); + }); + + it('shows the other-party name when showOtherParty=true (non-bilateral list)', () => { + render(ThumbnailRow, { + doc: baseDoc, + isOut: true, + showOtherParty: true, + now: new Date('2026-06-01T00:00:00Z') + }); + + // Out-going from Hans, other party is first receiver (Anna Schmidt) + expect(document.body.textContent).toContain('Anna Schmidt'); + }); + + it('hides the other-party name when showOtherParty=false (bilateral list)', () => { + render(ThumbnailRow, { + doc: baseDoc, + isOut: false, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + // Anna is the receiver; in a bilateral list we suppress party names. + expect(document.body.textContent).not.toContain('Anna Schmidt'); + }); + + it('renders at most 3 tag chips and signals any remainder with "+N"', () => { + render(ThumbnailRow, { + doc: baseDoc, + isOut: true, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]'); + expect(chips.length).toBeLessThanOrEqual(3); + expect(document.body.textContent).toMatch(/\+2/); + }); + + it('renders relative-year label derived from documentDate', () => { + render(ThumbnailRow, { + doc: baseDoc, + isOut: true, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + // 1950-06-01 → 2026-06-01 = 76 years + expect(document.body.textContent).toContain('vor 76 Jahren'); + }); + + it('sets border-l class based on isOut', () => { + const { unmount } = render(ThumbnailRow, { + doc: baseDoc, + isOut: true, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + let link = document.querySelector('a[href="/documents/d1"]') as HTMLElement; + expect(link.className).toContain('border-l-primary'); + + unmount(); + + render(ThumbnailRow, { + doc: baseDoc, + isOut: false, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + link = document.querySelector('a[href="/documents/d1"]') as HTMLElement; + expect(link.className).toContain('border-l-accent'); + }); + + it('exposes a descriptive aria-label combining title and date', () => { + render(ThumbnailRow, { + doc: baseDoc, + isOut: true, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement; + const label = link.getAttribute('aria-label') ?? ''; + expect(label).toContain('Liebe Anna'); + expect(label).toMatch(/1950/); + }); + + it('does not inject raw HTML when summary contains markup (XSS regression)', () => { + render(ThumbnailRow, { + doc: { + ...baseDoc, + summary: 'safe text' + }, + isOut: true, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + // No real img tag from the summary, the ConversationThumbnail img is fine. + const imgs = document.querySelectorAll('img[onerror]'); + expect(imgs.length).toBe(0); + expect(document.body.textContent).toContain(''); + }); + + it('handles missing optional fields without crashing', () => { + render(ThumbnailRow, { + doc: { + id: 'n1', + title: 'Ohne Datum', + originalFilename: 'x.pdf', + contentType: 'application/pdf', + thumbnailAspect: 'PORTRAIT' + }, + isOut: true, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + expect(document.body.textContent).toContain('Ohne Datum'); + }); +}); -- 2.49.1 From 80728200c6d0b3b1c52fd69123b76763dc7532e6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:47:41 +0200 Subject: [PATCH 12/30] refactor(briefwechsel): ConversationTimeline renders ThumbnailRow per letter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the inline row markup, arrow icons, status-dot helper, and the otherPartyName helper that only fed it. Each visible row is now a ThumbnailRow, which owns its own aria-label, border color, meta and tag rendering. The year-divider and "new document" footer are untouched — they were always intended to stay as timeline chrome. Also widens the documents prop shape to include the summary, tags and thumbnail metadata that ThumbnailRow consumes; the backend already returns these fields via the Document schema so no server change was required. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../briefwechsel/ConversationTimeline.svelte | 87 ++++--------------- 1 file changed, 15 insertions(+), 72 deletions(-) diff --git a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte index 68d6a799..e1a342c1 100644 --- a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte +++ b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte @@ -1,7 +1,10 @@ diff --git a/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts b/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts index e3c6572d..ae0542e1 100644 --- a/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts +++ b/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts @@ -127,7 +127,7 @@ describe('ThumbnailRow', () => { expect(link.className).toContain('border-l-accent'); }); - it('exposes a descriptive aria-label combining title and date', () => { + it('exposes a descriptive aria-label combining direction, title, and date', () => { render(ThumbnailRow, { doc: baseDoc, isOut: true, @@ -137,10 +137,23 @@ describe('ThumbnailRow', () => { const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement; const label = link.getAttribute('aria-label') ?? ''; + expect(label).toMatch(/^Gesendet:/); expect(label).toContain('Liebe Anna'); expect(label).toMatch(/1950/); }); + it('aria-label begins with "Empfangen:" for incoming letters', () => { + render(ThumbnailRow, { + doc: baseDoc, + isOut: false, + showOtherParty: false, + now: new Date('2026-06-01T00:00:00Z') + }); + + const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement; + expect(link.getAttribute('aria-label') ?? '').toMatch(/^Empfangen:/); + }); + it('does not inject raw HTML when summary contains markup (XSS regression)', () => { render(ThumbnailRow, { doc: { -- 2.49.1 From 858c9f056437b7aba77139e2bac654378f068ace Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 20:28:32 +0200 Subject: [PATCH 18/30] i18n(briefwechsel): DistributionBar reads text + aria-label via Paraglide Drops the hardcoded German strings ("Briefverteilung in diesem Zeitraum", "{n} von {name}") and routes every visible + assistive-tech string through dist_bar_aria and dist_bar_segment message keys. An English or Spanish user now sees "from" / "de" instead of "von" both on screen and in the aria-label their screen reader announces. Refs #305 Fixes @leonievoss i18n concern from PR review Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 ++ frontend/messages/en.json | 2 ++ frontend/messages/es.json | 2 ++ .../src/lib/components/DistributionBar.svelte | 12 ++++++--- .../components/DistributionBar.svelte.spec.ts | 25 ++++++++++++++----- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7df88f45..9f8c71b8 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -165,6 +165,8 @@ "conv_hero_divider": "oder", "conv_empty_recent_label": "Zuletzt geöffnet", "conv_no_party": "—", + "dist_bar_segment": "{count} von {name}", + "dist_bar_aria": "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}", "admin_heading": "Admin Dashboard", "admin_tab_users": "Benutzer", "admin_tab_groups": "Gruppen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index dc5cbc93..f93f2a4e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -165,6 +165,8 @@ "conv_hero_divider": "or", "conv_empty_recent_label": "Recently opened", "conv_no_party": "—", + "dist_bar_segment": "{count} from {name}", + "dist_bar_aria": "Letter distribution in this period: {outCount} from {senderName}, {inCount} from {receiverName}", "admin_heading": "Admin Dashboard", "admin_tab_users": "Users", "admin_tab_groups": "Groups", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 80ea31a0..7d976d46 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -165,6 +165,8 @@ "conv_hero_divider": "o", "conv_empty_recent_label": "Recientemente abiertos", "conv_no_party": "—", + "dist_bar_segment": "{count} de {name}", + "dist_bar_aria": "Distribución de cartas en este período: {outCount} de {senderName}, {inCount} de {receiverName}", "admin_heading": "Panel de administración", "admin_tab_users": "Usuarios", "admin_tab_groups": "Grupos", diff --git a/frontend/src/lib/components/DistributionBar.svelte b/frontend/src/lib/components/DistributionBar.svelte index d0e52292..4b9a09ad 100644 --- a/frontend/src/lib/components/DistributionBar.svelte +++ b/frontend/src/lib/components/DistributionBar.svelte @@ -1,4 +1,6 @@