55import { getLanguage , t } from '@nextcloud/l10n'
66import { 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 = / ^ (?: m r | m r s | m s | m i s s | m a s t e r | m i s t e r | d r | r e v | f r | p r o f | h e r r | f r a u | m m e | m l l e | m e | p r ) $ / 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'
15110export 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