feat(quick-upload): pre-fill date and sender from structured filename
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m28s
CI / Backend Unit Tests (pull_request) Successful in 2m14s
CI / E2E Tests (pull_request) Failing after 28m25s

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-26 15:43:39 +01:00
parent f0940524e7
commit 99e3163c0e
4 changed files with 90 additions and 19 deletions

View File

@@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
// Exact first+last name match, used for filename-based sender lookup
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
// --- Correspondent queries ---
@Query(value = """

View File

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

View File

@@ -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<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}
@Transactional
public Person findOrCreateByAlias(String rawName) {
String alias = rawName.trim();

View File

@@ -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<Document> 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<Document> 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<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().getSender()).isNull();
}
// ─── createDocument metadataComplete ─────────────────────────────────────
@Test