Skip to content

Commit 0f2d216

Browse files
authored
Merge pull request #18682 from nextcloud/fix/noid/first-name-parsing
fix(conversations): parse first name from complex display names
2 parents 3347a20 + ffaa289 commit 0f2d216

2 files changed

Lines changed: 166 additions & 1 deletion

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import { describe, expect, it } from 'vitest'
6+
import { getFirstName } from '../getDisplayName.ts'
7+
8+
describe('getDisplayName', () => {
9+
describe('getFirstName', () => {
10+
const TEST_CASES = [
11+
// Natural order
12+
['Hermes Conrad', 'Hermes'],
13+
['Hermes', 'Hermes'],
14+
[' Hermes Conrad ', 'Hermes'],
15+
['Philip J. Fry', 'Philip'],
16+
// Inverted "Lastname, Firstname" order
17+
['Conrad, Hermes', 'Hermes'],
18+
['Smith, John Jacob', 'John'],
19+
['van der Berg, Anna', 'Anna'],
20+
['Doe, John (Contracting)', 'John'],
21+
// Suffixes and post-nominal credentials
22+
['Martin Luther King, Jr.', 'Martin'],
23+
['Jane Smith, MD', 'Jane'],
24+
['Mary Williams, MD, PhD', 'Mary'],
25+
['Anna Schmidt, MSc', 'Anna'],
26+
['Tom Baker, CEO', 'Tom'],
27+
['King, Martin Luther, Jr.', 'Martin'],
28+
// Salutations, also stacked
29+
['Dr. Jane Smith', 'Jane'],
30+
['Mr. John Doe', 'John'],
31+
['Prof. Dr. Hans Müller', 'Hans'],
32+
['Herr Hans Müller', 'Hans'],
33+
['Frau Anna Schmidt', 'Anna'],
34+
['Mme Marie Curie', 'Marie'],
35+
['Mlle Jeanne Dupont', 'Jeanne'],
36+
['Me Jean Dupont', 'Jean'],
37+
['Pr Pierre Bernard', 'Pierre'],
38+
// Leading initials ("goes by middle name")
39+
['R. Jason Smith', 'Jason'],
40+
['R. J. Smith', 'R.'],
41+
// Cyrillic and Greek initials
42+
['А. С. Пушкин', 'А.'],
43+
['Пушкин, Александр Сергеевич', 'Александр'],
44+
['Γ. Κώστας Παπαδόπουλος', 'Κώστας'],
45+
// CJK names (family name first, single characters are not initials)
46+
['李明', '李明'],
47+
['山田 太郎', '山田'],
48+
['山田 太郎', '山田'],
49+
['王 小明', '王'],
50+
['李 明', '李'],
51+
// Bracketed annotations
52+
['John Doe (Contracting)', 'John'],
53+
['(Contracting)', '(Contracting)'],
54+
['[Bot] Weather', 'Weather'],
55+
['{External} John Doe', 'John'],
56+
// Degenerate input
57+
['Conrad,', 'Conrad'],
58+
[', Hermes', 'Hermes'],
59+
['', ''],
60+
[' ', ''],
61+
]
62+
63+
it.each(TEST_CASES)(
64+
'should return "%s" => "%s"',
65+
(input, output) => {
66+
expect(getFirstName(input)).toBe(output)
67+
},
68+
)
69+
})
70+
})

src/utils/getDisplayName.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,101 @@
55
import { getLanguage, t } from '@nextcloud/l10n'
66
import { ATTENDEE } from '../constants.ts'
77

