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
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:
@@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Lookup by full alias string, used during ODS mass import
|
// Lookup by full alias string, used during ODS mass import
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
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 ---
|
// --- Correspondent queries ---
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
|
|||||||
@@ -63,9 +63,15 @@ public class DocumentService {
|
|||||||
document = existingDoc.get();
|
document = existingDoc.get();
|
||||||
} else {
|
} else {
|
||||||
// New uploads from the drop zone always start as incomplete
|
// 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()
|
document = Document.builder()
|
||||||
.originalFilename(originalFilename)
|
.originalFilename(originalFilename)
|
||||||
.title(titleFromFilename(originalFilename))
|
.title(parsed != null ? parsed.title() : stripExtension(originalFilename))
|
||||||
|
.documentDate(parsed != null ? parsed.date() : null)
|
||||||
|
.sender(sender)
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.metadataComplete(false)
|
.metadataComplete(false)
|
||||||
.build();
|
.build();
|
||||||
@@ -356,29 +362,35 @@ public class DocumentService {
|
|||||||
return dot > 0 ? filename.substring(0, dot) : filename;
|
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),
|
* Algorithm: split stem on "_", identify the date token (first or last segment),
|
||||||
* treat the outermost remaining segment as firstName, rest as lastName parts.
|
* treat the outermost remaining segment as firstName, rest as lastName parts.
|
||||||
* Compound last names (e.g. "de_Gruyter") are handled naturally.
|
* Compound last names (e.g. "de_Gruyter") are supported naturally.
|
||||||
* Falls back to stripExtension for unrecognised filenames.
|
* Returns null for unrecognised filenames.
|
||||||
*
|
*
|
||||||
* Examples:
|
* Examples:
|
||||||
* 18881025_de_Gruyter_Walter.pdf → "Walter de Gruyter (25.10.1888)"
|
* 18881025_de_Gruyter_Walter.pdf → date=1888-10-25, firstName=Walter, lastName=de Gruyter
|
||||||
* 1965-03-12_Mueller_Hans.pdf → "Hans Mueller (12.03.1965)"
|
* 1965-03-12_Mueller_Hans.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
|
||||||
* Mueller_Hans_19650312.pdf → "Hans Mueller (12.03.1965)"
|
* 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;
|
if (filename == null) return null;
|
||||||
|
|
||||||
int dot = filename.lastIndexOf('.');
|
int dot = filename.lastIndexOf('.');
|
||||||
if (dot < 0) return stripExtension(filename);
|
if (dot < 0) return null;
|
||||||
String stem = filename.substring(0, dot);
|
String stem = filename.substring(0, dot);
|
||||||
|
|
||||||
String[] parts = stem.split("_", -1);
|
String[] parts = stem.split("_", -1);
|
||||||
// Minimum: date + at least one lastName segment + firstName
|
if (parts.length < 3) return null;
|
||||||
if (parts.length < 3) return stripExtension(filename);
|
|
||||||
|
|
||||||
String dateIso;
|
String dateIso;
|
||||||
String[] nameParts;
|
String[] nameParts;
|
||||||
@@ -389,23 +401,26 @@ public class DocumentService {
|
|||||||
nameParts = Arrays.copyOfRange(parts, 1, parts.length);
|
nameParts = Arrays.copyOfRange(parts, 1, parts.length);
|
||||||
} else {
|
} else {
|
||||||
String dateFromLast = tryParseDate(parts[parts.length - 1]);
|
String dateFromLast = tryParseDate(parts[parts.length - 1]);
|
||||||
if (dateFromLast == null) return stripExtension(filename);
|
if (dateFromLast == null) return null;
|
||||||
dateIso = dateFromLast;
|
dateIso = dateFromLast;
|
||||||
nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
|
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) {
|
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 firstName = nameParts[nameParts.length - 1];
|
||||||
String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, 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);
|
// Used by tests and as a public utility; delegates to parseFilenameData.
|
||||||
String dateDisplay = String.format("%02d.%02d.%d", date.getDayOfMonth(), date.getMonthValue(), date.getYear());
|
static String titleFromFilename(String filename) {
|
||||||
return firstName + " " + lastName + " (" + dateDisplay + ")";
|
if (filename == null) return null;
|
||||||
|
ParsedFilename parsed = parseFilenameData(filename);
|
||||||
|
return parsed != null ? parsed.title() : stripExtension(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String tryParseDate(String s) {
|
private static String tryParseDate(String s) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
@@ -42,6 +43,10 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
return personRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<Person> findByName(String firstName, String lastName) {
|
||||||
|
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person findOrCreateByAlias(String rawName) {
|
public Person findOrCreateByAlias(String rawName) {
|
||||||
String alias = rawName.trim();
|
String alias = rawName.trim();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
@@ -419,6 +420,53 @@ class DocumentServiceTest {
|
|||||||
assertThat(existing.isMetadataComplete()).isTrue();
|
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 ─────────────────────────────────────
|
// ─── createDocument metadataComplete ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user