Skip to content

Commit f095bb2

Browse files
committed
NameProcessor: only inject nickname from primary NAME, anchor on last given name
Two correctness fixes for the nickname-injection feature: getNickname() previously returned the first NICK across ALL NAME facts, including `_MARNM` and other type-tagged variants. A nickname tied to the married identity does not belong on the primary-identity name that getFullName() returns. Skip non-primary NAME facts (TYPE != BIRTH/empty). injectNickname() previously located the surname via strrpos and inserted the quoted nickname before it. For trees that store surname particles (`von`, `de la`, `van der`) outside the GEDCOM /SURN/ slashes, the SURN attribute is just the family name (e.g. "Berg") and the particle sits in the given-name area. The old algorithm produced `Friedrich von "Fritz" Berg` — the nickname split the particle off its surname. Anchor on the position after the last given name instead, which keeps particles intact: `Friedrich von "Fritz" Berg` becomes `Friedrich von "Fritz" Berg` if particles are inside the slashes (SURN = "von Berg", firstNames = ["Friedrich"]) or `Friedrich von "Fritz" Berg` if they sit outside (SURN = "Berg", firstNames = ["Friedrich", "von"]). Tests cover both conventions plus repeated given names, no given names, and idempotent re-injection. Also fix .gitattributes: `/test/` was a typo for `/tests/`, so the test suite was being shipped in composer --prefer-dist tarballs. Add the rest of the dev-only paths (Make/, Makefile, AGENTS.md, compose.yaml, .build/) for a leaner package.
1 parent 3d744c4 commit f095bb2

3 files changed

Lines changed: 72 additions & 36 deletions

File tree

.gitattributes

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,9 @@
88
/phpstan-baseline.neon export-ignore
99
/phpunit.xml export-ignore
1010
/rector.php export-ignore
11-
/test/ export-ignore
11+
/tests/ export-ignore
12+
/Make/ export-ignore
13+
/Makefile export-ignore
14+
/AGENTS.md export-ignore
15+
/compose.yaml export-ignore
16+
/.build/ export-ignore

src/Processor/NameProcessor.php

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,25 @@ public function getFullName(): string
183183
}
184184