8+
/**
9+
* Suffixes and post-nominal credentials that may follow a comma in a display name
10+
* (e.g. "Martin Luther King, Jr." or "Mary Williams, RN, BSN").
11+
* Based on https://github.com/joshfraser/JavaScript-Name-Parser
12+
*/
13+
const SUFFIX_PATTERN = new RegExp('^(?:'
14+
+ 'i{1,3}|iv|v|senior|junior|jr|sr' // generational
15+
+ '|phd|apr|rph|pe|md|ma|msc|bsc|ba|bs|dmd|cme|bsn|mba' // academic / professional
16+
+ '|ceo|cto|cfo|coo' // job titles
17+
+ ')$', 'i')
18+
19+
/**
20+
* Salutations / honorific prefixes that may precede a given name (e.g. "Dr. Jane Smith")
21+
*/
22+
const SALUTATION_PATTERN = /^(?:mr|mrs|ms|miss|master|mister|dr|rev|fr|prof|herr|frau|mme|mlle|me|pr)$/i
23+
24+
/**
25+
* Normalizes a word for pattern matching (strips periods and commas, e.g. "Ph.D." => "PhD")
26+
*
27+
* @param word single word of a name
28+
*/
29+
function normalizeWord(word: string): string {
30+
return word.replace(/[.,]/g, '')
31+
}
32+
33+
/**
34+
* Checks whether a word is a name suffix or post-nominal credential ("Jr.", "MD")
35+
*
36+
* @param word single word of a name
37+
*/
38+
function isSuffix(word: string): boolean {
39+
return SUFFIX_PATTERN.test(normalizeWord(word))
40+
}
41+
42+
/**
43+
* Checks whether a word is a salutation ("Mr.", "Dr.")
44+
*
45+
* @param word single word of a name
46+
*/
47+
function isSalutation(word: string): boolean {
48+
return SALUTATION_PATTERN.test(normalizeWord(word))
49+
}
50+
51+
/**
52+
* Checks whether a word is a single-letter initial ("R.", "J", "А.").
53+
* Restricted to alphabetic scripts: a single CJK character is a complete name component, not an initial
54+
*
55+
* @param word single word of a name
56+
*/
57+
function isInitial(word: string): boolean {
58+
return /^[\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}]$/u.test(normalizeWord(word))
59+
}
60+
61+
/**
62+
* Returns the first (given) name of a display name.
63+
*
64+
* Handles the inverted enterprise-directory convention ("Lastname, Firstname"),
65+
* comma-separated suffixes and credentials ("Martin Luther King, Jr.", "Jane Smith, MD"),
66+
* bracketed annotations ("Doe, John (Contracting)", "[Bot] Weather") and salutations ("Prof. Dr. Jane Smith").
67+
* Falls back to the trimmed input if nothing better can be extracted.
68+
*
69+
* @param fullName display name of a user
70+
*/
71+
export function getFirstName(fullName: string): string {
72+
// Drop bracketed annotations: "Doe, John (Contracting)" => "Doe, John"
73+
const cleanedName = fullName.replace(/\([^()]*\)|\[[^[\]]*\]|\{[^{}]*\}/g, ' ').trim()
74+
75+
// Word lists of comma-separated segments: "King, Martin Luther, Jr." => [['King'], ['Martin', 'Luther'], ['Jr.']]
76+
const segments = cleanedName.split(',')
77+
.map((segment) => segment.split(/\s+/).filter(Boolean))
78+
.filter((words) => words.length)
79+
80+
// Drop trailing segments consisting only of suffixes: [['King'], ['Martin', 'Luther'], ['Jr.']] => [['King'], ['Martin', 'Luther']]
81+
while (segments.length > 1 && segments.at(-1)!.every(isSuffix)) {
82+
segments.pop()
83+
}
84+
85+
// A remaining comma indicates inverted "Lastname, Firstname" order: [['King'], ['Martin', 'Luther']] => ['Martin', 'Luther']
86+
let givenWords = (segments.length > 1 ? segments[1] : segments[0]) ?? []
87+
88+
// Skip leading salutations, also stacked ones: ['Prof.', 'Dr.', 'Jane', 'Smith'] => ['Jane', 'Smith']
89+
while (givenWords.length > 1 && isSalutation(givenWords[0])) {
90+
givenWords = givenWords.slice(1)
91+
}
92+
93+
// "R. Jason Smith" goes by the middle name, but "R. J. Smith" goes by initials
94+
const firstName = (givenWords.length > 1 && isInitial(givenWords[0]) && !isInitial(givenWords[1]))
95+
? givenWords[1]
96+
: givenWords[0]
97+
98+
return firstName
99+
|| cleanedName.split(/\s+/)[0]
100+
|| fullName.trim()
101+
}
102+
8103
/**
9104
* Returns display name with 'Guest' or 'Deleted user' fallback if not provided
10105
*
@@ -15,7 +110,7 @@ import { ATTENDEE } from '../constants.ts'
15110
export function getDisplayNameWithFallback(displayName: string, source: string, firstNameOnly: boolean = false): string {
16111
if (displayName?.trim()) {
17112
return firstNameOnly
18-
? displayName.trim().split(' ').shift()!
113+
? getFirstName(displayName)
19114
: displayName.trim()
20115
}
21116

0 commit comments

Comments
 (0)