refactor(document): extract title composition into shared DocumentTitleFactory (#726)
Move DocumentTitleFormatter from importing into the document package and
introduce DocumentTitleFactory there as the single source of truth for the
{index} – {dateLabel} – {location} formula. DocumentImporter now consumes the
factory instead of owning the composition; the document package owns the rule,
importing depends on it (not the reverse). No behavioral change — importer
title assertions and the #666 fixture parity test stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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}}.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
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?");
|
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
private final DocumentTitleFactory documentTitleFactory;
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final S3Client s3Client;
|
private final S3Client s3Client;
|
||||||
@@ -181,7 +183,7 @@ public class DocumentImporter {
|
|||||||
applyAttribution(doc, row);
|
applyAttribution(doc, row);
|
||||||
applyDates(doc, row);
|
applyDates(doc, row);
|
||||||
applyAuthoritativeAssociations(doc, row);
|
applyAuthoritativeAssociations(doc, row);
|
||||||
applyFileMetadata(doc, s3Key, contentType, status, index);
|
applyFileMetadata(doc, s3Key, contentType, status);
|
||||||
applyComputedFlags(doc);
|
applyComputedFlags(doc);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -217,14 +219,15 @@ public class DocumentImporter {
|
|||||||
attachTag(doc, row.get("tags"));
|
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,
|
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
||||||
DocumentStatus status, String index) {
|
DocumentStatus status) {
|
||||||
doc.setStatus(status);
|
doc.setStatus(status);
|
||||||
doc.setFilePath(s3Key);
|
doc.setFilePath(s3Key);
|
||||||
doc.setContentType(contentType);
|
doc.setContentType(contentType);
|
||||||
doc.setTitle(buildTitle(index, doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
doc.setTitle(documentTitleFactory.build(doc));
|
||||||
doc.getMetaDateEnd(), doc.getMetaDateRaw(), doc.getLocation()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
||||||
@@ -235,20 +238,6 @@ public class DocumentImporter {
|
|||||||
|| !doc.getReceivers().isEmpty());
|
|| !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 ─────────────────────
|
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
||||||
|
|
||||||
private Person resolveSender(String slug, String rawName) {
|
private Person resolveSender(String slug, String rawName) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.DynamicTest;
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
import org.junit.jupiter.api.TestFactory;
|
import org.junit.jupiter.api.TestFactory;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -11,6 +11,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -59,8 +60,10 @@ class DocumentImporterTest {
|
|||||||
// override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException).
|
// override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException).
|
||||||
lenient().when(fileStreamOpener.open(any(File.class)))
|
lenient().when(fileStreamOpener.open(any(File.class)))
|
||||||
.thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class)));
|
.thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class)));
|
||||||
importer = new DocumentImporter(documentService, personService, tagService, s3Client,
|
// Real factory (pure, dependency-free) so the title-content assertions below exercise
|
||||||
thumbnailAsyncRunner, fileStreamOpener);
|
// 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");
|
ReflectionTestUtils.setField(importer, "bucketName", "test-bucket");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user