diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFactory.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFactory.java new file mode 100644 index 00000000..e25b3ce8 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFactory.java @@ -0,0 +1,37 @@ +package org.raddatz.familienarchiv.document; + +import org.springframework.stereotype.Component; + +/** + * Single source of truth for the auto-generated document title + * {@code {index} – {dateLabel} – {location}}. + * + *
The {@code document} package owns this formula; {@code importing} consumes it + * (see ADR for issue #726). The leading {@code index} is the document's + * {@code originalFilename}; the date label is the honest German label produced by + * {@link DocumentTitleFormatter} (the Java half of the #666 date-label split); the + * trailing location is the {@code meta_location} verbatim, omitted when blank. + */ +@Component +public class DocumentTitleFactory { + + static final String SEPARATOR = " – "; + + /** + * Composes the auto-title from the document's current state. The date segment is + * dropped for UNKNOWN precision or a null date (the honest "no date" case); the + * location segment is dropped when blank. + */ + public String build(Document doc) { + StringBuilder title = new StringBuilder(doc.getOriginalFilename()); + if (doc.getDocumentDate() != null && doc.getMetaDatePrecision() != DatePrecision.UNKNOWN) { + title.append(SEPARATOR).append(DocumentTitleFormatter.formatTitleDate( + doc.getDocumentDate(), doc.getMetaDatePrecision(), + doc.getMetaDateEnd(), doc.getMetaDateRaw())); + } + if (doc.getLocation() != null && !doc.getLocation().isBlank()) { + title.append(SEPARATOR).append(doc.getLocation()); + } + return title.toString(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatter.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFormatter.java similarity index 97% rename from backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatter.java rename to backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFormatter.java index 65120004..89885666 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatter.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFormatter.java @@ -1,6 +1,4 @@ -package org.raddatz.familienarchiv.importing; - -import org.raddatz.familienarchiv.document.DatePrecision; +package org.raddatz.familienarchiv.document; import java.time.LocalDate; import java.time.format.DateTimeFormatter; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java index b85a8cc6..91b11d31 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.document.DocumentTitleFactory; import org.raddatz.familienarchiv.document.DocumentStatus; import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner; import org.raddatz.familienarchiv.exception.DomainException; @@ -74,6 +75,7 @@ public class DocumentImporter { Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?"); private final DocumentService documentService; + private final DocumentTitleFactory documentTitleFactory; private final PersonService personService; private final TagService tagService; private final S3Client s3Client; @@ -181,7 +183,7 @@ public class DocumentImporter { applyAttribution(doc, row); applyDates(doc, row); applyAuthoritativeAssociations(doc, row); - applyFileMetadata(doc, s3Key, contentType, status, index); + applyFileMetadata(doc, s3Key, contentType, status); applyComputedFlags(doc); return doc; } @@ -217,14 +219,15 @@ public class DocumentImporter { attachTag(doc, row.get("tags")); } - // S3 key, content type, status, and the index-derived title. + // S3 key, content type, status, and the index-derived title. The title formula lives in + // the document package's DocumentTitleFactory (single source of truth, #726); by this point + // applyDates has populated the date/location and originalFilename carries the index. private void applyFileMetadata(Document doc, String s3Key, String contentType, - DocumentStatus status, String index) { + DocumentStatus status) { doc.setStatus(status); doc.setFilePath(s3Key); doc.setContentType(contentType); - doc.setTitle(buildTitle(index, doc.getDocumentDate(), doc.getMetaDatePrecision(), - doc.getMetaDateEnd(), doc.getMetaDateRaw(), doc.getLocation())); + doc.setTitle(documentTitleFactory.build(doc)); } // metadataComplete: a document counts as fully described if any of the three "who/when" @@ -235,20 +238,6 @@ public class DocumentImporter { || !doc.getReceivers().isEmpty()); } - // The title carries the date at the HONEST precision (never a fabricated day) via the - // shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating. - private static String buildTitle(String index, LocalDate date, DatePrecision precision, - LocalDate end, String raw, String location) { - StringBuilder title = new StringBuilder(index); - if (date != null && precision != DatePrecision.UNKNOWN) { - title.append(" – ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw)); - } - if (location != null && !location.isBlank()) { - title.append(" – ").append(location); - } - return title.toString(); - } - // ─── attribution routing — register-first, always retain raw ───────────────────── private Person resolveSender(String slug, String rawName) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentTitleFactoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentTitleFactoryTest.java new file mode 100644 index 00000000..d01801c9 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentTitleFactoryTest.java @@ -0,0 +1,82 @@ +package org.raddatz.familienarchiv.document; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The auto-title composition {@code {index} – {dateLabel} – {location}} in isolation. + * The honest date-label forms themselves are pinned by {@link DocumentTitleFormatterTest} + * against the shared #666 fixture; here we assert only how the factory composes the + * three segments and which segments it omits. + */ +class DocumentTitleFactoryTest { + + private final DocumentTitleFactory factory = new DocumentTitleFactory(); + + private static Document.DocumentBuilder doc(String index) { + return Document.builder() + .originalFilename(index) + .metaDatePrecision(DatePrecision.UNKNOWN); + } + + @Test + void index_only_when_no_date_and_no_location() { + assertThat(factory.build(doc("C-0029").build())).isEqualTo("C-0029"); + } + + @Test + void index_and_year_date() { + Document d = doc("C-0029") + .documentDate(LocalDate.of(1928, 1, 15)) + .metaDatePrecision(DatePrecision.YEAR) + .build(); + assertThat(factory.build(d)).isEqualTo("C-0029 – 1928"); + } + + @Test + void index_date_and_location() { + Document d = doc("C-0029") + .documentDate(LocalDate.of(1928, 1, 15)) + .metaDatePrecision(DatePrecision.YEAR) + .location("Berlin") + .build(); + assertThat(factory.build(d)).isEqualTo("C-0029 – 1928 – Berlin"); + } + + @Test + void location_without_date_attaches_directly_to_index() { + Document d = doc("C-0029").location("Berlin").build(); + assertThat(factory.build(d)).isEqualTo("C-0029 – Berlin"); + } + + @Test + void unknown_precision_omits_the_date_segment() { + Document d = doc("C-0029") + .documentDate(LocalDate.of(1928, 1, 15)) + .metaDatePrecision(DatePrecision.UNKNOWN) + .build(); + assertThat(factory.build(d)).isEqualTo("C-0029"); + } + + @Test + void blank_location_is_omitted() { + Document d = doc("C-0029") + .documentDate(LocalDate.of(1928, 1, 15)) + .metaDatePrecision(DatePrecision.YEAR) + .location(" ") + .build(); + assertThat(factory.build(d)).isEqualTo("C-0029 – 1928"); + } + + @Test + void day_precision_renders_the_full_german_label() { + Document d = doc("C-0029") + .documentDate(LocalDate.of(1928, 1, 15)) + .metaDatePrecision(DatePrecision.DAY) + .build(); + assertThat(factory.build(d)).isEqualTo("C-0029 – 15. Januar 1928"); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentTitleFormatterTest.java similarity index 95% rename from backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatterTest.java rename to backend/src/test/java/org/raddatz/familienarchiv/document/DocumentTitleFormatterTest.java index d8f66b6e..ddc39b12 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatterTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentTitleFormatterTest.java @@ -1,10 +1,9 @@ -package org.raddatz.familienarchiv.importing; +package org.raddatz.familienarchiv.document; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; -import org.raddatz.familienarchiv.document.DatePrecision; import java.nio.file.Files; import java.nio.file.Path; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java index c97de87b..06504cbd 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java @@ -11,6 +11,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.document.DocumentTitleFactory; import org.raddatz.familienarchiv.document.DocumentStatus; import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner; import org.raddatz.familienarchiv.person.Person; @@ -59,8 +60,10 @@ class DocumentImporterTest { // override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException). lenient().when(fileStreamOpener.open(any(File.class))) .thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class))); - importer = new DocumentImporter(documentService, personService, tagService, s3Client, - thumbnailAsyncRunner, fileStreamOpener); + // Real factory (pure, dependency-free) so the title-content assertions below exercise + // the shared composition rather than a stub — the #726 single source of truth. + importer = new DocumentImporter(documentService, new DocumentTitleFactory(), personService, + tagService, s3Client, thumbnailAsyncRunner, fileStreamOpener); ReflectionTestUtils.setField(importer, "bucketName", "test-bucket"); }