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:
Marcel
2026-06-04 16:15:00 +02:00
parent 4d1a5862d0
commit b1f77bcfb6
6 changed files with 134 additions and 26 deletions

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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) {