diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java index d31539ac..1f71045a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java @@ -9,11 +9,18 @@ import java.util.UUID; */ public interface PersonSummaryDTO { UUID getId(); + String getTitle(); String getFirstName(); String getLastName(); + String getPersonType(); String getAlias(); Integer getBirthYear(); Integer getDeathYear(); String getNotes(); long getDocumentCount(); + + default String getDisplayName() { + return org.raddatz.familienarchiv.model.DisplayNameFormatter.format( + getTitle(), getFirstName(), getLastName()); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java index ff00f1e0..b7026c70 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java @@ -5,6 +5,8 @@ import lombok.Data; @Data public class PersonUpdateDTO { + @Size(max = 50) + private String title; @Size(max = 100) private String firstName; @Size(max = 100) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DisplayNameFormatter.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DisplayNameFormatter.java new file mode 100644 index 00000000..8ec42a85 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DisplayNameFormatter.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.model; + +public class DisplayNameFormatter { + + public static String format(String title, String firstName, String lastName) { + StringBuilder sb = new StringBuilder(); + if (title != null) sb.append(title).append(" "); + if (firstName != null) sb.append(firstName).append(" "); + sb.append(lastName); + return sb.toString().trim(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java index 3bd6f418..e731a78a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java @@ -21,14 +21,22 @@ public class Person { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private UUID id; - @Column(nullable = false) - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Column(name = "title") + private String title; + + @Column(nullable = true) private String firstName; @Column(nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String lastName; + @Enumerated(EnumType.STRING) + @Column(name = "person_type", nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private PersonType personType = PersonType.PERSON; + // Optional: Aliasse für die Suche (z.B. "Opa Hans") private String alias; @@ -46,4 +54,10 @@ public class Person { @JsonIgnore @Builder.Default private List nameAliases = new ArrayList<>(); + + @Transient + @Schema(accessMode = Schema.AccessMode.READ_ONLY, requiredMode = Schema.RequiredMode.REQUIRED) + public String getDisplayName() { + return DisplayNameFormatter.format(title, firstName, lastName); + } } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java index 38ad90cf..e4c089e6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java @@ -4,5 +4,6 @@ public enum PersonNameAliasType { BIRTH, WIDOWED, DIVORCED, + MAIDEN_NAME, OTHER } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java new file mode 100644 index 00000000..db95b5bc --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.model; + +public enum PersonType { + PERSON, + INSTITUTION, + GROUP, + UNKNOWN, + SKIP +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java index ee9550c1..d8d572bc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -41,7 +41,7 @@ public class DocumentSpecifications { cb.equal(receiverRoot.get("id"), root.get("id")), cb.or( cb.like(cb.lower(receiverJoin.get("lastName")), likePattern), - cb.like(cb.lower(receiverJoin.get("firstName")), likePattern) + cb.like(cb.lower(cb.coalesce(receiverJoin.get("firstName"), "")), likePattern) ) ); @@ -74,7 +74,7 @@ public class DocumentSpecifications { cb.like(cb.lower(root.get("transcription")), likePattern), cb.like(cb.lower(root.get("location")), likePattern), cb.like(cb.lower(senderJoin.get("lastName")), likePattern), - cb.like(cb.lower(senderJoin.get("firstName")), likePattern), + cb.like(cb.lower(cb.coalesce(senderJoin.get("firstName"), "")), likePattern), cb.like(cb.lower(senderAliasJoin.get("lastName")), likePattern), cb.exists(receiverSub), cb.exists(receiverAliasSub), 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 abbed802..782dde24 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -16,8 +16,8 @@ import org.springframework.stereotype.Repository; public interface PersonRepository extends JpaRepository { @Query("SELECT DISTINCT p FROM Person p LEFT JOIN p.nameAliases a WHERE " + - "LOWER(CONCAT(p.firstName,' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " + - "LOWER(CONCAT(p.lastName, ' ', p.firstName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " + "LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " + "LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " + "ORDER BY p.lastName ASC, p.firstName ASC") @@ -35,7 +35,8 @@ public interface PersonRepository extends JpaRepository { // --- PersonSummaryDTO with document count --- @Query(value = """ - SELECT p.id, p.first_name AS firstName, p.last_name AS lastName, + SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, + p.person_type AS personType, p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount @@ -46,17 +47,18 @@ public interface PersonRepository extends JpaRepository { List findAllWithDocumentCount(); @Query(value = """ - SELECT p.id, p.first_name AS firstName, p.last_name AS lastName, + SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, + p.person_type AS personType, p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount FROM persons p LEFT JOIN person_name_aliases a ON a.person_id = p.id - WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%')) - OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%')) + WHERE LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%')) + OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%')) - GROUP BY p.id, p.first_name, p.last_name, p.alias, p.birth_year, p.death_year, p.notes + GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes ORDER BY p.last_name ASC, p.first_name ASC """, nativeQuery = true) @@ -98,8 +100,8 @@ public interface PersonRepository extends JpaRepository { WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL ) shared ON shared.other_id = p.id WHERE p.id != :personId - AND (LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%')) - OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:q,'%')) + AND (LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%')) + OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:q,'%')) OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%'))) GROUP BY p.id ORDER BY COUNT(DISTINCT shared.doc_id) DESC diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java index 9f35733b..5f3cb792 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.*; +import java.util.Objects; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; @@ -301,6 +302,7 @@ public class MassImportService { Person sender = senderRaw.isBlank() ? null : findOrCreatePerson(senderRaw); List receivers = PersonNameParser.parseReceivers(receiversRaw).stream() .map(this::findOrCreatePerson) + .filter(Objects::nonNull) .toList(); Tag tag = null; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java index 2706d630..caa0cad9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -16,14 +17,21 @@ public class PersonNameParser { // Known last names in this archive, longest first to avoid partial matches // (e.g. "de Gruyter" must be checked before any single-word name) static final List KNOWN_LAST_NAMES = List.of( + "von der Heide", "von Massenbach", "von Geldern", "von Gelden", "von Staa", "de Gruyter", "Dieckmann", "Gruber", "Müller", "Wolff", "Cram"); - private static final Pattern GEB_PATTERN = Pattern.compile("\\s+geb\\.\\s+\\S+"); + private static final Pattern GEB_PATTERN = Pattern.compile(",?\\s*geb\\.?\\s+(.+)$"); private static final Pattern PAREN_LAST_NAME = Pattern.compile("\\(([^)]+)\\)\\s*$"); private static final Pattern MULTI_SEPARATOR = Pattern.compile("\\s+(?:und|u)\\s+"); private static final Pattern SLASH_SEPARATOR = Pattern.compile("//"); - public record SplitName(String firstName, String lastName) {} + public record SplitName( + String title, + String firstName, + String lastName, + String maidenName, + String annotation + ) {} /** * Parses the "An" field from the ODS into individual normalised name strings. @@ -53,7 +61,14 @@ public class PersonNameParser { // 1. Strip "geb. Xxx" maiden-name annotations String cleaned = GEB_PATTERN.matcher(raw).replaceAll("").trim(); - // 2. Extract parenthesised last name override, e.g. "(Gruber)" + // 2. If no multi-separator present, this is a single person — leave parens + // intact for split()'s annotation extraction + if (!MULTI_SEPARATOR.matcher(cleaned).find()) { + return List.of(cleaned); + } + + // 3. Extract parenthesised last name override, e.g. "(Gruber)" + // Only applies to multi-person entries like "Hedi und Tutu (Gruber)" String sharedLastName = null; Matcher parenMatcher = PAREN_LAST_NAME.matcher(cleaned); if (parenMatcher.find()) { @@ -61,11 +76,6 @@ public class PersonNameParser { cleaned = cleaned.substring(0, parenMatcher.start()).trim(); } - // 3. If no multi-separator present, this is a single person - if (!MULTI_SEPARATOR.matcher(cleaned).find()) { - return List.of(cleaned); - } - // 4. Split on " und " / " u " String[] parts = MULTI_SEPARATOR.split(cleaned); @@ -112,35 +122,157 @@ public class PersonNameParser { return nameParts; } + // --- Pipeline result records (package-private for testing) --- + + public record MaidenNameResult(String cleaned, String maidenName) {} + public record AnnotationResult(String cleaned, String annotation) {} + public record TitleResult(String cleaned, String title) {} + record NameParts(String firstName, String lastName) {} + /** - * Splits a single full name string into firstName and lastName. - * Uses known last names first; falls back to splitting on the last space. + * Splits a single full name string into a structured SplitName. + * Pipeline: stripMaidenName → normalizeDotCompressed → stripAnnotation → stripTitle → splitByKnownLastNameOrFallback */ public static SplitName split(String rawName) { if (rawName == null || rawName.isBlank()) { - return new SplitName("?", "?"); + return new SplitName(null, "?", "?", null, null); } - String cleaned = GEB_PATTERN.matcher(rawName).replaceAll("").trim(); + MaidenNameResult maiden = stripMaidenName(rawName); + String cleaned = maiden.cleaned(); - // Normalize dot-compressed names: "Dr.Fr.Zarncke" -> "Dr. Fr. Zarncke" - if (!cleaned.contains(" ") && cleaned.contains(".")) { - cleaned = cleaned.replace(".", ". ").trim(); + cleaned = normalizeDotCompressed(cleaned); + + AnnotationResult paren = stripAnnotation(cleaned); + cleaned = paren.cleaned(); + + TitleResult title = stripTitle(cleaned); + cleaned = title.cleaned(); + + NameParts parts = splitByKnownLastNameOrFallback(cleaned); + + String firstName = parts.firstName(); + String lastName = parts.lastName(); + + // When a title was stripped and no first name could be extracted, the + // remaining text is the lastName. "Tante Molly" -> title=Tante, lastName=Molly. + if (title.title() != null) { + if ("?".equals(lastName) && !cleaned.contains(" ")) { + lastName = firstName; + firstName = null; + } else if (Objects.equals(firstName, lastName)) { + firstName = null; + } } + return new SplitName( + title.title(), firstName, lastName, + maiden.maidenName(), paren.annotation() + ); + } + + /** Strips geb annotations and extracts the maiden name. */ + public static MaidenNameResult stripMaidenName(String input) { + Matcher m = GEB_PATTERN.matcher(input); + if (m.find()) { + String cleaned = input.substring(0, m.start()).trim(); + String maidenName = m.group(1).trim(); + return new MaidenNameResult(cleaned, maidenName); + } + return new MaidenNameResult(input, null); + } + + /** Normalizes dot-compressed names: "Dr.Fr.Zarncke" → "Dr. Fr. Zarncke" */ + static String normalizeDotCompressed(String input) { + if (!input.contains(" ") && input.contains(".")) { + return input.replace(".", ". ").trim(); + } + return input; + } + + private static final Pattern PAREN_ANNOTATION = Pattern.compile("\\s*\\(([^)]*)\\)\\s*$"); + private static final Pattern UNCERTAIN_NAME = Pattern.compile("^(\\S+)\\s+\\?\\s*$"); + + /** Strips trailing parenthesized annotations and extracts the content. */ + public static AnnotationResult stripAnnotation(String input) { + Matcher m = PAREN_ANNOTATION.matcher(input); + if (!m.find()) { + return new AnnotationResult(input, null); + } + String cleaned = input.substring(0, m.start()).trim(); + String rawAnnotation = m.group(1).trim(); + + Matcher uncertainMatcher = UNCERTAIN_NAME.matcher(rawAnnotation); + if (uncertainMatcher.matches()) { + String nameFromAnnotation = uncertainMatcher.group(1); + cleaned = (cleaned + " " + nameFromAnnotation).trim(); + return new AnnotationResult(cleaned, "?"); + } + + return new AnnotationResult(cleaned, rawAnnotation); + } + + private static final List DOT_PREFIXES = List.of("Dr.", "Prof."); + + private static final List WORD_PREFIXES = List.of( + "Frau", "Herr", "Freifrau", "Freiherr", + "Tante", "Onkel", "Schwester", "Bruder", + "Cousine", "Cousin", "Freundin", "Freund", + "Mutter", "Vater", "Pastor"); + + /** Strips known title/relationship prefixes, looping for stacked titles. */ + public static TitleResult stripTitle(String input) { + String remaining = input; + StringBuilder titleBuilder = new StringBuilder(); + boolean found = true; + + while (found) { + found = false; + + for (String prefix : DOT_PREFIXES) { + if (remaining.toLowerCase().startsWith(prefix.toLowerCase())) { + titleBuilder.append(titleBuilder.isEmpty() ? "" : " ").append(prefix); + remaining = remaining.substring(prefix.length()).trim(); + found = true; + break; + } + } + if (found) continue; + + for (String prefix : WORD_PREFIXES) { + String lower = remaining.toLowerCase(); + if (lower.startsWith(prefix.toLowerCase() + " ") || lower.equals(prefix.toLowerCase())) { + titleBuilder.append(titleBuilder.isEmpty() ? "" : " ").append(prefix); + remaining = remaining.length() > prefix.length() + ? remaining.substring(prefix.length() + 1).trim() + : ""; + found = true; + break; + } + } + } + + if (titleBuilder.isEmpty()) { + return new TitleResult(input, null); + } + return new TitleResult(remaining, titleBuilder.toString()); + } + + /** Splits a cleaned name into firstName/lastName using known last names or last-space fallback. */ + static NameParts splitByKnownLastNameOrFallback(String cleaned) { String lastName = findKnownLastName(cleaned); if (lastName != null) { String firstName = cleaned.substring(0, cleaned.length() - lastName.length()).trim(); if (firstName.isBlank()) firstName = cleaned; - return new SplitName(firstName, lastName); + return new NameParts(firstName, lastName); } int lastSpace = cleaned.lastIndexOf(' '); if (lastSpace > 0) { - return new SplitName(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim()); + return new NameParts(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim()); } - return new SplitName(cleaned, "?"); + return new NameParts(cleaned, "?"); } /** Returns the known last name that the given string ends with, or null. */ 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 51bebc24..e900dd4c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -1,9 +1,12 @@ package org.raddatz.familienarchiv.service; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; +import org.springframework.lang.Nullable; + import org.raddatz.familienarchiv.dto.PersonNameAliasDTO; import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; @@ -11,6 +14,8 @@ import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.PersonNameAlias; +import org.raddatz.familienarchiv.model.PersonNameAliasType; +import org.raddatz.familienarchiv.model.PersonType; import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.http.HttpStatus; @@ -57,16 +62,38 @@ public class PersonService { return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName); } + @Nullable @Transactional public Person findOrCreateByAlias(String rawName) { String alias = rawName.trim(); + PersonType type = PersonTypeClassifier.classify(alias); + if (type == PersonType.SKIP) return null; + return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> { + if (type == PersonType.INSTITUTION || type == PersonType.GROUP) { + return personRepository.save(Person.builder() + .alias(alias) + .lastName(alias) + .personType(type) + .build()); + } + PersonNameParser.SplitName split = PersonNameParser.split(alias); - return personRepository.save(Person.builder() + Person person = personRepository.save(Person.builder() .alias(alias) .firstName(split.firstName()) .lastName(split.lastName()) .build()); + if (split.maidenName() != null) { + int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1; + aliasRepository.save(PersonNameAlias.builder() + .person(person) + .lastName(split.maidenName()) + .type(PersonNameAliasType.MAIDEN_NAME) + .sortOrder(nextSortOrder) + .build()); + } + return person; }); } @@ -84,6 +111,7 @@ public class PersonService { public Person createPerson(PersonUpdateDTO dto) { validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = Person.builder() + .title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()) .firstName(dto.getFirstName()) .lastName(dto.getLastName()) .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()) @@ -111,6 +139,7 @@ public class PersonService { validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); + person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()); person.setFirstName(dto.getFirstName()); person.setLastName(dto.getLastName()); person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonTypeClassifier.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonTypeClassifier.java new file mode 100644 index 00000000..b3b5c094 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonTypeClassifier.java @@ -0,0 +1,63 @@ +package org.raddatz.familienarchiv.service; + +import java.util.List; +import org.raddatz.familienarchiv.model.PersonType; + +public class PersonTypeClassifier { + + private static final List SKIP_KEYWORDS = List.of( + "Briefumschlag", "Kondolenzbriefe", "Hochzeitsgedicht"); + + private static final List INSTITUTION_START = List.of( + "Firma", "Architekt"); + + private static final List INSTITUTION_END = List.of( + "GmbH"); + + private static final List GROUP_START = List.of( + "Familie", "Comité", "Comite", "Geschwister", "Gesellschafter", + "Garde", "Mitarbeiter"); + + private static final List GROUP_CONTAINS = List.of( + "Eltern", "Kinder", "Schwiegereltern"); + + public static PersonType classify(String rawName) { + if (rawName == null || rawName.isBlank()) return PersonType.PERSON; + + String lower = rawName.trim().toLowerCase(); + + for (String keyword : SKIP_KEYWORDS) { + if (lower.startsWith(keyword.toLowerCase())) return PersonType.SKIP; + } + + for (String keyword : INSTITUTION_START) { + if (lower.startsWith(keyword.toLowerCase())) return PersonType.INSTITUTION; + } + for (String keyword : INSTITUTION_END) { + if (lower.endsWith(keyword.toLowerCase())) return PersonType.INSTITUTION; + } + if (lower.endsWith(" co") || lower.endsWith(" co.")) return PersonType.INSTITUTION; + + for (String keyword : GROUP_START) { + if (lower.startsWith(keyword.toLowerCase())) return PersonType.GROUP; + } + for (String keyword : GROUP_CONTAINS) { + if (containsWord(lower, keyword.toLowerCase())) return PersonType.GROUP; + } + + return PersonType.PERSON; + } + + private static boolean containsWord(String text, String word) { + int fromIndex = 0; + while (true) { + int idx = text.indexOf(word, fromIndex); + if (idx < 0) return false; + boolean startOk = idx == 0 || !Character.isLetter(text.charAt(idx - 1)); + int end = idx + word.length(); + boolean endOk = end >= text.length() || !Character.isLetter(text.charAt(end)); + if (startOk && endOk) return true; + fromIndex = idx + 1; + } + } +} diff --git a/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql b/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql new file mode 100644 index 00000000..a01b6373 --- /dev/null +++ b/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql @@ -0,0 +1,12 @@ +-- Add title column for honorifics/salutations (Dr., Tante, Frau, etc.) +ALTER TABLE persons ADD COLUMN title VARCHAR(50); + +-- Add person_type column to distinguish persons from institutions/groups. +-- SKIP is intentionally omitted: it exists in the Java enum for parse-time +-- filtering but must never be persisted. The CHECK constraint enforces this. +ALTER TABLE persons ADD COLUMN person_type VARCHAR(20) NOT NULL DEFAULT 'PERSON'; +ALTER TABLE persons ADD CONSTRAINT chk_person_type + CHECK (person_type IN ('PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN')); + +-- Make first_name nullable for non-person entities (institutions, groups) +ALTER TABLE persons ALTER COLUMN first_name DROP NOT NULL; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index bd41be36..02973927 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -73,8 +73,10 @@ class PersonControllerTest { private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) { return new PersonSummaryDTO() { public java.util.UUID getId() { return UUID.randomUUID(); } + public String getTitle() { return null; } public String getFirstName() { return firstName; } public String getLastName() { return lastName; } + public String getPersonType() { return "PERSON"; } public String getAlias() { return null; } public Integer getBirthYear() { return null; } public Integer getDeathYear() { return null; } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java new file mode 100644 index 00000000..72674925 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java @@ -0,0 +1,53 @@ +package org.raddatz.familienarchiv.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PersonTest { + + @Test + void getDisplayName_withAllFields() { + Person person = Person.builder() + .title("Dr.") + .firstName("Walter") + .lastName("de Gruyter") + .build(); + assertThat(person.getDisplayName()).isEqualTo("Dr. Walter de Gruyter"); + } + + @Test + void getDisplayName_withoutTitle() { + Person person = Person.builder() + .firstName("Clara") + .lastName("Cram") + .build(); + assertThat(person.getDisplayName()).isEqualTo("Clara Cram"); + } + + @Test + void getDisplayName_withNullFirstName() { + Person person = Person.builder() + .lastName("Gesellschafter des Verlages") + .build(); + assertThat(person.getDisplayName()).isEqualTo("Gesellschafter des Verlages"); + } + + @Test + void getDisplayName_withTitleAndNullFirstName() { + Person person = Person.builder() + .title("Tante") + .lastName("Molly") + .build(); + assertThat(person.getDisplayName()).isEqualTo("Tante Molly"); + } + + @Test + void personType_defaultsToPerson() { + Person person = Person.builder() + .firstName("Clara") + .lastName("Cram") + .build(); + assertThat(person.getPersonType()).isEqualTo(PersonType.PERSON); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java index b0873d35..26eccf6a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java @@ -440,4 +440,26 @@ class PersonRepositoryTest { assertThat(results).hasSize(1); assertThat(results.get(0).getLastName()).isEqualTo("Cram"); } + + // ─── null firstName handling ──────────────────────────────────────────── + + @Test + void searchByName_findsPersonWithNullFirstName() { + personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build()); + + List result = personRepository.searchByName("Gesellschafter"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages"); + } + + @Test + void searchWithDocumentCount_findsPersonWithNullFirstName() { + personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build()); + + List result = personRepository.searchWithDocumentCount("Gesellschafter"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages"); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java index 75eab4b2..1ea4a22e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java @@ -1,6 +1,8 @@ package org.raddatz.familienarchiv.service; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.model.PersonNameAliasType; +import org.raddatz.familienarchiv.model.PersonType; import java.util.List; @@ -22,6 +24,30 @@ class PersonNameParserTest { .containsExactly("Eugenie de Gruyter"); } + @Test + void singlePerson_annotationParenPreserved() { + assertThat(PersonNameParser.parseReceivers("Clara de Gruyter(*1871)")) + .containsExactly("Clara de Gruyter(*1871)"); + } + + @Test + void singlePerson_nicknameParenPreserved() { + assertThat(PersonNameParser.parseReceivers("Gertrud D.(Tuttu)")) + .containsExactly("Gertrud D.(Tuttu)"); + } + + @Test + void gebAnnotation_noDot_multiWord_stripped() { + assertThat(PersonNameParser.parseReceivers("Ella Dieckmann, geb de Gruyter")) + .containsExactly("Ella Dieckmann"); + } + + @Test + void gebAnnotation_noDot_singleWord_stripped() { + assertThat(PersonNameParser.parseReceivers("Elise Rockstroh geb Sintenis")) + .containsExactly("Elise Rockstroh"); + } + @Test void twoFirstNames_sharedKnownLastName_und() { assertThat(PersonNameParser.parseReceivers("Walter und Eugenie de Gruyter")) @@ -81,6 +107,9 @@ class PersonNameParserTest { PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter"); assertThat(result.firstName()).isEqualTo("Walter"); assertThat(result.lastName()).isEqualTo("de Gruyter"); + assertThat(result.title()).isNull(); + assertThat(result.maidenName()).isNull(); + assertThat(result.annotation()).isNull(); } @Test @@ -107,22 +136,31 @@ class PersonNameParserTest { @Test void split_gebAnnotation_stripped() { PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Müller"); + assertThat(result.title()).isNull(); assertThat(result.firstName()).isEqualTo("Eugenie"); assertThat(result.lastName()).isEqualTo("de Gruyter"); + assertThat(result.maidenName()).isEqualTo("Müller"); + assertThat(result.annotation()).isNull(); } @Test void split_null_returnsPlaceholder() { PersonNameParser.SplitName result = PersonNameParser.split(null); + assertThat(result.title()).isNull(); assertThat(result.firstName()).isEqualTo("?"); assertThat(result.lastName()).isEqualTo("?"); + assertThat(result.maidenName()).isNull(); + assertThat(result.annotation()).isNull(); } @Test void split_blank_returnsPlaceholder() { PersonNameParser.SplitName result = PersonNameParser.split(" "); + assertThat(result.title()).isNull(); assertThat(result.firstName()).isEqualTo("?"); assertThat(result.lastName()).isEqualTo("?"); + assertThat(result.maidenName()).isNull(); + assertThat(result.annotation()).isNull(); } @Test @@ -152,14 +190,16 @@ class PersonNameParserTest { @Test void split_dotCompressed_titleFirstNameLastName() { PersonNameParser.SplitName result = PersonNameParser.split("Dr.Fr.Zarncke"); - assertThat(result.firstName()).isEqualTo("Dr. Fr."); + assertThat(result.title()).isEqualTo("Dr."); + assertThat(result.firstName()).isEqualTo("Fr."); assertThat(result.lastName()).isEqualTo("Zarncke"); } @Test void split_dotCompressed_titleAndLastName() { PersonNameParser.SplitName result = PersonNameParser.split("Dr.Zarnke"); - assertThat(result.firstName()).isEqualTo("Dr."); + assertThat(result.title()).isEqualTo("Dr."); + assertThat(result.firstName()).isNull(); assertThat(result.lastName()).isEqualTo("Zarnke"); } @@ -172,7 +212,8 @@ class PersonNameParserTest { @Test void split_alreadySpacedDotName_noDoubleSpacing() { PersonNameParser.SplitName result = PersonNameParser.split("Dr. Fr. Zarncke"); - assertThat(result.firstName()).isEqualTo("Dr. Fr."); + assertThat(result.title()).isEqualTo("Dr."); + assertThat(result.firstName()).isEqualTo("Fr."); assertThat(result.lastName()).isEqualTo("Zarncke"); } @@ -244,4 +285,256 @@ class PersonNameParserTest { List result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter"); assertThat(result).containsExactlyInAnyOrder("Müller", "Herbert de Gruyter"); } + + // --- pipeline pass-through methods --- + + @Test + void stripMaidenName_isPassthrough() { + PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Walter de Gruyter"); + assertThat(result.cleaned()).isEqualTo("Walter de Gruyter"); + assertThat(result.maidenName()).isNull(); + } + + @Test + void stripAnnotation_noParens_returnsNull() { + PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Walter de Gruyter"); + assertThat(result.cleaned()).isEqualTo("Walter de Gruyter"); + assertThat(result.annotation()).isNull(); + } + + @Test + void stripAnnotation_birthYear_noSpace() { + PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Clara de Gruyter(*1871)"); + assertThat(result.cleaned()).isEqualTo("Clara de Gruyter"); + assertThat(result.annotation()).isEqualTo("*1871"); + } + + @Test + void stripAnnotation_uncertainty_withSpace() { + PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Ernst Kurmany (?)"); + assertThat(result.cleaned()).isEqualTo("Ernst Kurmany"); + assertThat(result.annotation()).isEqualTo("?"); + } + + @Test + void stripAnnotation_nickname_noSpace() { + PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Gertrud D.(Tuttu)"); + assertThat(result.cleaned()).isEqualTo("Gertrud D."); + assertThat(result.annotation()).isEqualTo("Tuttu"); + } + + @Test + void stripAnnotation_uncertainName_extractsNameBack() { + PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Richard (Quast ? )"); + assertThat(result.cleaned()).isEqualTo("Richard Quast"); + assertThat(result.annotation()).isEqualTo("?"); + } + + @Test + void stripAnnotation_onlyParen_returnsPlaceholder() { + PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("(OnlyParen)"); + assertThat(result.cleaned()).isEmpty(); + assertThat(result.annotation()).isEqualTo("OnlyParen"); + } + + // --- split — annotation extraction end-to-end --- + + @Test + void split_birthYearAnnotation_extracted() { + PersonNameParser.SplitName result = PersonNameParser.split("Clara de Gruyter(*1871)"); + assertThat(result.firstName()).isEqualTo("Clara"); + assertThat(result.lastName()).isEqualTo("de Gruyter"); + assertThat(result.annotation()).isEqualTo("*1871"); + } + + @Test + void split_uncertainName_extractsLastName() { + PersonNameParser.SplitName result = PersonNameParser.split("Richard (Quast ? )"); + assertThat(result.firstName()).isEqualTo("Richard"); + assertThat(result.lastName()).isEqualTo("Quast"); + assertThat(result.annotation()).isEqualTo("?"); + } + + @Test + void stripTitle_noPrefix_returnsNull() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Walter de Gruyter"); + assertThat(result.cleaned()).isEqualTo("Walter de Gruyter"); + assertThat(result.title()).isNull(); + } + + @Test + void stripTitle_tante() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Tante Molly"); + assertThat(result.cleaned()).isEqualTo("Molly"); + assertThat(result.title()).isEqualTo("Tante"); + } + + @Test + void stripTitle_schwester() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Schwester Hanni"); + assertThat(result.cleaned()).isEqualTo("Hanni"); + assertThat(result.title()).isEqualTo("Schwester"); + } + + @Test + void stripTitle_frau() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Frau Bakker"); + assertThat(result.cleaned()).isEqualTo("Bakker"); + assertThat(result.title()).isEqualTo("Frau"); + } + + @Test + void stripTitle_cousine_withFullName() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Cousine Emmy Haniel"); + assertThat(result.cleaned()).isEqualTo("Emmy Haniel"); + assertThat(result.title()).isEqualTo("Cousine"); + } + + @Test + void stripTitle_freifrau() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Freifrau von Massenbach"); + assertThat(result.cleaned()).isEqualTo("von Massenbach"); + assertThat(result.title()).isEqualTo("Freifrau"); + } + + @Test + void stripTitle_dotPrefix_withSpace() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Dr. Sattelmacher"); + assertThat(result.cleaned()).isEqualTo("Sattelmacher"); + assertThat(result.title()).isEqualTo("Dr."); + } + + @Test + void stripTitle_dotPrefix_noSpace() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Dr.von Gelden"); + assertThat(result.cleaned()).isEqualTo("von Gelden"); + assertThat(result.title()).isEqualTo("Dr."); + } + + @Test + void stripTitle_stacked_profDr() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Prof. Dr. Muller"); + assertThat(result.cleaned()).isEqualTo("Muller"); + assertThat(result.title()).isEqualTo("Prof. Dr."); + } + + // --- split — title extraction end-to-end --- + + @Test + void split_tante_setsTitle_firstNameNull() { + PersonNameParser.SplitName result = PersonNameParser.split("Tante Molly"); + assertThat(result.title()).isEqualTo("Tante"); + assertThat(result.firstName()).isNull(); + assertThat(result.lastName()).isEqualTo("Molly"); + } + + @Test + void split_dotTitle_afterDotNorm() { + PersonNameParser.SplitName result = PersonNameParser.split("Dr.Fr.Zarncke"); + assertThat(result.title()).isEqualTo("Dr."); + assertThat(result.firstName()).isEqualTo("Fr."); + assertThat(result.lastName()).isEqualTo("Zarncke"); + } + + @Test + void split_dotTitle_noSpace_vonLastName() { + PersonNameParser.SplitName result = PersonNameParser.split("Dr.von Gelden"); + assertThat(result.title()).isEqualTo("Dr."); + assertThat(result.firstName()).isNull(); + assertThat(result.lastName()).isEqualTo("von Gelden"); + } + + // --- regression: non-prefixes not stripped --- + + @Test + void split_walter_noTitleStrip() { + PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter"); + assertThat(result.title()).isNull(); + assertThat(result.firstName()).isEqualTo("Walter"); + assertThat(result.lastName()).isEqualTo("de Gruyter"); + } + + @Test + void split_conrad_vonGeldern_noTitleStrip() { + PersonNameParser.SplitName result = PersonNameParser.split("Conrad von Geldern"); + assertThat(result.title()).isNull(); + assertThat(result.firstName()).isEqualTo("Conrad"); + assertThat(result.lastName()).isEqualTo("von Geldern"); + } + + // --- stripMaidenName — maiden name extraction --- + + @Test + void stripMaidenName_standardDot_singleWord() { + PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Eugenie de Gruyter geb. Muller"); + assertThat(result.cleaned()).isEqualTo("Eugenie de Gruyter"); + assertThat(result.maidenName()).isEqualTo("Muller"); + } + + @Test + void stripMaidenName_dot_multiWordMaidenName() { + PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Clara Cram geb. de Gruyter"); + assertThat(result.cleaned()).isEqualTo("Clara Cram"); + assertThat(result.maidenName()).isEqualTo("de Gruyter"); + } + + @Test + void stripMaidenName_commaPrefix_noDot_multiWord() { + PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Ella Dieckmann, geb de Gruyter"); + assertThat(result.cleaned()).isEqualTo("Ella Dieckmann"); + assertThat(result.maidenName()).isEqualTo("de Gruyter"); + } + + @Test + void stripMaidenName_noDot_singleWord() { + PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Elise Rockstroh geb Sintenis"); + assertThat(result.cleaned()).isEqualTo("Elise Rockstroh"); + assertThat(result.maidenName()).isEqualTo("Sintenis"); + } + + @Test + void stripMaidenName_noDot_noMarriedLastName() { + PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Elisabeth geb Fernow"); + assertThat(result.cleaned()).isEqualTo("Elisabeth"); + assertThat(result.maidenName()).isEqualTo("Fernow"); + } + + @Test + void stripMaidenName_noGeb_returnsNullMaidenName() { + PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Walter de Gruyter"); + assertThat(result.cleaned()).isEqualTo("Walter de Gruyter"); + assertThat(result.maidenName()).isNull(); + } + + // --- split — maiden name extraction end-to-end --- + + @Test + void split_gebDot_extractsMaidenName() { + PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Muller"); + assertThat(result.firstName()).isEqualTo("Eugenie"); + assertThat(result.lastName()).isEqualTo("de Gruyter"); + assertThat(result.maidenName()).isEqualTo("Muller"); + } + + @Test + void split_gebNoDot_multiWordMaidenName() { + PersonNameParser.SplitName result = PersonNameParser.split("Clara Cram geb. de Gruyter"); + assertThat(result.firstName()).isEqualTo("Clara"); + assertThat(result.lastName()).isEqualTo("Cram"); + assertThat(result.maidenName()).isEqualTo("de Gruyter"); + } + + // --- enum values --- + + @Test + void personType_hasFiveValues() { + assertThat(PersonType.values()).containsExactly( + PersonType.PERSON, PersonType.INSTITUTION, PersonType.GROUP, + PersonType.UNKNOWN, PersonType.SKIP); + } + + @Test + void personNameAliasType_includesMaidenName() { + assertThat(PersonNameAliasType.valueOf("MAIDEN_NAME")).isEqualTo(PersonNameAliasType.MAIDEN_NAME); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceIntegrationTest.java new file mode 100644 index 00000000..a13c7017 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceIntegrationTest.java @@ -0,0 +1,66 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonType; +import org.raddatz.familienarchiv.repository.PersonRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.S3Client; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class PersonServiceIntegrationTest { + + @MockitoBean S3Client s3Client; + @Autowired PersonService personService; + @Autowired PersonRepository personRepository; + + @Test + void findOrCreateByAlias_skipReturnsNull_noRecordCreated() { + Person result = personService.findOrCreateByAlias("Briefumschlag aus Java"); + + assertThat(result).isNull(); + assertThat(personRepository.findAll()).isEmpty(); + } + + @Test + void findOrCreateByAlias_institutionStoresFullNameInLastName() { + Person result = personService.findOrCreateByAlias("Arthur Collignon GmbH"); + + assertThat(result).isNotNull(); + assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION); + assertThat(result.getFirstName()).isNull(); + assertThat(result.getLastName()).isEqualTo("Arthur Collignon GmbH"); + assertThat(result.getDisplayName()).isEqualTo("Arthur Collignon GmbH"); + } + + @Test + void findOrCreateByAlias_groupStoresFullNameInLastName() { + Person result = personService.findOrCreateByAlias("Geschwister de Gruyter"); + + assertThat(result).isNotNull(); + assertThat(result.getPersonType()).isEqualTo(PersonType.GROUP); + assertThat(result.getFirstName()).isNull(); + assertThat(result.getLastName()).isEqualTo("Geschwister de Gruyter"); + } + + @Test + void findOrCreateByAlias_personSplitsNameNormally() { + Person result = personService.findOrCreateByAlias("Clara Cram"); + + assertThat(result).isNotNull(); + assertThat(result.getPersonType()).isEqualTo(PersonType.PERSON); + assertThat(result.getFirstName()).isEqualTo("Clara"); + assertThat(result.getLastName()).isEqualTo("Cram"); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index 33288bf6..1095e93f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.PersonNameAlias; import org.raddatz.familienarchiv.model.PersonNameAliasType; +import org.raddatz.familienarchiv.model.PersonType; import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.web.server.ResponseStatusException; @@ -201,6 +202,75 @@ class PersonServiceTest { verify(personRepository).save(any()); } + @Test + void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() { + String alias = "Clara Cram geb. de Gruyter"; + Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build(); + when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenReturn(saved); + when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0); + when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + personService.findOrCreateByAlias(alias); + + verify(aliasRepository).save(argThat(a -> + a.getLastName().equals("de Gruyter") && + a.getType() == PersonNameAliasType.MAIDEN_NAME)); + } + + @Test + void findOrCreateByAlias_returnsNull_whenSkip() { + Person result = personService.findOrCreateByAlias("Briefumschlag aus Java"); + assertThat(result).isNull(); + verify(personRepository, never()).save(any()); + } + + @Test + void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() { + String alias = "Arthur Collignon GmbH"; + when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenAnswer(inv -> { + Person p = inv.getArgument(0); + p.setId(UUID.randomUUID()); + return p; + }); + + Person result = personService.findOrCreateByAlias(alias); + + assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION); + assertThat(result.getFirstName()).isNull(); + assertThat(result.getLastName()).isEqualTo("Arthur Collignon GmbH"); + } + + @Test + void findOrCreateByAlias_setsGroupType_withFullNameInLastName() { + String alias = "Geschwister de Gruyter"; + when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenAnswer(inv -> { + Person p = inv.getArgument(0); + p.setId(UUID.randomUUID()); + return p; + }); + + Person result = personService.findOrCreateByAlias(alias); + + assertThat(result.getPersonType()).isEqualTo(PersonType.GROUP); + assertThat(result.getFirstName()).isNull(); + assertThat(result.getLastName()).isEqualTo("Geschwister de Gruyter"); + } + + @Test + void findOrCreateByAlias_noAlias_whenNoGeb() { + String alias = "Clara Cram"; + Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build(); + when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenReturn(saved); + + personService.findOrCreateByAlias(alias); + + verify(aliasRepository, never()).save(any()); + } + @Test void findOrCreateByAlias_trimsInput() { String alias = " Clara Cram "; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonTypeClassifierTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonTypeClassifierTest.java new file mode 100644 index 00000000..20746379 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonTypeClassifierTest.java @@ -0,0 +1,94 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.raddatz.familienarchiv.model.PersonType; + +import static org.assertj.core.api.Assertions.assertThat; + +class PersonTypeClassifierTest { + + // --- SKIP --- + + @ParameterizedTest + @CsvSource({ + "'Briefumschlag aus Java', SKIP", + "'Kondolenzbriefe zum Tod von Walter de Gruyter', SKIP", + "'Hochzeitsgedicht fur Paul u Luise de Gruyter', SKIP" + }) + void classify_skipEntries(String input, PersonType expected) { + assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected); + } + + // --- INSTITUTION --- + + @ParameterizedTest + @CsvSource({ + "'Arthur Collignon GmbH', INSTITUTION", + "'Firma Auschrath', INSTITUTION", + "'Westermann u Co', INSTITUTION", + "'Architekt Korschelt u Renker', INSTITUTION" + }) + void classify_institutionEntries(String input, PersonType expected) { + assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected); + } + + // --- GROUP --- + + @ParameterizedTest + @CsvSource({ + "'Comite der Abschiedsfeier', GROUP", + "'Comité zur Errichtung eines Heine-Denkmals', GROUP", + "'Garde du Corps', GROUP", + "'Geschwister de Gruyter', GROUP", + "'Gesellschafter des Verlages', GROUP", + "'Ella de Gruyters Eltern', GROUP", + "'Eugenie de Gruyters Kinder', GROUP", + "'Hilde de Gruyters Schwiegereltern', GROUP", + "'Eltern Muller', GROUP", + "'Familie Cram', GROUP", + "'Familie Hasenvlever', GROUP", + "'Mitarbeiter Verlag', GROUP", + "'Mitarbeiter Druckerei TrebbinClara Cram', GROUP", + "'Mitarbeiter Kunstverlag Mu', GROUP" + }) + void classify_groupEntries(String input, PersonType expected) { + assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected); + } + + // --- PERSON (default) --- + + @ParameterizedTest + @CsvSource({ + "'Walter de Gruyter', PERSON", + "'Clara Cram', PERSON", + "'Eugenie de Gruyter geb. Müller', PERSON", + "'Dr. Firma Mueller', PERSON" + }) + void classify_personEntries(String input, PersonType expected) { + assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected); + } + + // --- Edge cases --- + + @Test + void classify_null_returnsPerson() { + assertThat(PersonTypeClassifier.classify(null)).isEqualTo(PersonType.PERSON); + } + + @Test + void classify_blank_returnsPerson() { + assertThat(PersonTypeClassifier.classify(" ")).isEqualTo(PersonType.PERSON); + } + + @Test + void classify_caseInsensitive() { + assertThat(PersonTypeClassifier.classify("firma auschrath")).isEqualTo(PersonType.INSTITUTION); + } + + @Test + void classify_containsWord_findsSecondOccurrence() { + assertThat(PersonTypeClassifier.classify("Nachbareltern Eltern")).isEqualTo(PersonType.GROUP); + } +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 58a677b2..1bd5f8a2 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -485,7 +485,12 @@ "person_alias_type_BIRTH": "geborene/r", "person_alias_type_WIDOWED": "verwitwete/r", "person_alias_type_DIVORCED": "geschiedene/r", + "person_alias_type_MAIDEN_NAME": "Geburtsname", "person_alias_type_OTHER": "Sonstiger Name", + "person_type_PERSON": "Person", + "person_type_INSTITUTION": "Institution", + "person_type_GROUP": "Gruppe", + "person_type_UNKNOWN": "Unbekannt", "person_alias_add_heading": "Name hinzufuegen", "person_alias_label_type": "Art", "person_alias_label_last_name": "Nachname", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 31a7d6a6..4c247132 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -485,7 +485,12 @@ "person_alias_type_BIRTH": "Birth name", "person_alias_type_WIDOWED": "Name as widow/widower", "person_alias_type_DIVORCED": "Name after divorce", + "person_alias_type_MAIDEN_NAME": "Maiden name", "person_alias_type_OTHER": "Other name", + "person_type_PERSON": "Person", + "person_type_INSTITUTION": "Institution", + "person_type_GROUP": "Group", + "person_type_UNKNOWN": "Unknown", "person_alias_add_heading": "Add name", "person_alias_label_type": "Type", "person_alias_label_last_name": "Last name", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 5beeb2f2..89da4185 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -485,7 +485,12 @@ "person_alias_type_BIRTH": "Nombre de nacimiento", "person_alias_type_WIDOWED": "Nombre como viuda/viudo", "person_alias_type_DIVORCED": "Nombre tras el divorcio", + "person_alias_type_MAIDEN_NAME": "Apellido de soltera", "person_alias_type_OTHER": "Otro nombre", + "person_type_PERSON": "Persona", + "person_type_INSTITUTION": "Institución", + "person_type_GROUP": "Grupo", + "person_type_UNKNOWN": "Desconocido", "person_alias_add_heading": "Agregar nombre", "person_alias_label_type": "Tipo", "person_alias_label_last_name": "Apellido", diff --git a/frontend/src/lib/components/DashboardRecentDocuments.svelte b/frontend/src/lib/components/DashboardRecentDocuments.svelte index bfe10e0f..25b93c8b 100644 --- a/frontend/src/lib/components/DashboardRecentDocuments.svelte +++ b/frontend/src/lib/components/DashboardRecentDocuments.svelte @@ -7,7 +7,7 @@ type Document = { id: string; title: string; updatedAt?: string; - sender?: { id: string; firstName: string; lastName: string }; + sender?: { id: string; firstName?: string | null; lastName: string; displayName: string }; }; type StatsDTO = components['schemas']['StatsDTO']; diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte b/frontend/src/lib/components/DocumentMetadataDrawer.svelte index 22e8b13a..d057a993 100644 --- a/frontend/src/lib/components/DocumentMetadataDrawer.svelte +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte @@ -2,9 +2,9 @@ import { m } from '$lib/paraglide/messages.js'; import { formatDate } from '$lib/utils/date'; import { formatDocumentStatus } from '$lib/utils/documentStatusLabel'; -import { personAvatarColor } from '$lib/utils/personFormat'; +import { getInitials as calcInitials, personAvatarColor } from '$lib/utils/personFormat'; -type Person = { id: string; firstName: string; lastName: string }; +type Person = { id: string; firstName?: string | null; lastName: string; displayName: string }; type Tag = { id: string; name: string }; type Props = { @@ -33,11 +33,11 @@ let showAllReceivers = $state(false); const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers); function getInitials(person: Person): string { - return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase(); + return calcInitials(person); } function getFullName(person: Person): string { - return `${person.firstName} ${person.lastName}`; + return person.displayName; } diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts index 7265a9a9..aeda6bbe 100644 --- a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts @@ -5,10 +5,10 @@ import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte'; afterEach(cleanup); -const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' }; +const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller', displayName: 'Karl Müller' }; const receivers = [ - { id: 'r1', firstName: 'Anna', lastName: 'Schmidt' }, - { id: 'r2', firstName: 'Hans', lastName: 'Weber' } + { id: 'r1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }, + { id: 'r2', firstName: 'Hans', lastName: 'Weber', displayName: 'Hans Weber' } ]; const tags = [ { id: 't1', name: 'Familienbrief' }, diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index 7d610706..c7604e7c 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -7,7 +7,7 @@ import PersonChipRow from './PersonChipRow.svelte'; import OverflowPillButton from './OverflowPillButton.svelte'; import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte'; -type Person = { id: string; firstName: string; lastName: string }; +type Person = { id: string; firstName?: string | null; lastName: string; displayName: string }; type Tag = { id: string; name: string }; type Doc = { diff --git a/frontend/src/lib/components/OverflowPillButton.svelte b/frontend/src/lib/components/OverflowPillButton.svelte index 5541edc7..3661b5c4 100644 --- a/frontend/src/lib/components/OverflowPillButton.svelte +++ b/frontend/src/lib/components/OverflowPillButton.svelte @@ -3,7 +3,7 @@ import { tick } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/actions/clickOutside'; -type Person = { id: string; firstName: string; lastName: string }; +type Person = { id: string; firstName?: string | null; lastName: string; displayName: string }; type Props = { extraCount: number; @@ -67,8 +67,7 @@ function handleKeydown(e: KeyboardEvent) { href="/persons/{person.id}" class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary" > - {person.firstName} - {person.lastName} + {person.displayName} {/each} diff --git a/frontend/src/lib/components/PersonChip.svelte b/frontend/src/lib/components/PersonChip.svelte index d17e7365..97796c00 100644 --- a/frontend/src/lib/components/PersonChip.svelte +++ b/frontend/src/lib/components/PersonChip.svelte @@ -1,7 +1,7 @@ {initials} - {displayName} + {name} diff --git a/frontend/src/lib/components/PersonChipRow.svelte b/frontend/src/lib/components/PersonChipRow.svelte index e50ed3e3..d292e22d 100644 --- a/frontend/src/lib/components/PersonChipRow.svelte +++ b/frontend/src/lib/components/PersonChipRow.svelte @@ -2,7 +2,7 @@ import PersonChip from './PersonChip.svelte'; import OverflowPillDisplay from './OverflowPillDisplay.svelte'; -type Person = { id: string; firstName: string; lastName: string }; +type Person = { id: string; firstName?: string | null; lastName: string; displayName: string }; type Props = { sender: Person | null | undefined; diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte index 536ef97b..7847787f 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte +++ b/frontend/src/lib/components/PersonMultiSelect.svelte @@ -73,8 +73,7 @@ function removePerson(id: string | undefined) { - {person.firstName} - {person.lastName} + {person.displayName}