Dokumenttitel automatisch mit Datum/Ort synchronisieren (Save-time + Backfill) (#726) #727
@@ -68,6 +68,7 @@ import static org.raddatz.familienarchiv.document.DocumentSpecifications.*;
|
||||
public class DocumentService {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final DocumentTitleFactory documentTitleFactory;
|
||||
private final PersonService personService;
|
||||
private final FileService fileService;
|
||||
private final TagService tagService;
|
||||
@@ -379,8 +380,14 @@ public class DocumentService {
|
||||
|
||||
DocumentStatus statusBefore = doc.getStatus();
|
||||
|
||||
// Auto-title sync (#726): capture the machine title from the CURRENTLY-persisted state
|
||||
// BEFORE any setter runs — the setters below overwrite date/location and applyDatePrecision
|
||||
// skips nulls, so the old state must be read first. The submitted title is the catalog
|
||||
// auto-title iff it equals this; only then does it follow date/location forward.
|
||||
String autoTitleBefore = documentTitleFactory.build(doc);
|
||||
|
||||
// 1. Einfache Felder Update
|
||||
doc.setTitle(dto.getTitle());
|
||||
doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto));
|
||||
doc.setDocumentDate(dto.getDocumentDate());
|
||||
applyDatePrecision(doc, dto);
|
||||
validateDateRange(doc); // guard before any save (updateDocumentTags below persists)
|
||||
@@ -452,6 +459,47 @@ public class DocumentService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides the title to persist on an edit (#726). The submitted title is the catalog
|
||||
* auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact
|
||||
* comparison with no heuristic, relying on the edit form round-tripping the stored title
|
||||
* verbatim when untouched. A machine title is rebuilt from the new state so a corrected
|
||||
* date/location flows into it; a hand-written or freshly-typed title is kept verbatim. A blank
|
||||
* submission is never persisted (title is always present) — it falls back to the rebuilt
|
||||
* auto-title, which always carries at least the index.
|
||||
*/
|
||||
private String resolveTitle(String submitted, String autoBefore, Document doc, DocumentUpdateDTO dto) {
|
||||
if (submitted == null || submitted.isBlank()) {
|
||||
return documentTitleFactory.build(projectedState(doc, dto));
|
||||
}
|
||||
if (!Objects.equals(submitted, autoBefore)) {
|
||||
return submitted;
|
||||
}
|
||||
return documentTitleFactory.build(projectedState(doc, dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* The document state the regenerated title is built from, mirroring {@code updateDocument}'s
|
||||
* setter asymmetry exactly: {@code documentDate}/{@code location} are overwritten from the DTO
|
||||
* (a null value clears the field), while precision/end/raw are taken from the DTO only when
|
||||
* non-null and otherwise kept from the stored entity (see {@link #applyDatePrecision}). The
|
||||
* index ({@code originalFilename}) is never touched by a metadata edit. A mismatch here would
|
||||
* silently produce a wrong label, so it is kept lock-step with the real setters.
|
||||
*/
|
||||
private Document projectedState(Document doc, DocumentUpdateDTO dto) {
|
||||
return Document.builder()
|
||||
.originalFilename(doc.getOriginalFilename())
|
||||
.documentDate(dto.getDocumentDate())
|
||||
.location(dto.getLocation())
|
||||
.metaDatePrecision(dto.getMetaDatePrecision() != null
|
||||
? dto.getMetaDatePrecision() : doc.getMetaDatePrecision())
|
||||
.metaDateEnd(dto.getMetaDateEnd() != null
|
||||
? dto.getMetaDateEnd() : doc.getMetaDateEnd())
|
||||
.metaDateRaw(dto.getMetaDateRaw() != null
|
||||
? dto.getMetaDateRaw() : doc.getMetaDateRaw())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the three date-precision fields only when the DTO carries them.
|
||||
* A null field means "not submitted" — overwriting the stored value with null
|
||||
|
||||
@@ -23,7 +23,9 @@ public class DocumentTitleFactory {
|
||||
* location segment is dropped when blank.
|
||||
*/
|
||||
public String build(Document doc) {
|
||||
StringBuilder title = new StringBuilder(doc.getOriginalFilename());
|
||||
// originalFilename is NOT NULL in production; guard only so a synthetic/partial entity
|
||||
// never trips StringBuilder(null) with an opaque NPE.
|
||||
StringBuilder title = new StringBuilder(doc.getOriginalFilename() == null ? "" : doc.getOriginalFilename());
|
||||
if (doc.getDocumentDate() != null && doc.getMetaDatePrecision() != DatePrecision.UNKNOWN) {
|
||||
title.append(SEPARATOR).append(DocumentTitleFormatter.formatTitleDate(
|
||||
doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
@@ -74,6 +75,9 @@ class DocumentServiceTest {
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
||||
// shared composition rather than a stub — the #726 single source of truth.
|
||||
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
@@ -228,6 +232,179 @@ class DocumentServiceTest {
|
||||
assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
|
||||
}
|
||||
|
||||
// ─── updateDocument save-time auto-title regeneration (#726) ──────────────
|
||||
//
|
||||
// Exact old-vs-new comparison: the title is the catalog auto-title iff the submitted
|
||||
// title equals what the factory builds from the CURRENTLY-persisted state. The edit form
|
||||
// round-trips the stored title verbatim when untouched, so an equal submission means the
|
||||
// user did not type over it. makeStored() seeds index/date/precision/location and sets the
|
||||
// stored title to the matching auto-title, mirroring a freshly-imported row.
|
||||
|
||||
private Document makeStored(String index, LocalDate date, DatePrecision precision, String location) {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename(index)
|
||||
.documentDate(date)
|
||||
.metaDatePrecision(precision)
|
||||
.location(location)
|
||||
.receivers(new HashSet<>())
|
||||
.tags(new HashSet<>())
|
||||
.build();
|
||||
doc.setTitle(documentTitleFactory.build(doc));
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** A DTO that round-trips the stored auto-title untouched, with new date/precision/location. */
|
||||
private static DocumentUpdateDTO editDto(String submittedTitle, LocalDate date,
|
||||
DatePrecision precision, String location) {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle(submittedTitle);
|
||||
dto.setDocumentDate(date);
|
||||
dto.setMetaDatePrecision(precision);
|
||||
dto.setLocation(location);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private Document runUpdate(Document stored, DocumentUpdateDTO dto) throws Exception {
|
||||
when(documentRepository.findById(stored.getId())).thenReturn(Optional.of(stored));
|
||||
when(documentRepository.save(any())).thenReturn(stored);
|
||||
documentService.updateDocument(stored.getId(), dto, null, null);
|
||||
return stored;
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratesAutoTitle_whenDateChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// title untouched ("C-0029 – 2028 – Berlin"), date corrected to 1928
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_keepsHandWrittenTitle_whenDateChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
stored.setTitle("C-0029 – Brief an Mutter"); // hand-written, ≠ auto-title
|
||||
DocumentUpdateDTO dto = editDto("C-0029 – Brief an Mutter", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Brief an Mutter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_freshlyTypedTitleWins_overRegeneration() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// user changed the date AND typed a new title in the same save
|
||||
DocumentUpdateDTO dto = editDto("Geburtsanzeige", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("Geburtsanzeige");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratesWithNewDateAndLocation() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "München");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – München");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_dropsTrailingLocationSegment_whenLocationCleared() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// location cleared (null), title untouched
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratedTitle_doesNotContainOldDate() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).doesNotContain("2028");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_relabelsOnPrecisionChange_yearToDay() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
// stored auto-title "C-0029 – 1928"; set a full day at DAY precision
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 15), DatePrecision.DAY, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 15. Januar 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_populatesTitle_whenDateAddedToUnknownRow() throws Exception {
|
||||
Document stored = makeStored("C-0029", null, DatePrecision.UNKNOWN, null);
|
||||
// stored auto-title is just "C-0029"; add a 1928 YEAR date
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_roundTripsSeasonLabel() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
stored.setMetaDateRaw("Frühling 1943");
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
dto.setMetaDateRaw("Frühling 1943");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1943");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto("", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_treatsFileReplacedDoc_asManual() throws Exception {
|
||||
// originalFilename was reassigned by an earlier file-replace, so the stored title (built
|
||||
// at import from the old index) no longer matches build(currentState) → treated as manual.
|
||||
Document stored = makeStored("scan_2024.pdf", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stored.setTitle("C-0029 – 1928 – Berlin"); // legacy import title, ≠ build("scan_2024.pdf"…)
|
||||
DocumentUpdateDTO dto = editDto("C-0029 – 1928 – Berlin", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_idempotent_whenNothingChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
String before = stored.getTitle();
|
||||
DocumentUpdateDTO dto = editDto(before, LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo(before);
|
||||
}
|
||||
|
||||
// ─── updateDocument date-range validation (#678) ──────────────────────────
|
||||
|
||||
/** Builds a stored doc ready for an updateDocument call (collections initialised). */
|
||||
|
||||
Reference in New Issue
Block a user