feat: PersonNameParser enhancements and Person model refactor (#209-#213) #215
@@ -9,11 +9,18 @@ import java.util.UUID;
|
|||||||
*/
|
*/
|
||||||
public interface PersonSummaryDTO {
|
public interface PersonSummaryDTO {
|
||||||
UUID getId();
|
UUID getId();
|
||||||
|
String getTitle();
|
||||||
String getFirstName();
|
String getFirstName();
|
||||||
String getLastName();
|
String getLastName();
|
||||||
|
String getPersonType();
|
||||||
String getAlias();
|
String getAlias();
|
||||||
Integer getBirthYear();
|
Integer getBirthYear();
|
||||||
Integer getDeathYear();
|
Integer getDeathYear();
|
||||||
String getNotes();
|
String getNotes();
|
||||||
long getDocumentCount();
|
long getDocumentCount();
|
||||||
|
|
||||||
|
default String getDisplayName() {
|
||||||
|
return org.raddatz.familienarchiv.model.DisplayNameFormatter.format(
|
||||||
|
getTitle(), getFirstName(), getLastName());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import lombok.Data;
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
|
@Size(max = 50)
|
||||||
|
private String title;
|
||||||
@Size(max = 100)
|
@Size(max = 100)
|
||||||
private String firstName;
|
private String firstName;
|
||||||
@Size(max = 100)
|
@Size(max = 100)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,14 +21,22 @@ public class Person {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private UUID id;
|
private UUID id;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(name = "title")
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
private String title;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
private String firstName;
|
private String firstName;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String lastName;
|
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")
|
// Optional: Aliasse für die Suche (z.B. "Opa Hans")
|
||||||
private String alias;
|
private String alias;
|
||||||
|
|
||||||
@@ -46,4 +54,10 @@ public class Person {
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<PersonNameAlias> nameAliases = new ArrayList<>();
|
private List<PersonNameAlias> nameAliases = new ArrayList<>();
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
@Schema(accessMode = Schema.AccessMode.READ_ONLY, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
public String getDisplayName() {
|
||||||
|
return DisplayNameFormatter.format(title, firstName, lastName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,5 +4,6 @@ public enum PersonNameAliasType {
|
|||||||
BIRTH,
|
BIRTH,
|
||||||
WIDOWED,
|
WIDOWED,
|
||||||
DIVORCED,
|
DIVORCED,
|
||||||
|
MAIDEN_NAME,
|
||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
public enum PersonType {
|
||||||
|
PERSON,
|
||||||
|
INSTITUTION,
|
||||||
|
GROUP,
|
||||||
|
UNKNOWN,
|
||||||
|
SKIP
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ public class DocumentSpecifications {
|
|||||||
cb.equal(receiverRoot.get("id"), root.get("id")),
|
cb.equal(receiverRoot.get("id"), root.get("id")),
|
||||||
cb.or(
|
cb.or(
|
||||||
cb.like(cb.lower(receiverJoin.get("lastName")), likePattern),
|
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("transcription")), likePattern),
|
||||||
cb.like(cb.lower(root.get("location")), likePattern),
|
cb.like(cb.lower(root.get("location")), likePattern),
|
||||||
cb.like(cb.lower(senderJoin.get("lastName")), 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.like(cb.lower(senderAliasJoin.get("lastName")), likePattern),
|
||||||
cb.exists(receiverSub),
|
cb.exists(receiverSub),
|
||||||
cb.exists(receiverAliasSub),
|
cb.exists(receiverAliasSub),
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import org.springframework.stereotype.Repository;
|
|||||||
public interface PersonRepository extends JpaRepository<Person, UUID> {
|
public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||||
|
|
||||||
@Query("SELECT DISTINCT p FROM Person p LEFT JOIN p.nameAliases a WHERE " +
|
@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(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(CONCAT(p.lastName, ' ', p.firstName)) 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(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||||
@@ -35,7 +35,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// --- PersonSummaryDTO with document count ---
|
// --- PersonSummaryDTO with document count ---
|
||||||
|
|
||||||
@Query(value = """
|
@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,
|
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 documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -46,17 +47,18 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
List<PersonSummaryDTO> findAllWithDocumentCount();
|
List<PersonSummaryDTO> findAllWithDocumentCount();
|
||||||
|
|
||||||
@Query(value = """
|
@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,
|
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 documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
LEFT JOIN person_name_aliases a ON a.person_id = p.id
|
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,'%'))
|
WHERE LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(CONCAT(p.last_name,' ',p.first_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(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(a.last_name) 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
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
@@ -98,8 +100,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
|
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
|
||||||
) shared ON shared.other_id = p.id
|
) shared ON shared.other_id = p.id
|
||||||
WHERE p.id != :personId
|
WHERE p.id != :personId
|
||||||
AND (LOWER(CONCAT(p.first_name,' ',p.last_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,' ',p.first_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,'%')))
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%')))
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
|
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.poi.ss.usermodel.*;
|
import org.apache.poi.ss.usermodel.*;
|
||||||
|
import java.util.Objects;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
@@ -301,6 +302,7 @@ public class MassImportService {
|
|||||||
Person sender = senderRaw.isBlank() ? null : findOrCreatePerson(senderRaw);
|
Person sender = senderRaw.isBlank() ? null : findOrCreatePerson(senderRaw);
|
||||||
List<Person> receivers = PersonNameParser.parseReceivers(receiversRaw).stream()
|
List<Person> receivers = PersonNameParser.parseReceivers(receiversRaw).stream()
|
||||||
.map(this::findOrCreatePerson)
|
.map(this::findOrCreatePerson)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Tag tag = null;
|
Tag tag = null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@@ -16,14 +17,21 @@ public class PersonNameParser {
|
|||||||
// Known last names in this archive, longest first to avoid partial matches
|
// Known last names in this archive, longest first to avoid partial matches
|
||||||
// (e.g. "de Gruyter" must be checked before any single-word name)
|
// (e.g. "de Gruyter" must be checked before any single-word name)
|
||||||
static final List<String> KNOWN_LAST_NAMES = List.of(
|
static final List<String> KNOWN_LAST_NAMES = List.of(
|
||||||
|
"von der Heide", "von Massenbach", "von Geldern", "von Gelden", "von Staa",
|
||||||
"de Gruyter", "Dieckmann", "Gruber", "Müller", "Wolff", "Cram");
|
"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 PAREN_LAST_NAME = Pattern.compile("\\(([^)]+)\\)\\s*$");
|
||||||
private static final Pattern MULTI_SEPARATOR = Pattern.compile("\\s+(?:und|u)\\s+");
|
private static final Pattern MULTI_SEPARATOR = Pattern.compile("\\s+(?:und|u)\\s+");
|
||||||
private static final Pattern SLASH_SEPARATOR = Pattern.compile("//");
|
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.
|
* 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
|
// 1. Strip "geb. Xxx" maiden-name annotations
|
||||||
String cleaned = GEB_PATTERN.matcher(raw).replaceAll("").trim();
|
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;
|
String sharedLastName = null;
|
||||||
Matcher parenMatcher = PAREN_LAST_NAME.matcher(cleaned);
|
Matcher parenMatcher = PAREN_LAST_NAME.matcher(cleaned);
|
||||||
if (parenMatcher.find()) {
|
if (parenMatcher.find()) {
|
||||||
@@ -61,11 +76,6 @@ public class PersonNameParser {
|
|||||||
cleaned = cleaned.substring(0, parenMatcher.start()).trim();
|
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 "
|
// 4. Split on " und " / " u "
|
||||||
String[] parts = MULTI_SEPARATOR.split(cleaned);
|
String[] parts = MULTI_SEPARATOR.split(cleaned);
|
||||||
|
|
||||||
@@ -112,35 +122,157 @@ public class PersonNameParser {
|
|||||||
return nameParts;
|
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.
|
* Splits a single full name string into a structured SplitName.
|
||||||
* Uses known last names first; falls back to splitting on the last space.
|
* Pipeline: stripMaidenName → normalizeDotCompressed → stripAnnotation → stripTitle → splitByKnownLastNameOrFallback
|
||||||
*/
|
*/
|
||||||
public static SplitName split(String rawName) {
|
public static SplitName split(String rawName) {
|
||||||
if (rawName == null || rawName.isBlank()) {
|
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"
|
cleaned = normalizeDotCompressed(cleaned);
|
||||||
if (!cleaned.contains(" ") && cleaned.contains(".")) {
|
|
||||||
cleaned = cleaned.replace(".", ". ").trim();
|
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<String> DOT_PREFIXES = List.of("Dr.", "Prof.");
|
||||||
|
|
||||||
|
private static final List<String> 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);
|
String lastName = findKnownLastName(cleaned);
|
||||||
if (lastName != null) {
|
if (lastName != null) {
|
||||||
String firstName = cleaned.substring(0, cleaned.length() - lastName.length()).trim();
|
String firstName = cleaned.substring(0, cleaned.length() - lastName.length()).trim();
|
||||||
if (firstName.isBlank()) firstName = cleaned;
|
if (firstName.isBlank()) firstName = cleaned;
|
||||||
return new SplitName(firstName, lastName);
|
return new NameParts(firstName, lastName);
|
||||||
}
|
}
|
||||||
|
|
||||||
int lastSpace = cleaned.lastIndexOf(' ');
|
int lastSpace = cleaned.lastIndexOf(' ');
|
||||||
if (lastSpace > 0) {
|
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. */
|
/** Returns the known last name that the given string ends with, or null. */
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
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.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
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.PersonNameAliasRepository;
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -57,16 +62,38 @@ public class PersonService {
|
|||||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person findOrCreateByAlias(String rawName) {
|
public Person findOrCreateByAlias(String rawName) {
|
||||||
String alias = rawName.trim();
|
String alias = rawName.trim();
|
||||||
|
PersonType type = PersonTypeClassifier.classify(alias);
|
||||||
|
if (type == PersonType.SKIP) return null;
|
||||||
|
|
||||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
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);
|
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||||
return personRepository.save(Person.builder()
|
Person person = personRepository.save(Person.builder()
|
||||||
.alias(alias)
|
.alias(alias)
|
||||||
.firstName(split.firstName())
|
.firstName(split.firstName())
|
||||||
.lastName(split.lastName())
|
.lastName(split.lastName())
|
||||||
.build());
|
.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) {
|
public Person createPerson(PersonUpdateDTO dto) {
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
|
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||||
.firstName(dto.getFirstName())
|
.firstName(dto.getFirstName())
|
||||||
.lastName(dto.getLastName())
|
.lastName(dto.getLastName())
|
||||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||||
@@ -111,6 +139,7 @@ public class PersonService {
|
|||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + 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.setFirstName(dto.getFirstName());
|
||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||||
|
|||||||
@@ -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<String> SKIP_KEYWORDS = List.of(
|
||||||
|
"Briefumschlag", "Kondolenzbriefe", "Hochzeitsgedicht");
|
||||||
|
|
||||||
|
private static final List<String> INSTITUTION_START = List.of(
|
||||||
|
"Firma", "Architekt");
|
||||||
|
|
||||||
|
private static final List<String> INSTITUTION_END = List.of(
|
||||||
|
"GmbH");
|
||||||
|
|
||||||
|
private static final List<String> GROUP_START = List.of(
|
||||||
|
"Familie", "Comité", "Comite", "Geschwister", "Gesellschafter",
|
||||||
|
"Garde", "Mitarbeiter");
|
||||||
|
|
||||||
|
private static final List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -73,8 +73,10 @@ class PersonControllerTest {
|
|||||||
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||||
return new PersonSummaryDTO() {
|
return new PersonSummaryDTO() {
|
||||||
public java.util.UUID getId() { return UUID.randomUUID(); }
|
public java.util.UUID getId() { return UUID.randomUUID(); }
|
||||||
|
public String getTitle() { return null; }
|
||||||
public String getFirstName() { return firstName; }
|
public String getFirstName() { return firstName; }
|
||||||
public String getLastName() { return lastName; }
|
public String getLastName() { return lastName; }
|
||||||
|
public String getPersonType() { return "PERSON"; }
|
||||||
public String getAlias() { return null; }
|
public String getAlias() { return null; }
|
||||||
public Integer getBirthYear() { return null; }
|
public Integer getBirthYear() { return null; }
|
||||||
public Integer getDeathYear() { return null; }
|
public Integer getDeathYear() { return null; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -440,4 +440,26 @@ class PersonRepositoryTest {
|
|||||||
assertThat(results).hasSize(1);
|
assertThat(results).hasSize(1);
|
||||||
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── null firstName handling ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByName_findsPersonWithNullFirstName() {
|
||||||
|
personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());
|
||||||
|
|
||||||
|
List<Person> 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<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Gesellschafter");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonType;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -22,6 +24,30 @@ class PersonNameParserTest {
|
|||||||
.containsExactly("Eugenie de Gruyter");
|
.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
|
@Test
|
||||||
void twoFirstNames_sharedKnownLastName_und() {
|
void twoFirstNames_sharedKnownLastName_und() {
|
||||||
assertThat(PersonNameParser.parseReceivers("Walter und Eugenie de Gruyter"))
|
assertThat(PersonNameParser.parseReceivers("Walter und Eugenie de Gruyter"))
|
||||||
@@ -81,6 +107,9 @@ class PersonNameParserTest {
|
|||||||
PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter");
|
PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter");
|
||||||
assertThat(result.firstName()).isEqualTo("Walter");
|
assertThat(result.firstName()).isEqualTo("Walter");
|
||||||
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
||||||
|
assertThat(result.title()).isNull();
|
||||||
|
assertThat(result.maidenName()).isNull();
|
||||||
|
assertThat(result.annotation()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -107,22 +136,31 @@ class PersonNameParserTest {
|
|||||||
@Test
|
@Test
|
||||||
void split_gebAnnotation_stripped() {
|
void split_gebAnnotation_stripped() {
|
||||||
PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Müller");
|
PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Müller");
|
||||||
|
assertThat(result.title()).isNull();
|
||||||
assertThat(result.firstName()).isEqualTo("Eugenie");
|
assertThat(result.firstName()).isEqualTo("Eugenie");
|
||||||
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
||||||
|
assertThat(result.maidenName()).isEqualTo("Müller");
|
||||||
|
assertThat(result.annotation()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void split_null_returnsPlaceholder() {
|
void split_null_returnsPlaceholder() {
|
||||||
PersonNameParser.SplitName result = PersonNameParser.split(null);
|
PersonNameParser.SplitName result = PersonNameParser.split(null);
|
||||||
|
assertThat(result.title()).isNull();
|
||||||
assertThat(result.firstName()).isEqualTo("?");
|
assertThat(result.firstName()).isEqualTo("?");
|
||||||
assertThat(result.lastName()).isEqualTo("?");
|
assertThat(result.lastName()).isEqualTo("?");
|
||||||
|
assertThat(result.maidenName()).isNull();
|
||||||
|
assertThat(result.annotation()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void split_blank_returnsPlaceholder() {
|
void split_blank_returnsPlaceholder() {
|
||||||
PersonNameParser.SplitName result = PersonNameParser.split(" ");
|
PersonNameParser.SplitName result = PersonNameParser.split(" ");
|
||||||
|
assertThat(result.title()).isNull();
|
||||||
assertThat(result.firstName()).isEqualTo("?");
|
assertThat(result.firstName()).isEqualTo("?");
|
||||||
assertThat(result.lastName()).isEqualTo("?");
|
assertThat(result.lastName()).isEqualTo("?");
|
||||||
|
assertThat(result.maidenName()).isNull();
|
||||||
|
assertThat(result.annotation()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -152,14 +190,16 @@ class PersonNameParserTest {
|
|||||||
@Test
|
@Test
|
||||||
void split_dotCompressed_titleFirstNameLastName() {
|
void split_dotCompressed_titleFirstNameLastName() {
|
||||||
PersonNameParser.SplitName result = PersonNameParser.split("Dr.Fr.Zarncke");
|
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");
|
assertThat(result.lastName()).isEqualTo("Zarncke");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void split_dotCompressed_titleAndLastName() {
|
void split_dotCompressed_titleAndLastName() {
|
||||||
PersonNameParser.SplitName result = PersonNameParser.split("Dr.Zarnke");
|
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");
|
assertThat(result.lastName()).isEqualTo("Zarnke");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +212,8 @@ class PersonNameParserTest {
|
|||||||
@Test
|
@Test
|
||||||
void split_alreadySpacedDotName_noDoubleSpacing() {
|
void split_alreadySpacedDotName_noDoubleSpacing() {
|
||||||
PersonNameParser.SplitName result = PersonNameParser.split("Dr. Fr. Zarncke");
|
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");
|
assertThat(result.lastName()).isEqualTo("Zarncke");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,4 +285,256 @@ class PersonNameParserTest {
|
|||||||
List<String> result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
|
List<String> result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
|
||||||
assertThat(result).containsExactlyInAnyOrder("Müller", "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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
|||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonType;
|
||||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -201,6 +202,75 @@ class PersonServiceTest {
|
|||||||
verify(personRepository).save(any());
|
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
|
@Test
|
||||||
void findOrCreateByAlias_trimsInput() {
|
void findOrCreateByAlias_trimsInput() {
|
||||||
String alias = " Clara Cram ";
|
String alias = " Clara Cram ";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -485,7 +485,12 @@
|
|||||||
"person_alias_type_BIRTH": "geborene/r",
|
"person_alias_type_BIRTH": "geborene/r",
|
||||||
"person_alias_type_WIDOWED": "verwitwete/r",
|
"person_alias_type_WIDOWED": "verwitwete/r",
|
||||||
"person_alias_type_DIVORCED": "geschiedene/r",
|
"person_alias_type_DIVORCED": "geschiedene/r",
|
||||||
|
"person_alias_type_MAIDEN_NAME": "Geburtsname",
|
||||||
"person_alias_type_OTHER": "Sonstiger Name",
|
"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_add_heading": "Name hinzufuegen",
|
||||||
"person_alias_label_type": "Art",
|
"person_alias_label_type": "Art",
|
||||||
"person_alias_label_last_name": "Nachname",
|
"person_alias_label_last_name": "Nachname",
|
||||||
|
|||||||
@@ -485,7 +485,12 @@
|
|||||||
"person_alias_type_BIRTH": "Birth name",
|
"person_alias_type_BIRTH": "Birth name",
|
||||||
"person_alias_type_WIDOWED": "Name as widow/widower",
|
"person_alias_type_WIDOWED": "Name as widow/widower",
|
||||||
"person_alias_type_DIVORCED": "Name after divorce",
|
"person_alias_type_DIVORCED": "Name after divorce",
|
||||||
|
"person_alias_type_MAIDEN_NAME": "Maiden name",
|
||||||
"person_alias_type_OTHER": "Other 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_add_heading": "Add name",
|
||||||
"person_alias_label_type": "Type",
|
"person_alias_label_type": "Type",
|
||||||
"person_alias_label_last_name": "Last name",
|
"person_alias_label_last_name": "Last name",
|
||||||
|
|||||||
@@ -485,7 +485,12 @@
|
|||||||
"person_alias_type_BIRTH": "Nombre de nacimiento",
|
"person_alias_type_BIRTH": "Nombre de nacimiento",
|
||||||
"person_alias_type_WIDOWED": "Nombre como viuda/viudo",
|
"person_alias_type_WIDOWED": "Nombre como viuda/viudo",
|
||||||
"person_alias_type_DIVORCED": "Nombre tras el divorcio",
|
"person_alias_type_DIVORCED": "Nombre tras el divorcio",
|
||||||
|
"person_alias_type_MAIDEN_NAME": "Apellido de soltera",
|
||||||
"person_alias_type_OTHER": "Otro nombre",
|
"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_add_heading": "Agregar nombre",
|
||||||
"person_alias_label_type": "Tipo",
|
"person_alias_label_type": "Tipo",
|
||||||
"person_alias_label_last_name": "Apellido",
|
"person_alias_label_last_name": "Apellido",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type Document = {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt?: 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'];
|
type StatsDTO = components['schemas']['StatsDTO'];
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
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 Tag = { id: string; name: string };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -33,11 +33,11 @@ let showAllReceivers = $state(false);
|
|||||||
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
||||||
|
|
||||||
function getInitials(person: Person): string {
|
function getInitials(person: Person): string {
|
||||||
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
|
return calcInitials(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFullName(person: Person): string {
|
function getFullName(person: Person): string {
|
||||||
return `${person.firstName} ${person.lastName}`;
|
return person.displayName;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
|||||||
|
|
||||||
afterEach(cleanup);
|
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 = [
|
const receivers = [
|
||||||
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt' },
|
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
|
||||||
{ id: 'r2', firstName: 'Hans', lastName: 'Weber' }
|
{ id: 'r2', firstName: 'Hans', lastName: 'Weber', displayName: 'Hans Weber' }
|
||||||
];
|
];
|
||||||
const tags = [
|
const tags = [
|
||||||
{ id: 't1', name: 'Familienbrief' },
|
{ id: 't1', name: 'Familienbrief' },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import PersonChipRow from './PersonChipRow.svelte';
|
|||||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||||
import DocumentMetadataDrawer from './DocumentMetadataDrawer.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 Tag = { id: string; name: string };
|
||||||
|
|
||||||
type Doc = {
|
type Doc = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tick } from 'svelte';
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
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 = {
|
type Props = {
|
||||||
extraCount: number;
|
extraCount: number;
|
||||||
@@ -67,8 +67,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
href="/persons/{person.id}"
|
href="/persons/{person.id}"
|
||||||
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
|
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
>
|
>
|
||||||
{person.firstName}
|
{person.displayName}
|
||||||
{person.lastName}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { abbreviateName, personAvatarColor } from '$lib/utils/personFormat';
|
import { abbreviateName, getInitials, 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 Props = {
|
type Props = {
|
||||||
person: Person;
|
person: Person;
|
||||||
@@ -10,13 +10,9 @@ type Props = {
|
|||||||
|
|
||||||
let { person, abbreviated }: Props = $props();
|
let { person, abbreviated }: Props = $props();
|
||||||
|
|
||||||
const displayName = $derived(
|
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
|
||||||
abbreviated ? abbreviateName(person) : `${person.firstName} ${person.lastName}`
|
|
||||||
);
|
|
||||||
const avatarColor = $derived(personAvatarColor(person.id));
|
const avatarColor = $derived(personAvatarColor(person.id));
|
||||||
const initials = $derived(
|
const initials = $derived(getInitials(person));
|
||||||
`${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase()
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@@ -30,5 +26,5 @@ const initials = $derived(
|
|||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[14px] font-semibold text-ink">{displayName}</span>
|
<span class="text-[14px] font-semibold text-ink">{name}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import PersonChip from './PersonChip.svelte';
|
import PersonChip from './PersonChip.svelte';
|
||||||
import OverflowPillDisplay from './OverflowPillDisplay.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 = {
|
type Props = {
|
||||||
sender: Person | null | undefined;
|
sender: Person | null | undefined;
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ function removePerson(id: string | undefined) {
|
|||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||||
>
|
>
|
||||||
{person.firstName}
|
{person.displayName}
|
||||||
{person.lastName}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removePerson(person.id)}
|
onclick={() => removePerson(person.id)}
|
||||||
@@ -121,7 +120,7 @@ function removePerson(id: string | undefined) {
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
{person.lastName}, {person.firstName}
|
{person.displayName}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
|||||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
const PERSONS = [
|
const PERSONS = [
|
||||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
{
|
||||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
|
id: '1',
|
||||||
{ id: '3', firstName: 'Karl', lastName: 'König' }
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
displayName: 'Anna Musterfrau',
|
||||||
|
personType: 'PERSON'
|
||||||
|
},
|
||||||
|
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function mockFetch(persons = PERSONS) {
|
function mockFetch(persons = PERSONS) {
|
||||||
@@ -45,8 +57,20 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
it('renders pre-selected persons as chips', async () => {
|
it('renders pre-selected persons as chips', async () => {
|
||||||
render(PersonMultiSelect, {
|
render(PersonMultiSelect, {
|
||||||
selectedPersons: [
|
selectedPersons: [
|
||||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
{
|
||||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
displayName: 'Anna Musterfrau',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
@@ -57,8 +81,20 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
it('renders hidden inputs for each selected person', async () => {
|
it('renders hidden inputs for each selected person', async () => {
|
||||||
render(PersonMultiSelect, {
|
render(PersonMultiSelect, {
|
||||||
selectedPersons: [
|
selectedPersons: [
|
||||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
{
|
||||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
displayName: 'Anna Musterfrau',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
await tick();
|
await tick();
|
||||||
@@ -70,7 +106,15 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
|
|
||||||
it('hides the placeholder when persons are selected', async () => {
|
it('hides the placeholder when persons are selected', async () => {
|
||||||
render(PersonMultiSelect, {
|
render(PersonMultiSelect, {
|
||||||
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
|
selectedPersons: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
|
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -85,7 +129,7 @@ describe('PersonMultiSelect – selecting persons', () => {
|
|||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await page.getByText('Mustermann, Max').click();
|
await page.getByText('Max Mustermann').click();
|
||||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
await expect.element(input).toHaveValue('');
|
await expect.element(input).toHaveValue('');
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
@@ -100,11 +144,11 @@ describe('PersonMultiSelect – selecting persons', () => {
|
|||||||
|
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await page.getByText('Mustermann, Max').click();
|
await page.getByText('Max Mustermann').click();
|
||||||
|
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await page.getByText('Musterfrau, Anna').click();
|
await page.getByText('Anna Musterfrau').click();
|
||||||
|
|
||||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||||
@@ -116,22 +160,41 @@ describe('PersonMultiSelect – selecting persons', () => {
|
|||||||
it('filters already-selected persons from search results', async () => {
|
it('filters already-selected persons from search results', async () => {
|
||||||
mockFetch();
|
mockFetch();
|
||||||
render(PersonMultiSelect, {
|
render(PersonMultiSelect, {
|
||||||
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
|
selectedPersons: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
// Chip still shows "Max Mustermann" but the dropdown item (role=button) must be filtered out
|
||||||
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: 'Anna Musterfrau' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects a result with Enter key', async () => {
|
it('selects a result with Enter key', async () => {
|
||||||
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
|
mockFetch([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
]);
|
||||||
render(PersonMultiSelect, { selectedPersons: [] });
|
render(PersonMultiSelect, { selectedPersons: [] });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Ma');
|
await input.fill('Ma');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await page.getByText('Mustermann, Max').click();
|
await page.getByText('Max Mustermann').click();
|
||||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -142,8 +205,20 @@ describe('PersonMultiSelect – removing persons', () => {
|
|||||||
it('removes a chip when its × button is clicked', async () => {
|
it('removes a chip when its × button is clicked', async () => {
|
||||||
render(PersonMultiSelect, {
|
render(PersonMultiSelect, {
|
||||||
selectedPersons: [
|
selectedPersons: [
|
||||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
{
|
||||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
displayName: 'Anna Musterfrau',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
// Buttons have aria-label="Entfernen"
|
// Buttons have aria-label="Entfernen"
|
||||||
@@ -156,8 +231,20 @@ describe('PersonMultiSelect – removing persons', () => {
|
|||||||
it('removes the corresponding hidden input when a chip is removed', async () => {
|
it('removes the corresponding hidden input when a chip is removed', async () => {
|
||||||
render(PersonMultiSelect, {
|
render(PersonMultiSelect, {
|
||||||
selectedPersons: [
|
selectedPersons: [
|
||||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
{
|
||||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
displayName: 'Anna Musterfrau',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
await page.getByRole('button', { name: 'Entfernen' }).first().click();
|
await page.getByRole('button', { name: 'Entfernen' }).first().click();
|
||||||
@@ -177,9 +264,9 @@ describe('PersonMultiSelect – click outside', () => {
|
|||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
document.body.click();
|
document.body.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
76
frontend/src/lib/components/PersonTypeBadge.svelte
Normal file
76
frontend/src/lib/components/PersonTypeBadge.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
personType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { personType }: Props = $props();
|
||||||
|
|
||||||
|
const config = $derived.by(() => {
|
||||||
|
switch (personType) {
|
||||||
|
case 'INSTITUTION':
|
||||||
|
return { label: m.person_type_INSTITUTION(), icon: 'building' as const };
|
||||||
|
case 'GROUP':
|
||||||
|
return { label: m.person_type_GROUP(), icon: 'people' as const };
|
||||||
|
case 'UNKNOWN':
|
||||||
|
return { label: m.person_type_UNKNOWN(), icon: 'question' as const };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if config}
|
||||||
|
<span
|
||||||
|
class="badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
|
||||||
|
class:badge-institution={personType === 'INSTITUTION'}
|
||||||
|
class:badge-group={personType === 'GROUP'}
|
||||||
|
class:badge-unknown={personType === 'UNKNOWN'}
|
||||||
|
>
|
||||||
|
{#if config.icon === 'building'}
|
||||||
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if config.icon === 'people'}
|
||||||
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge-institution {
|
||||||
|
background-color: var(--c-badge-institution-bg);
|
||||||
|
color: var(--c-badge-institution-text);
|
||||||
|
border-color: var(--c-badge-institution-border);
|
||||||
|
}
|
||||||
|
.badge-group {
|
||||||
|
background-color: var(--c-badge-group-bg);
|
||||||
|
color: var(--c-badge-group-text);
|
||||||
|
border-color: var(--c-badge-group-border);
|
||||||
|
}
|
||||||
|
.badge-unknown {
|
||||||
|
background-color: var(--c-badge-unknown-bg);
|
||||||
|
color: var(--c-badge-unknown-text);
|
||||||
|
border-color: var(--c-badge-unknown-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -117,7 +117,7 @@ function handleFocus() {
|
|||||||
|
|
||||||
function selectPerson(person: Person) {
|
function selectPerson(person: Person) {
|
||||||
value = person.id!;
|
value = person.id!;
|
||||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
searchTerm = person.displayName;
|
||||||
showDropdown = false;
|
showDropdown = false;
|
||||||
onchange?.(person.id!);
|
onchange?.(person.id!);
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ function selectPerson(person: Person) {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="block truncate font-medium">
|
<span class="block truncate font-medium">
|
||||||
{person.lastName}, {person.firstName}
|
{person.displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,20 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
|||||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
const PERSONS = [
|
const PERSONS = [
|
||||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
{
|
||||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
displayName: 'Anna Musterfrau',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
function mockFetchWithPersons(persons = PERSONS) {
|
function mockFetchWithPersons(persons = PERSONS) {
|
||||||
@@ -76,8 +88,8 @@ describe('PersonTypeahead – search', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
|
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
|
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +117,7 @@ describe('PersonTypeahead – search', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Ma');
|
await input.fill('Ma');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +134,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
|
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||||
});
|
});
|
||||||
@@ -152,7 +164,15 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('selects a result with Enter key', async () => {
|
it('selects a result with Enter key', async () => {
|
||||||
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
|
mockFetchWithPersons([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
]);
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Ma');
|
await input.fill('Ma');
|
||||||
@@ -218,7 +238,7 @@ describe('PersonTypeahead – correspondent mode', () => {
|
|||||||
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
|
|
||||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses correspondents endpoint with q param when typing', async () => {
|
it('uses correspondents endpoint with q param when typing', async () => {
|
||||||
@@ -259,9 +279,9 @@ describe('PersonTypeahead – click outside', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
document.body.click();
|
document.body.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -952,14 +952,18 @@ export interface components {
|
|||||||
Person: {
|
Person: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
title?: string;
|
||||||
|
firstName?: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
|
||||||
alias?: string;
|
alias?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
|
readonly displayName: string;
|
||||||
};
|
};
|
||||||
DocumentUpdateDTO: {
|
DocumentUpdateDTO: {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -1047,19 +1051,18 @@ export interface components {
|
|||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
};
|
};
|
||||||
PersonNameAliasDTO: {
|
PersonNameAliasDTO: {
|
||||||
lastName?: string;
|
lastName: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
type?: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
|
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "MAIDEN_NAME" | "OTHER";
|
||||||
};
|
};
|
||||||
PersonNameAlias: {
|
PersonNameAlias: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
person?: components["schemas"]["Person"];
|
|
||||||
lastName: string;
|
lastName: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
|
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "MAIDEN_NAME" | "OTHER";
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -1203,8 +1206,10 @@ export interface components {
|
|||||||
totalDocuments?: number;
|
totalDocuments?: number;
|
||||||
};
|
};
|
||||||
PersonSummaryDTO: {
|
PersonSummaryDTO: {
|
||||||
|
title?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id?: string;
|
id?: string;
|
||||||
|
displayName?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -1213,6 +1218,7 @@ export interface components {
|
|||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
personType?: string;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
documentCount?: number;
|
documentCount?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { formatDocumentStatus } from './documentStatusLabel';
|
import { formatDocumentStatus } from './documentStatusLabel';
|
||||||
|
|
||||||
type Person = { firstName: string; lastName: string };
|
type Person = { firstName?: string | null; lastName: string; displayName: string };
|
||||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||||
type DocForMeta = {
|
type DocForMeta = {
|
||||||
sender?: Person | null;
|
sender?: Person | null;
|
||||||
@@ -18,7 +18,13 @@ function djb2(str: string): number {
|
|||||||
return Math.abs(hash);
|
return Math.abs(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInitials(person: Person): string {
|
||||||
|
if (person.firstName) return `${person.firstName[0]}${person.lastName[0]}`.toUpperCase();
|
||||||
|
return person.lastName.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function abbreviateName(person: Person): string {
|
export function abbreviateName(person: Person): string {
|
||||||
|
if (!person.firstName) return person.lastName;
|
||||||
const first = person.firstName.trim();
|
const first = person.firstName.trim();
|
||||||
const last = person.lastName.trim();
|
const last = person.lastName.trim();
|
||||||
if (!last) return first;
|
if (!last) return first;
|
||||||
@@ -26,6 +32,7 @@ export function abbreviateName(person: Person): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function abbreviateCompact(person: Person): string {
|
function abbreviateCompact(person: Person): string {
|
||||||
|
if (!person.firstName) return person.lastName;
|
||||||
const first = person.firstName.trim();
|
const first = person.firstName.trim();
|
||||||
const last = person.lastName.trim();
|
const last = person.lastName.trim();
|
||||||
if (!last) return first;
|
if (!last) return first;
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ export async function load({ url, fetch }) {
|
|||||||
incompleteDocs,
|
incompleteDocs,
|
||||||
recentDocs,
|
recentDocs,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
senderName: senderObj?.displayName ?? '',
|
||||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
receiverName: receiverObj?.displayName ?? ''
|
||||||
},
|
},
|
||||||
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ },
|
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ },
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ let {
|
|||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
documentDate?: string | null;
|
documentDate?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
sender?: { firstName: string; lastName: string } | null;
|
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
|
||||||
receivers?: { firstName: string; lastName: string }[];
|
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
|
||||||
tags?: { id: string; name: string }[];
|
tags?: { id: string; name: string }[];
|
||||||
}[];
|
}[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
@@ -102,7 +102,7 @@ let {
|
|||||||
>{m.docs_list_from()}</span
|
>{m.docs_list_from()}</span
|
||||||
>
|
>
|
||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
<span class="text-ink">{doc.sender.firstName} {doc.sender.lastName}</span>
|
<span class="text-ink">{doc.sender.displayName}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -114,7 +114,7 @@ let {
|
|||||||
>
|
>
|
||||||
{#if doc.receivers && doc.receivers.length > 0}
|
{#if doc.receivers && doc.receivers.length > 0}
|
||||||
<span class="text-ink">
|
<span class="text-ink">
|
||||||
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
|
{doc.receivers.map((p) => p.displayName).join(', ')}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export async function load({ url, fetch, locals }) {
|
|||||||
const code = (result.error as unknown as { code?: string })?.code;
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
throw error(result.response.status, getErrorMessage(code));
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
const p = result.data as { firstName: string; lastName: string } | undefined;
|
const p = result.data as { displayName: string } | undefined;
|
||||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
if (p) senderName = p.displayName;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,8 +65,8 @@ export async function load({ url, fetch, locals }) {
|
|||||||
const code = (result.error as unknown as { code?: string })?.code;
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
throw error(result.response.status, getErrorMessage(code));
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
const p = result.data as { firstName: string; lastName: string } | undefined;
|
const p = result.data as { displayName: string } | undefined;
|
||||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
if (p) receiverName = p.displayName;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ interface Props {
|
|||||||
documentDate?: string;
|
documentDate?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
status: string;
|
status: string;
|
||||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
sender?: {
|
||||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName: string;
|
||||||
|
displayName: string;
|
||||||
|
} | null;
|
||||||
|
receivers?: { id: string; firstName?: string | null; lastName: string; displayName: string }[];
|
||||||
}[];
|
}[];
|
||||||
senderId: string;
|
senderId: string;
|
||||||
receiverId?: string;
|
receiverId?: string;
|
||||||
@@ -67,9 +72,9 @@ function statusDotClass(status: string): string {
|
|||||||
function otherPartyName(doc: (typeof documents)[number]): string {
|
function otherPartyName(doc: (typeof documents)[number]): string {
|
||||||
if (doc.sender?.id === senderId) {
|
if (doc.sender?.id === senderId) {
|
||||||
const r = doc.receivers?.[0];
|
const r = doc.receivers?.[0];
|
||||||
return r ? `${r.firstName} ${r.lastName}` : m.conv_no_party();
|
return r ? r.displayName : m.conv_no_party();
|
||||||
}
|
}
|
||||||
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : m.conv_no_party();
|
return doc.sender ? doc.sender.displayName : m.conv_no_party();
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDocUrl = $derived(
|
const newDocUrl = $derived(
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { clickOutside } from '$lib/actions/clickOutside';
|
|||||||
|
|
||||||
interface Correspondent {
|
interface Correspondent {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName?: string | null;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -41,7 +42,9 @@ function handleKeydown(event: KeyboardEvent, container: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(person: Correspondent): string {
|
function getInitials(person: Correspondent): string {
|
||||||
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
|
if (person.firstName)
|
||||||
|
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
|
||||||
|
return person.lastName.substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ function getInitials(person: Correspondent): string {
|
|||||||
{getInitials(person)}
|
{getInitials(person)}
|
||||||
</span>
|
</span>
|
||||||
<!-- Svelte auto-escapes — do not use {@html} here. -->
|
<!-- Svelte auto-escapes — do not use {@html} here. -->
|
||||||
{person.lastName}, {person.firstName}
|
{person.displayName}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -54,7 +54,15 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
|
|||||||
const docs = [{ id: 'd1', title: 'Testbrief' }];
|
const docs = [{ id: 'd1', title: 'Testbrief' }];
|
||||||
const GET = mockApi([
|
const GET = mockApi([
|
||||||
{ ok: true, data: docs },
|
{ ok: true, data: docs },
|
||||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
firstName: 'Hans',
|
||||||
|
lastName: 'Müller',
|
||||||
|
displayName: 'Hans Müller',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
@@ -76,8 +84,24 @@ describe('korrespondenz load — senderId and receiverId set', () => {
|
|||||||
it('calls conversation, sender person, and receiver person endpoints', async () => {
|
it('calls conversation, sender person, and receiver person endpoints', async () => {
|
||||||
const GET = mockApi([
|
const GET = mockApi([
|
||||||
{ ok: true, data: [] },
|
{ ok: true, data: [] },
|
||||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } },
|
{
|
||||||
{ ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } }
|
ok: true,
|
||||||
|
data: {
|
||||||
|
firstName: 'Hans',
|
||||||
|
lastName: 'Müller',
|
||||||
|
displayName: 'Hans Müller',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Schmidt',
|
||||||
|
displayName: 'Anna Schmidt',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
@@ -98,7 +122,15 @@ describe('korrespondenz load — canWrite', () => {
|
|||||||
it('derives canWrite true from WRITE_ALL permission', async () => {
|
it('derives canWrite true from WRITE_ALL permission', async () => {
|
||||||
mockApi([
|
mockApi([
|
||||||
{ ok: true, data: [] },
|
{ ok: true, data: [] },
|
||||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
firstName: 'Hans',
|
||||||
|
lastName: 'Müller',
|
||||||
|
displayName: 'Hans Müller',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
@@ -113,7 +145,15 @@ describe('korrespondenz load — canWrite', () => {
|
|||||||
it('derives canWrite false when user lacks WRITE_ALL', async () => {
|
it('derives canWrite false when user lacks WRITE_ALL', async () => {
|
||||||
mockApi([
|
mockApi([
|
||||||
{ ok: true, data: [] },
|
{ ok: true, data: [] },
|
||||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
firstName: 'Hans',
|
||||||
|
lastName: 'Müller',
|
||||||
|
displayName: 'Hans Müller',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
@@ -132,7 +172,15 @@ describe('korrespondenz load — backend error', () => {
|
|||||||
it('throws when the conversation endpoint returns non-ok', async () => {
|
it('throws when the conversation endpoint returns non-ok', async () => {
|
||||||
mockApi([
|
mockApi([
|
||||||
{ ok: false, status: 500 },
|
{ ok: false, status: 500 },
|
||||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
firstName: 'Hans',
|
||||||
|
lastName: 'Müller',
|
||||||
|
displayName: 'Hans Müller',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export async function load({ url, fetch }) {
|
|||||||
if (senderId) {
|
if (senderId) {
|
||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
||||||
const p = data as { firstName: string; lastName: string } | undefined;
|
const p = data as { displayName: string } | undefined;
|
||||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
if (p) senderName = p.displayName;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -48,8 +48,8 @@ export async function load({ url, fetch }) {
|
|||||||
if (receiverId) {
|
if (receiverId) {
|
||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
||||||
const p = data as { firstName: string; lastName: string } | undefined;
|
const p = data as { displayName: string } | undefined;
|
||||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
if (p) receiverName = p.displayName;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ let {
|
|||||||
documentDate?: string;
|
documentDate?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
status: string;
|
status: string;
|
||||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
sender?: {
|
||||||
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName: string;
|
||||||
|
displayName: string;
|
||||||
|
} | null;
|
||||||
}[];
|
}[];
|
||||||
senderId: string;
|
senderId: string;
|
||||||
receiverId: string;
|
receiverId: string;
|
||||||
@@ -106,7 +111,7 @@ const enrichedDocuments = $derived(
|
|||||||
: 'border-line bg-surface text-ink'}"
|
: 'border-line bg-surface text-ink'}"
|
||||||
>
|
>
|
||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
{doc.sender.firstName ? doc.sender.firstName[0] : doc.sender.lastName[0]}{doc.sender.lastName[0]}
|
||||||
{:else}
|
{:else}
|
||||||
?
|
?
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ let selectedReceivers = $state(doc.receivers ?? []);
|
|||||||
bind:selectedReceivers={selectedReceivers}
|
bind:selectedReceivers={selectedReceivers}
|
||||||
initialDateIso={doc.documentDate ?? ''}
|
initialDateIso={doc.documentDate ?? ''}
|
||||||
initialLocation={doc.location ?? ''}
|
initialLocation={doc.location ?? ''}
|
||||||
initialSenderName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
|
initialSenderName={doc.sender ? doc.sender.displayName : ''}
|
||||||
/>
|
/>
|
||||||
<DescriptionSection
|
<DescriptionSection
|
||||||
bind:tags={tags}
|
bind:tags={tags}
|
||||||
|
|||||||
@@ -24,14 +24,19 @@ export async function load({
|
|||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
let initialSenderName = '';
|
let initialSenderName = '';
|
||||||
let initialReceivers: { id: string; firstName: string; lastName: string }[] = [];
|
let initialReceivers: {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName: string;
|
||||||
|
displayName: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
const requests: Promise<void>[] = [];
|
const requests: Promise<void>[] = [];
|
||||||
|
|
||||||
if (senderId) {
|
if (senderId) {
|
||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
||||||
if (data) initialSenderName = `${data.firstName} ${data.lastName}`;
|
if (data) initialSenderName = data.displayName;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,7 +45,14 @@ export async function load({
|
|||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
||||||
if (data)
|
if (data)
|
||||||
initialReceivers = [{ id: data.id!, firstName: data.firstName, lastName: data.lastName }];
|
initialReceivers = [
|
||||||
|
{
|
||||||
|
id: data.id!,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
displayName: data.displayName
|
||||||
|
}
|
||||||
|
];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ let { data, form } = $props();
|
|||||||
|
|
||||||
let tags: string[] = $state([]);
|
let tags: string[] = $state([]);
|
||||||
let senderId = $state(untrack(() => data.initialSenderId));
|
let senderId = $state(untrack(() => data.initialSenderId));
|
||||||
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
|
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
||||||
untrack(() => data.initialReceivers)
|
$state(untrack(() => data.initialReceivers));
|
||||||
);
|
|
||||||
|
|
||||||
let parsedSuggestion = $state<FilenameParseResult>({});
|
let parsedSuggestion = $state<FilenameParseResult>({});
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ describe('New document page – receiver prefill', () => {
|
|||||||
it('shows a receiver chip when initialReceivers has a person', async () => {
|
it('shows a receiver chip when initialReceivers has a person', async () => {
|
||||||
const data = {
|
const data = {
|
||||||
...baseData,
|
...baseData,
|
||||||
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
|
initialReceivers: [
|
||||||
|
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||||
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data, form: null });
|
render(Page, { data, form: null });
|
||||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
@@ -67,7 +69,9 @@ describe('New document page – receiver prefill', () => {
|
|||||||
it('renders a hidden receiverIds input for the prefilled receiver', async () => {
|
it('renders a hidden receiverIds input for the prefilled receiver', async () => {
|
||||||
const data = {
|
const data = {
|
||||||
...baseData,
|
...baseData,
|
||||||
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
|
initialReceivers: [
|
||||||
|
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||||
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data, form: null });
|
render(Page, { data, form: null });
|
||||||
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
|||||||
initialDateIso={doc.documentDate ?? ''}
|
initialDateIso={doc.documentDate ?? ''}
|
||||||
initialLocation={doc.location ?? ''}
|
initialLocation={doc.location ?? ''}
|
||||||
initialSenderName={doc.sender
|
initialSenderName={doc.sender
|
||||||
? `${doc.sender.firstName} ${doc.sender.lastName}`
|
? doc.sender.displayName
|
||||||
: ''}
|
: ''}
|
||||||
/>
|
/>
|
||||||
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />
|
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />
|
||||||
|
|||||||
@@ -106,6 +106,19 @@
|
|||||||
--c-pdf-bg: #ebebeb;
|
--c-pdf-bg: #ebebeb;
|
||||||
--c-pdf-ctrl: #d8d8d8;
|
--c-pdf-ctrl: #d8d8d8;
|
||||||
--c-pdf-text: #333333;
|
--c-pdf-text: #333333;
|
||||||
|
|
||||||
|
/* PersonType badge — institution (navy-tinted blue) */
|
||||||
|
--c-badge-institution-bg: #e8eff7;
|
||||||
|
--c-badge-institution-text: #1a4971;
|
||||||
|
--c-badge-institution-border: #c4d5e8;
|
||||||
|
/* PersonType badge — group (muted purple) */
|
||||||
|
--c-badge-group-bg: #f0e8f5;
|
||||||
|
--c-badge-group-text: #5a2d6f;
|
||||||
|
--c-badge-group-border: #d8c5e3;
|
||||||
|
/* PersonType badge — unknown (amber warning) */
|
||||||
|
--c-badge-unknown-bg: #fdf4e3;
|
||||||
|
--c-badge-unknown-text: #7a5a0a;
|
||||||
|
--c-badge-unknown-border: #f0ddb3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||||
@@ -148,6 +161,16 @@
|
|||||||
--c-pdf-bg: #010e1e;
|
--c-pdf-bg: #010e1e;
|
||||||
--c-pdf-ctrl: #011526;
|
--c-pdf-ctrl: #011526;
|
||||||
--c-pdf-text: #f0efe9;
|
--c-pdf-text: #f0efe9;
|
||||||
|
|
||||||
|
--c-badge-institution-bg: rgba(30, 80, 140, 0.25);
|
||||||
|
--c-badge-institution-text: #8bb8e0;
|
||||||
|
--c-badge-institution-border: rgba(30, 80, 140, 0.4);
|
||||||
|
--c-badge-group-bg: rgba(90, 45, 111, 0.25);
|
||||||
|
--c-badge-group-text: #c9a0dc;
|
||||||
|
--c-badge-group-border: rgba(90, 45, 111, 0.4);
|
||||||
|
--c-badge-unknown-bg: rgba(122, 90, 10, 0.25);
|
||||||
|
--c-badge-unknown-text: #e0c060;
|
||||||
|
--c-badge-unknown-border: rgba(122, 90, 10, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +209,16 @@
|
|||||||
--c-pdf-bg: #010e1e;
|
--c-pdf-bg: #010e1e;
|
||||||
--c-pdf-ctrl: #011526;
|
--c-pdf-ctrl: #011526;
|
||||||
--c-pdf-text: #f0efe9;
|
--c-pdf-text: #f0efe9;
|
||||||
|
|
||||||
|
--c-badge-institution-bg: rgba(30, 80, 140, 0.25);
|
||||||
|
--c-badge-institution-text: #8bb8e0;
|
||||||
|
--c-badge-institution-border: rgba(30, 80, 140, 0.4);
|
||||||
|
--c-badge-group-bg: rgba(90, 45, 111, 0.25);
|
||||||
|
--c-badge-group-text: #c9a0dc;
|
||||||
|
--c-badge-group-border: rgba(90, 45, 111, 0.4);
|
||||||
|
--c-badge-unknown-bg: rgba(122, 90, 10, 0.25);
|
||||||
|
--c-badge-unknown-text: #e0c060;
|
||||||
|
--c-badge-unknown-border: rgba(122, 90, 10, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||||
|
|||||||
@@ -39,8 +39,22 @@ const makeDoc = (overrides = {}) => ({
|
|||||||
status: 'UPLOADED' as const,
|
status: 'UPLOADED' as const,
|
||||||
documentDate: '2024-03-15',
|
documentDate: '2024-03-15',
|
||||||
location: 'Berlin',
|
location: 'Berlin',
|
||||||
sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' },
|
sender: {
|
||||||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }],
|
id: 'p1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON' as const
|
||||||
|
},
|
||||||
|
receivers: [
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
displayName: 'Anna Musterfrau',
|
||||||
|
personType: 'PERSON' as const
|
||||||
|
}
|
||||||
|
],
|
||||||
tags: [{ id: 't1', name: 'Familie' }],
|
tags: [{ id: 't1', name: 'Familie' }],
|
||||||
filePath: '/files/testbrief.pdf',
|
filePath: '/files/testbrief.pdf',
|
||||||
createdAt: '2024-03-15T10:00:00Z',
|
createdAt: '2024-03-15T10:00:00Z',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||||
|
import PersonTypeBadge from '$lib/components/PersonTypeBadge.svelte';
|
||||||
import PersonsStatsBar from './PersonsStatsBar.svelte';
|
import PersonsStatsBar from './PersonsStatsBar.svelte';
|
||||||
import PersonsEmptyState from './PersonsEmptyState.svelte';
|
import PersonsEmptyState from './PersonsEmptyState.svelte';
|
||||||
|
|
||||||
@@ -99,15 +100,48 @@ function handleSearch() {
|
|||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-primary font-serif text-base font-bold text-primary-fg transition-colors"
|
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-primary font-serif text-base font-bold text-primary-fg transition-colors"
|
||||||
>
|
>
|
||||||
{person.firstName?.[0]}{person.lastName?.[0]}
|
{#if person.personType && person.personType !== 'PERSON'}
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
{#if person.personType === 'INSTITUTION'}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
|
||||||
|
/>
|
||||||
|
{:else if person.personType === 'GROUP'}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Name -->
|
<!-- Name -->
|
||||||
<p class="font-serif text-sm font-bold text-ink group-hover:underline">
|
<p class="font-serif text-sm font-bold text-ink group-hover:underline">
|
||||||
{person.firstName}
|
{person.displayName}
|
||||||
{person.lastName}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{#if person.personType && person.personType !== 'PERSON'}
|
||||||
|
<PersonTypeBadge personType={person.personType} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Alias -->
|
<!-- Alias -->
|
||||||
{#if person.alias}
|
{#if person.alias}
|
||||||
<p class="font-sans text-xs text-ink-2 italic">„{person.alias}"</p>
|
<p class="font-sans text-xs text-ink-2 italic">„{person.alias}"</p>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const coCorrespondents = $derived.by(() => {
|
|||||||
else
|
else
|
||||||
freq.set(key, {
|
freq.set(key, {
|
||||||
id: receiver.id,
|
id: receiver.id,
|
||||||
name: `${receiver.firstName} ${receiver.lastName}`,
|
name: receiver.displayName,
|
||||||
count: 1
|
count: 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ const coCorrespondents = $derived.by(() => {
|
|||||||
else
|
else
|
||||||
freq.set(key, {
|
freq.set(key, {
|
||||||
id: doc.sender.id,
|
id: doc.sender.id,
|
||||||
name: `${doc.sender.firstName} ${doc.sender.lastName}`,
|
name: doc.sender.displayName,
|
||||||
count: 1
|
count: 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
type: string;
|
type: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}>;
|
}>;
|
||||||
personFirstName: string;
|
personFirstName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { aliases, personFirstName }: Props = $props();
|
let { aliases, personFirstName }: Props = $props();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||||
|
import PersonTypeBadge from '$lib/components/PersonTypeBadge.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
person,
|
person,
|
||||||
@@ -8,8 +9,10 @@ let {
|
|||||||
}: {
|
}: {
|
||||||
person: {
|
person: {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName?: string | null;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
displayName: string;
|
||||||
|
personType?: string | null;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
birthYear?: number | null;
|
birthYear?: number | null;
|
||||||
deathYear?: number | null;
|
deathYear?: number | null;
|
||||||
@@ -29,16 +32,51 @@ let {
|
|||||||
<div
|
<div
|
||||||
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary font-serif text-xl font-bold text-primary-fg"
|
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary font-serif text-xl font-bold text-primary-fg"
|
||||||
>
|
>
|
||||||
{person.firstName[0]}{person.lastName[0]}
|
{#if person.personType && person.personType !== 'PERSON'}
|
||||||
|
<svg
|
||||||
|
class="h-7 w-7"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
{#if person.personType === 'INSTITUTION'}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
|
||||||
|
/>
|
||||||
|
{:else if person.personType === 'GROUP'}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Name — centered, serif -->
|
<!-- Name — centered, serif -->
|
||||||
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
||||||
{person.firstName}
|
{person.displayName}
|
||||||
{person.lastName}
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{#if person.personType && person.personType !== 'PERSON'}
|
||||||
|
<div class="mb-1 flex justify-center">
|
||||||
|
<PersonTypeBadge personType={person.personType} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Alias — centered, italic -->
|
<!-- Alias — centered, italic -->
|
||||||
{#if person.alias}
|
{#if person.alias}
|
||||||
<p class="mb-1 text-center font-sans text-sm text-ink-2 italic">„{person.alias}"</p>
|
<p class="mb-1 text-center font-sans text-sm text-ink-2 italic">„{person.alias}"</p>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ let {
|
|||||||
person,
|
person,
|
||||||
form
|
form
|
||||||
}: {
|
}: {
|
||||||
person: { firstName: string; lastName: string };
|
person: { displayName: string };
|
||||||
form?: { mergeError?: string } | null;
|
form?: { mergeError?: string } | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ let showMergeConfirm = $state(false);
|
|||||||
|
|
||||||
{#if showMergeConfirm}
|
{#if showMergeConfirm}
|
||||||
<p class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
<p class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong>
|
{m.person_merge_warning()} <strong>{person.displayName}</strong>
|
||||||
{m.person_merge_will_be_deleted()}
|
{m.person_merge_will_be_deleted()}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ const person = $derived(data.person);
|
|||||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{person.firstName}
|
{person.displayName}
|
||||||
{person.lastName}
|
|
||||||
</a>
|
</a>
|
||||||
<h1 class="font-serif text-3xl text-ink">{m.person_edit_heading()}</h1>
|
<h1 class="font-serif text-3xl text-ink">{m.person_edit_heading()}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ let {
|
|||||||
person,
|
person,
|
||||||
form
|
form
|
||||||
}: {
|
}: {
|
||||||
person: { id: string; firstName: string; lastName: string };
|
person: { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
form?: { mergeError?: string } | null;
|
form?: { mergeError?: string } | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ let {
|
|||||||
person
|
person
|
||||||
}: {
|
}: {
|
||||||
person: {
|
person: {
|
||||||
firstName: string;
|
firstName?: string | null;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
birthYear?: number | null;
|
birthYear?: number | null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const makePerson = (overrides = {}) => ({
|
|||||||
id: '1',
|
id: '1',
|
||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
documentCount: 0,
|
documentCount: 0,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user