185185
/**
186-
* Returns the GEDCOM `2 NICK` value of the individual's primary NAME fact, or
187-
* an empty string when no nickname is set. The lookup walks all NAME facts and
188-
* returns the first NICK it finds.
186+
* Returns the GEDCOM `2 NICK` value of the individual's *primary* NAME fact, or
187+
* an empty string when no nickname is set there. NAME facts whose `2 TYPE` is
188+
* something other than the primary identity (e.g. `_MARNM`, `aka`) are skipped:
189+
* a nickname attached to the married name belongs to the married identity, not
190+
* the birth identity that `getFullName()` returns by default.
189191
*
190192
* @return string
191193
*/
192194
public function getNickname(): string
193195
{
194196
foreach ($this->individual->facts(['NAME']) as $nameFact) {
197+
$type = $nameFact->attribute('TYPE');
198+
199+
// Skip non-primary NAME variants (married name, also-known-as, etc.)
200+
// so the nickname injection sticks to the birth-identity name.
201+
if ($type !== '' && strtoupper($type) !== 'BIRTH') {
202+
continue;
203+
}
204+
195205
$nick = $nameFact->attribute('NICK');
196206

197207
if ($nick !== '') {
@@ -223,40 +233,50 @@ public function getFullNameWithNickname(): string
223233

224234
return $this->injectNickname(
225235
$this->getFullName(),
226-
implode(' ', $this->getLastNames()),
236+
$this->getFirstNames(),
227237
$nick
228238
);
229239
}
230240

231241
/**
232-
* Inserts the quoted nickname before the surname in a flat name string.
242+
* Inserts the quoted nickname after the last given name in a flat name string,
243+
* which lands it before whatever comes next (a surname particle like `von`
244+
* followed by the surname, the surname itself, a married-name suffix, etc.).
233245
* Idempotent: if the nickname is already present in quotes, the input is
234246
* returned unchanged.
235247
*
236-
* @param string $fullName Plain full name (e.g. "Martin White")
237-
* @param string $surname Surname tokens joined by spaces (e.g. "White" or "Van Der Berg")
238-
* @param string $nick Nickname without quotes (e.g. "Chalky")
248+
* Anchoring on the last given name (rather than the first surname token) keeps
249+
* particles like `von`, `de la`, `van der` -- which webtrees renders inside
250+
* the given-name area when they sit outside `/SURN/` slashes -- attached to the
251+
* surname they belong to instead of letting the nickname split them off.
252+
*
253+
* @param string $fullName Plain full name (e.g. "Martin White")
254+
* @param string[] $firstNames Given-name tokens as returned by getFirstNames()
255+
* @param string $nick Nickname without quotes (e.g. "Chalky")
239256
*
240257
* @return string
241258
*/
242-
private function injectNickname(string $fullName, string $surname, string $nick): string
259+
private function injectNickname(string $fullName, array $firstNames, string $nick): string
243260
{
244261
if ($nick === '' || str_contains($fullName, '"' . $nick . '"')) {
245262
return $fullName;
246263
}
247264

248-
$position = ($surname !== '') ? strrpos($fullName, $surname) : false;
265+
$lastGivenName = end($firstNames);
249266

250-
if ($position !== false) {
251-
return substr_replace(
252-
$fullName,
253-
'"' . $nick . '" ' . $surname,
254-
$position,
255-
strlen($surname)
256-
);
267+
if ($lastGivenName === false || $lastGivenName === '') {
268+
return $fullName . ' "' . $nick . '"';
257269
}
258270

259-
return $fullName . ' "' . $nick . '"';
271+
$position = strrpos($fullName, $lastGivenName);
272+
273+
if ($position === false) {
274+
return $fullName . ' "' . $nick . '"';
275+
}
276+
277+
$insertAt = $position + strlen($lastGivenName);
278+
279+
return substr_replace($fullName, ' "' . $nick . '"', $insertAt, 0);
260280
}
261281

262282
/**

tests/NameProcessorTest.php

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -386,46 +386,57 @@ public function getMarriedSurnamesReturnsEmptyWhenSpouseSurnameDoesNotMatchAnyMa
386386
}
387387

388388
/**
389-
* @return array<string, array{string, string, string, string}>
389+
* @return array<string, array{string, list<string>, string, string}>
390390
*/
391391
public static function injectNicknameDataProvider(): array
392392
{
393-
// [ fullName, surname, nick, expected ]
393+
// [ fullName, firstNames, nick, expected ]
394394
return [
395395
'Empty nick returns input unchanged' => [
396-
'Martin White', 'White', '', 'Martin White',
396+
'Martin White', ['Martin'], '', 'Martin White',
397397
],
398-
'Inserts before single-word surname' => [
399-
'Martin White', 'White', 'Chalky', 'Martin "Chalky" White',
398+
'Inserts after last given name (single-word surname)' => [
399+
'Martin White', ['Martin'], 'Chalky', 'Martin "Chalky" White',
400400
],
401-
'Inserts before multi-word surname' => [
402-
'Hans Van Der Berg', 'Van Der Berg', 'Hänschen', 'Hans "Hänschen" Van Der Berg',
401+
'Multiple given names: inserts after the last one' => [
402+
'Friedrich Wilhelm August von Habsburg-Lothringen',
403+
['Friedrich', 'Wilhelm', 'August'],
404+
'Fritz',
405+
'Friedrich Wilhelm August "Fritz" von Habsburg-Lothringen',
403406
],
404-
'Idempotent: nick already inline' => [
405-
'Martin "Chalky" White', 'White', 'Chalky', 'Martin "Chalky" White',
407+
'Surname particle in given-name area: nickname stays before particle+surname' => [
408+
'Friedrich von Berg',
409+
['Friedrich', 'von'],
410+
'Fritz',
411+
'Friedrich von "Fritz" Berg',
406412
],
407-
'No surname found: appends nick' => [
408-
'Anonymous', 'White', 'Chalky', 'Anonymous "Chalky"',
413+
'Idempotent: nick already inline' => [
414+
'Martin "Chalky" White', ['Martin'], 'Chalky', 'Martin "Chalky" White',
409415
],
410-
'Empty surname: appends nick' => [
411-
'Anonymous', '', 'Chalky', 'Anonymous "Chalky"',
416+
'No given names: appends nick' => [
417+
'Anonymous', [], 'Chalky', 'Anonymous "Chalky"',
412418
],
413-
'Hits last occurrence when surname is also a given name' => [
414-
'White Robert White', 'White', 'Bob', 'White Robert "Bob" White',
419+
'Hits last occurrence when given name repeats' => [
420+
'Maria Anna Maria Schmidt',
421+
['Maria', 'Anna', 'Maria'],
422+
'Mimi',
423+
'Maria Anna Maria "Mimi" Schmidt',
415424
],
416425
];
417426
}
418427

419428
/**
429+
* @param list<string> $firstNames
430+
*
420431
* @throws ReflectionException
421432
*/
422433
#[Test]
423434
#[DataProvider('injectNicknameDataProvider')]
424-
public function injectNickname(string $fullName, string $surname, string $nick, string $expected): void
435+
public function injectNickname(string $fullName, array $firstNames, string $nick, string $expected): void
425436
{
426437
$processorStub = self::createStub(NameProcessor::class);
427438
$reflectionMethod = (new ReflectionClass(NameProcessor::class))->getMethod('injectNickname');
428-
$result = $reflectionMethod->invoke($processorStub, $fullName, $surname, $nick);
439+
$result = $reflectionMethod->invoke($processorStub, $fullName, $firstNames, $nick);
429440

430441
self::assertSame($expected, $result);
431442
}

0 commit comments

Comments
 (0)