From 99e3163c0e13893fc16376586a995f468ed342d9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 15:43:39 +0100 Subject: [PATCH] feat(quick-upload): pre-fill date and sender from structured filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storeDocument() now uses the ParsedFilename record to also set documentDate and sender on new quick-uploads. Sender lookup is an exact case-insensitive first+last name match — no new persons are created. Unmatched filenames behave as before. Co-Authored-By: Claude Sonnet 4.6 --- .../repository/PersonRepository.java | 3 ++ .../service/DocumentService.java | 53 ++++++++++++------- .../familienarchiv/service/PersonService.java | 5 ++ .../service/DocumentServiceTest.java | 48 +++++++++++++++++ 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index 2fef46d3..3c1411b1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository { // Lookup by full alias string, used during ODS mass import Optional findByAliasIgnoreCase(String alias); + // Exact first+last name match, used for filename-based sender lookup + Optional findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName); + // --- Correspondent queries --- @Query(value = """ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index dab03a48..bb379194 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -63,9 +63,15 @@ public class DocumentService { document = existingDoc.get(); } else { // New uploads from the drop zone always start as incomplete + ParsedFilename parsed = parseFilenameData(originalFilename); + Person sender = (parsed != null) + ? personService.findByName(parsed.firstName(), parsed.lastName()).orElse(null) + : null; document = Document.builder() .originalFilename(originalFilename) - .title(titleFromFilename(originalFilename)) + .title(parsed != null ? parsed.title() : stripExtension(originalFilename)) + .documentDate(parsed != null ? parsed.date() : null) + .sender(sender) .status(DocumentStatus.UPLOADED) .metadataComplete(false) .build(); @@ -356,29 +362,35 @@ public class DocumentService { return dot > 0 ? filename.substring(0, dot) : filename; } + private record ParsedFilename(LocalDate date, String firstName, String lastName) { + String title() { + String dateDisplay = String.format("%02d.%02d.%d", + date.getDayOfMonth(), date.getMonthValue(), date.getYear()); + return firstName + " " + lastName + " (" + dateDisplay + ")"; + } + } + /** - * Derives a human-readable title from a structured filename. + * Parses a structured filename into its date and name components. * * Algorithm: split stem on "_", identify the date token (first or last segment), * treat the outermost remaining segment as firstName, rest as lastName parts. - * Compound last names (e.g. "de_Gruyter") are handled naturally. - * Falls back to stripExtension for unrecognised filenames. + * Compound last names (e.g. "de_Gruyter") are supported naturally. + * Returns null for unrecognised filenames. * * Examples: - * 18881025_de_Gruyter_Walter.pdf → "Walter de Gruyter (25.10.1888)" - * 1965-03-12_Mueller_Hans.pdf → "Hans Mueller (12.03.1965)" - * Mueller_Hans_19650312.pdf → "Hans Mueller (12.03.1965)" + * 18881025_de_Gruyter_Walter.pdf → date=1888-10-25, firstName=Walter, lastName=de Gruyter + * 1965-03-12_Mueller_Hans.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller + * Mueller_Hans_19650312.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller */ - static String titleFromFilename(String filename) { + private static ParsedFilename parseFilenameData(String filename) { if (filename == null) return null; - int dot = filename.lastIndexOf('.'); - if (dot < 0) return stripExtension(filename); + if (dot < 0) return null; String stem = filename.substring(0, dot); String[] parts = stem.split("_", -1); - // Minimum: date + at least one lastName segment + firstName - if (parts.length < 3) return stripExtension(filename); + if (parts.length < 3) return null; String dateIso; String[] nameParts; @@ -389,23 +401,26 @@ public class DocumentService { nameParts = Arrays.copyOfRange(parts, 1, parts.length); } else { String dateFromLast = tryParseDate(parts[parts.length - 1]); - if (dateFromLast == null) return stripExtension(filename); + if (dateFromLast == null) return null; dateIso = dateFromLast; nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1); } - if (nameParts.length < 2) return stripExtension(filename); - + if (nameParts.length < 2) return null; for (String p : nameParts) { - if (!p.matches("\\p{L}+")) return stripExtension(filename); + if (!p.matches("\\p{L}+")) return null; } String firstName = nameParts[nameParts.length - 1]; String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1)); + return new ParsedFilename(LocalDate.parse(dateIso), firstName, lastName); + } - LocalDate date = LocalDate.parse(dateIso); - String dateDisplay = String.format("%02d.%02d.%d", date.getDayOfMonth(), date.getMonthValue(), date.getYear()); - return firstName + " " + lastName + " (" + dateDisplay + ")"; + // Used by tests and as a public utility; delegates to parseFilenameData. + static String titleFromFilename(String filename) { + if (filename == null) return null; + ParsedFilename parsed = parseFilenameData(filename); + return parsed != null ? parsed.title() : stripExtension(filename); } private static String tryParseDate(String s) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index a3b56da1..8ac9a3bd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.service; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; @@ -42,6 +43,10 @@ public class PersonService { return personRepository.findAllById(ids); } + public Optional findByName(String firstName, String lastName) { + return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName); + } + @Transactional public Person findOrCreateByAlias(String rawName) { String alias = rawName.trim(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 5e94216a..c4a1c6d2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.data.domain.Sort; @@ -419,6 +420,53 @@ class DocumentServiceTest { assertThat(existing.isMetadataComplete()).isTrue(); } + @Test + void storeDocument_parsesDateFromFilename_forNewDocument() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1}); + when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + when(personService.findByName(any(), any())).thenReturn(Optional.empty()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.storeDocument(file); + + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().getDocumentDate()).isEqualTo(java.time.LocalDate.of(1965, 3, 12)); + assertThat(captor.getValue().getTitle()).isEqualTo("Hans Mueller (12.03.1965)"); + } + + @Test + void storeDocument_setsSender_whenPersonExistsForParsedName() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "18881025_de_Gruyter_Walter.pdf", "application/pdf", new byte[]{1}); + Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("de Gruyter").build(); + when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + when(personService.findByName("Walter", "de Gruyter")).thenReturn(Optional.of(walter)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.storeDocument(file); + + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().getSender()).isEqualTo(walter); + } + + @Test + void storeDocument_leavesSenderNull_whenPersonNotFound() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1}); + when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + when(personService.findByName(any(), any())).thenReturn(Optional.empty()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.storeDocument(file); + + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().getSender()).isNull(); + } + // ─── createDocument metadataComplete ───────────────────────────────────── @Test