From 8af71b04388a440ec6d7d21885effde6a8d26a17 Mon Sep 17 00:00:00 2001 From: "Enjeck C." Date: Tue, 14 Jul 2026 19:58:14 +0100 Subject: [PATCH 1/2] fix: enhance name splitting to handle 'Lastname, Firstname' format Signed-off-by: Enjeck C. --- apps/dav/lib/CardDAV/Converter.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/dav/lib/CardDAV/Converter.php b/apps/dav/lib/CardDAV/Converter.php index fd35a5cf35108..f2d7a572b146b 100644 --- a/apps/dav/lib/CardDAV/Converter.php +++ b/apps/dav/lib/CardDAV/Converter.php @@ -161,6 +161,14 @@ public function splitFullName(string $fullName): array { // Very basic western style parsing. I'm not gonna implement // https://github.com/android/platform_packages_providers_contactsprovider/blob/master/src/com/android/providers/contacts/NameSplitter.java ;) + // Handle "Lastname, Firstname" format + if (str_contains($fullName, ',')) { + [$family, $given] = array_map('trim', explode(',', $fullName, 2)); + if ($family !== '' && $given !== '') { + return [$family, $given, '', '', '']; + } + } + $elements = explode(' ', $fullName); $result = ['', '', '', '', '']; if (count($elements) > 2) { From 5581ee3146679cb3072441cecaf8ceaf949cb07b Mon Sep 17 00:00:00 2001 From: "Enjeck C." Date: Fri, 24 Jul 2026 02:04:19 +0100 Subject: [PATCH 2/2] fix: enhance name parsing to support honorifics and various formats Signed-off-by: Enjeck C. --- apps/dav/lib/CardDAV/Converter.php | 102 ++++++++++++++---- apps/dav/tests/unit/CardDAV/ConverterTest.php | 13 +++ 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/apps/dav/lib/CardDAV/Converter.php b/apps/dav/lib/CardDAV/Converter.php index f2d7a572b146b..474e6f22f4cec 100644 --- a/apps/dav/lib/CardDAV/Converter.php +++ b/apps/dav/lib/CardDAV/Converter.php @@ -21,6 +21,18 @@ use Sabre\VObject\Property\VCard\Date; class Converter { + private const NAME_SUFFIXES = [ + 'i', 'ii', 'iii', 'iv', 'v', + 'senior', 'junior', 'jr', 'sr', + 'phd', 'apr', 'rph', 'pe', 'md', 'ma', 'msc', 'bsc', 'ba', 'bs', + 'dmd', 'cme', 'bsn', 'mba', + 'ceo', 'cto', 'cfo', 'coo', + ]; + private const NAME_SALUTATIONS = [ + 'mr', 'mrs', 'ms', 'miss', 'master', 'mister', 'dr', 'rev', 'fr', 'prof', + 'herr', 'frau', 'mme', 'mlle', 'me', 'pr', + ]; + public function __construct( private IAccountManager $accountManager, private IUserManager $userManager, @@ -158,33 +170,87 @@ public function createCardFromUser(IUser $user): ?VCard { } public function splitFullName(string $fullName): array { - // Very basic western style parsing. I'm not gonna implement - // https://github.com/android/platform_packages_providers_contactsprovider/blob/master/src/com/android/providers/contacts/NameSplitter.java ;) - - // Handle "Lastname, Firstname" format - if (str_contains($fullName, ',')) { - [$family, $given] = array_map('trim', explode(',', $fullName, 2)); - if ($family !== '' && $given !== '') { - return [$family, $given, '', '', '']; + // Based on https://github.com/joshfraser/PHP-Name-Parser + + $prefix = []; + $suffix = []; + $cleanedName = preg_replace('/\([^()]*\)|\[[^[\]]*\]|\{[^{}]*\}/', ' ', $fullName) ?? $fullName; + $cleanedName = trim(preg_replace('/\s+/', ' ', $cleanedName) ?? $cleanedName); + if ($cleanedName === '') { + $cleanedName = trim($fullName); + } + + $segments = array_values(array_filter( + array_map($this->splitNameWords(...), explode(',', $cleanedName)), + static fn (array $segment): bool => $segment !== [], + )); + + while (count($segments) > 1) { + $lastSegment = $segments[count($segments) - 1]; + $knownSuffix = array_filter( + $lastSegment, + fn (string $word): bool => !$this->isNameSuffix($word), + ) === []; + if (!$knownSuffix) { + break; } + array_unshift($suffix, implode(' ', array_pop($segments))); } - $elements = explode(' ', $fullName); - $result = ['', '', '', '', '']; - if (count($elements) > 2) { - $result[0] = implode(' ', array_slice($elements, count($elements) - 1)); - $result[1] = $elements[0]; - $result[2] = implode(' ', array_slice($elements, 1, count($elements) - 2)); - } elseif (count($elements) === 2) { - $result[0] = $elements[1]; - $result[1] = $elements[0]; + if (count($segments) > 1 && count($segments[0]) === 1) { + $result = [$segments[0][0], '', '', '', '']; + $nameWords = array_merge(...array_slice($segments, 1)); } else { - $result[0] = $elements[0]; + $result = ['', '', '', '', '']; + $nameWords = $segments[0] ?? $this->splitNameWords($cleanedName); + if (count($segments) > 1) { + $suffix[] = implode(', ', array_map(static fn (array $segment): string => implode(' ', $segment), array_slice($segments, 1))); + } + } + + while (count($nameWords) > 1 && $this->isNameSalutation($nameWords[0])) { + $prefix[] = array_shift($nameWords); + } + while (count($nameWords) > 1 && $this->isNameSuffix($nameWords[count($nameWords) - 1])) { + array_unshift($suffix, array_pop($nameWords)); + } + + if ($result[0] !== '') { + $result[1] = $nameWords[0] ?? ''; + $result[2] = implode(' ', array_slice($nameWords, 1)); + } elseif (count($nameWords) > 2) { + $result[0] = implode(' ', array_slice($nameWords, count($nameWords) - 1)); + $result[1] = $nameWords[0]; + $result[2] = implode(' ', array_slice($nameWords, 1, count($nameWords) - 2)); + } elseif (count($nameWords) === 2) { + $result[0] = $nameWords[1]; + $result[1] = $nameWords[0]; + } elseif (count($nameWords) === 1) { + $result[0] = $nameWords[0]; } + $result[3] = implode(' ', $prefix); + $result[4] = implode(', ', array_filter($suffix)); + return $result; } + private function splitNameWords(string $name): array { + return preg_split('/\s+/', trim($name), -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + private function isNameSuffix(string $word): bool { + return in_array($this->normalizeNameWord($word), self::NAME_SUFFIXES, true); + } + + private function isNameSalutation(string $word): bool { + return in_array($this->normalizeNameWord($word), self::NAME_SALUTATIONS, true); + } + + private function normalizeNameWord(string $word): string { + return strtolower(preg_replace('/[.,]/', '', trim($word)) ?? $word); + } + private function getAvatarImage(IUser $user): ?IImage { try { return $user->getAvatarImage(512); diff --git a/apps/dav/tests/unit/CardDAV/ConverterTest.php b/apps/dav/tests/unit/CardDAV/ConverterTest.php index ddbb6ffc6bee9..803e6d9bc99d0 100644 --- a/apps/dav/tests/unit/CardDAV/ConverterTest.php +++ b/apps/dav/tests/unit/CardDAV/ConverterTest.php @@ -201,6 +201,19 @@ public static function providesNames(): array { ['Sauron;;;;', 'Sauron'], ['Baggins;Bilbo;;;', 'Bilbo Baggins'], ['Tolkien;John;Ronald Reuel;;', 'John Ronald Reuel Tolkien'], + ['Doe;Jane;;;MD', 'Jane Doe, MD'], + ['Williams;Mary;;;MD, PhD', 'Mary Williams, MD, PhD'], + ['King;Martin;Luther;;Jr.', 'Martin Luther King, Jr.'], + ['King;Martin;Luther;;Jr.', 'King, Martin Luther, Jr.'], + ['Doe;Jane;;Dr.;', 'Dr. Jane Doe'], + ['Doe;Jane;;Prof. Dr.;', 'Prof. Dr. Jane Doe'], + ['Lastname;Firstname;;;', 'Lastname, Firstname'], + ['Lastname;Firstname;Middlename;;', 'Lastname, Firstname Middlename'], + ['b;a;;;c', 'a b, c'], + ['a;b;c;;', 'a, b c'], + ['Doe;Jane;;;', 'Jane Doe (Contracting)'], + ['Smith;Jane;;;Ph.D.', 'Jane Smith Ph.D.'], + ['Smith;R.;Jason;;', 'R. Jason Smith'], ]; }