xref: /webtrees/app/Individual.php (revision 5ce0f61bc8e074b3dfab1b9eebd3057199a29125)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees;
21
22use Closure;
23use Fisharebest\ExtCalendar\GregorianCalendar;
24use Fisharebest\Webtrees\Contracts\UserInterface;
25use Fisharebest\Webtrees\Elements\PedigreeLinkageType;
26use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage;
27use Illuminate\Support\Collection;
28
29use function array_key_exists;
30use function count;
31use function in_array;
32use function preg_match;
33
34/**
35 * A GEDCOM individual (INDI) object.
36 */
37class Individual extends GedcomRecord
38{
39    public const RECORD_TYPE = 'INDI';
40
41    // Placeholders to indicate unknown names
42    public const NOMEN_NESCIO     = '@N.N.';
43    public const PRAENOMEN_NESCIO = '@P.N.';
44
45    protected const ROUTE_NAME = IndividualPage::class;
46
47    /** Used in some lists to keep track of this individual’s generation in that list */
48    public int|null $generation = null;
49
50    private Date|null $estimated_birth_date = null;
51
52    private Date|null $estimated_death_date = null;
53
54    /**
55     * A closure which will compare individuals by birth date.
56     *
57     * @return Closure(Individual,Individual):int
58     */
59    public static function birthDateComparator(): Closure
60    {
61        return static fn (Individual $x, Individual $y): int => Date::compare($x->getEstimatedBirthDate(), $y->getEstimatedBirthDate());
62    }
63
64    /**
65     * A closure which will compare individuals by death date.
66     *
67     * @return Closure(Individual,Individual):int
68     */
69    public static function deathDateComparator(): Closure
70    {
71        return static fn (Individual $x, Individual $y): int => Date::compare($x->getEstimatedDeathDate(), $y->getEstimatedDeathDate());
72    }
73
74    /**
75     * Can the name of this record be shown?
76     *
77     * @param int|null $access_level
78     *
79     * @return bool
80     */
81    public function canShowName(int|null $access_level = null): bool
82    {
83        $access_level ??= Auth::accessLevel($this->tree);
84
85        return (int) $this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level || $this->canShow($access_level);
86    }
87
88    /**
89     * Can this individual be shown?
90     *
91     * @param int $access_level
92     *
93     * @return bool
94     */
95    protected function canShowByType(int $access_level): bool
96    {
97        // Dead people...
98        if ((int) $this->tree->getPreference('SHOW_DEAD_PEOPLE') >= $access_level && $this->isDead()) {
99            $keep_alive             = false;
100            $KEEP_ALIVE_YEARS_BIRTH = (int) $this->tree->getPreference('KEEP_ALIVE_YEARS_BIRTH');
101            if ($KEEP_ALIVE_YEARS_BIRTH !== 0) {
102                preg_match_all('/\n1 (?:' . implode('|', Gedcom::BIRTH_EVENTS) . ').*(?:\n[2-9].*)*\n2 DATE (.+)/', $this->gedcom, $matches, PREG_SET_ORDER);
103                foreach ($matches as $match) {
104                    $date = new Date($match[1]);
105                    if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_BIRTH > date('Y')) {
106                        $keep_alive = true;
107                        break;
108                    }
109                }
110            }
111            $KEEP_ALIVE_YEARS_DEATH = (int) $this->tree->getPreference('KEEP_ALIVE_YEARS_DEATH');
112            if ($KEEP_ALIVE_YEARS_DEATH !== 0) {
113                preg_match_all('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ').*(?:\n[2-9].*)*\n2 DATE (.+)/', $this->gedcom, $matches, PREG_SET_ORDER);
114                foreach ($matches as $match) {
115                    $date = new Date($match[1]);
116                    if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_DEATH > date('Y')) {
117                        $keep_alive = true;
118                        break;
119                    }
120                }
121            }
122            if (!$keep_alive) {
123                return true;
124            }
125        }
126        // Consider relationship privacy (unless an admin is applying download restrictions)
127        $user_path_length = (int) $this->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_PATH_LENGTH);
128        $gedcomid         = $this->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF);
129
130        if ($gedcomid !== '' && $user_path_length > 0) {
131            return self::isRelated($this, $user_path_length);
132        }
133
134        // No restriction found - show living people to members only:
135        return Auth::PRIV_USER >= $access_level;
136    }
137
138    /**
139     * For relationship privacy calculations - is this individual a close relative?
140     *
141     * @param Individual $target
142     * @param int        $distance
143     *
144     * @return bool
145     */
146    private static function isRelated(Individual $target, int $distance): bool
147    {
148        static $cache = null;
149
150        $user_individual = Registry::individualFactory()->make($target->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF), $target->tree);
151        if ($user_individual instanceof Individual) {
152            if (!$cache) {
153                $cache = [
154                    0 => [$user_individual],
155                    1 => [],
156                ];
157                foreach ($user_individual->facts(['FAMC', 'FAMS'], false, Auth::PRIV_HIDE) as $fact) {
158                    $family = $fact->target();
159                    if ($family instanceof Family) {
160                        $cache[1][] = $family;
161                    }
162                }
163            }
164        } else {
165            // No individual linked to this account? Cannot use relationship privacy.
166            return true;
167        }
168
169        // Double the distance, as we count the INDI-FAM and FAM-INDI links separately
170        $distance *= 2;
171
172        // Consider each path length in turn
173        for ($n = 0; $n <= $distance; ++$n) {
174            if (array_key_exists($n, $cache)) {
175                // We have already calculated all records with this length
176                if ($n % 2 === 0 && in_array($target, $cache[$n], true)) {
177                    return true;
178                }
179            } else {
180                // Need to calculate these paths
181                $cache[$n] = [];
182                if ($n % 2 === 0) {
183                    // Add FAM->INDI links
184                    foreach ($cache[$n - 1] as $family) {
185                        foreach ($family->facts(['HUSB', 'WIFE', 'CHIL'], false, Auth::PRIV_HIDE) as $fact) {
186                            $individual = $fact->target();
187                            // Don’t backtrack
188                            if ($individual instanceof self && !in_array($individual, $cache[$n - 2], true)) {
189                                $cache[$n][] = $individual;
190                            }
191                        }
192                    }
193                    if (in_array($target, $cache[$n], true)) {
194                        return true;
195                    }
196                } else {
197                    // Add INDI->FAM links
198                    foreach ($cache[$n - 1] as $individual) {
199                        foreach ($individual->facts(['FAMC', 'FAMS'], false, Auth::PRIV_HIDE) as $fact) {
200                            $family = $fact->target();
201                            // Don’t backtrack
202                            if ($family instanceof Family && !in_array($family, $cache[$n - 2], true)) {
203                                $cache[$n][] = $family;
204                            }
205                        }
206                    }
207                }
208            }
209        }
210
211        return false;
212    }
213
214    /**
215     * Generate a private version of this record
216     *
217     * @param int $access_level
218     *
219     * @return string
220     */
221    protected function createPrivateGedcomRecord(int $access_level): string
222    {
223        $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
224
225        $rec = '0 @' . $this->xref . '@ INDI';
226        if ((int) $this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level) {
227            // Show all the NAME tags, including subtags
228            foreach ($this->facts(['NAME']) as $fact) {
229                $rec .= "\n" . $fact->gedcom();
230            }
231        }
232        // Just show the 1 FAMC/FAMS tag, not any subtags, which may contain private data
233        preg_match_all('/\n1 (?:FAMC|FAMS) @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom, $matches, PREG_SET_ORDER);
234        foreach ($matches as $match) {
235            $rela = Registry::familyFactory()->make($match[1], $this->tree);
236            if ($rela && ($SHOW_PRIVATE_RELATIONSHIPS || $rela->canShow($access_level))) {
237                $rec .= $match[0];
238            }
239        }
240        // Don’t privatize sex.
241        if (preg_match('/\n1 SEX [MFU]/', $this->gedcom, $match)) {
242            $rec .= $match[0];
243        }
244
245        return $rec;
246    }
247
248    /**
249     * Calculate whether this individual is living or dead.
250     * If not known to be dead, then assume living.
251     *
252     * @return bool
253     */
254    public function isDead(): bool
255    {
256        $MAX_ALIVE_AGE = (int) $this->tree->getPreference('MAX_ALIVE_AGE');
257        $today_jd      = Registry::timestampFactory()->now()->julianDay();
258
259        // "1 DEAT Y" or "1 DEAT/2 DATE" or "1 DEAT/2 PLAC"
260        if (preg_match('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ')(?: Y|(?:\n[2-9].+)*\n2 (DATE|PLAC) )/', $this->gedcom)) {
261            return true;
262        }
263
264        // If any event occurred more than $MAX_ALIVE_AGE years ago, then assume the individual is dead
265        if (preg_match_all('/\n2 DATE (.+)/', $this->gedcom, $date_matches)) {
266            foreach ($date_matches[1] as $date_match) {
267                $date = new Date($date_match);
268                if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * $MAX_ALIVE_AGE) {
269                    return true;
270                }
271            }
272            // The individual has one or more dated events. All are less than $MAX_ALIVE_AGE years ago.
273            // If one of these is a birth, the individual must be alive.
274            if (preg_match('/\n1 BIRT(?:\n[2-9].+)*\n2 DATE /', $this->gedcom)) {
275                return false;
276            }
277        }
278
279        // If we found no conclusive dates then check the dates of close relatives.
280
281        // Check parents (birth and adopted)
282        foreach ($this->childFamilies(Auth::PRIV_HIDE) as $family) {
283            foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
284                // Assume parents are no more than 45 years older than their children
285                preg_match_all('/\n2 DATE (.+)/', $parent->gedcom, $date_matches);
286                foreach ($date_matches[1] as $date_match) {
287                    $date = new Date($date_match);
288                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 45)) {
289                        return true;
290                    }
291                }
292            }
293        }
294
295        // Check spouses
296        foreach ($this->spouseFamilies(Auth::PRIV_HIDE) as $family) {
297            preg_match_all('/\n2 DATE (.+)/', $family->gedcom, $date_matches);
298            foreach ($date_matches[1] as $date_match) {
299                $date = new Date($date_match);
300                // Assume marriage occurs after age of 10
301                if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 10)) {
302                    return true;
303                }
304            }
305            // Check spouse dates
306            $spouse = $family->spouse($this, Auth::PRIV_HIDE);
307            if ($spouse) {
308                preg_match_all('/\n2 DATE (.+)/', $spouse->gedcom, $date_matches);
309                foreach ($date_matches[1] as $date_match) {
310                    $date = new Date($date_match);
311                    // Assume max age difference between spouses of 40 years
312                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 40)) {
313                        return true;
314                    }
315                }
316            }
317            // Check child dates
318            foreach ($family->children(Auth::PRIV_HIDE) as $child) {
319                preg_match_all('/\n2 DATE (.+)/', $child->gedcom, $date_matches);
320                // Assume children born after age of 15
321                foreach ($date_matches[1] as $date_match) {
322                    $date = new Date($date_match);
323                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 15)) {
324                        return true;
325                    }
326                }
327                // Check grandchildren
328                foreach ($child->spouseFamilies(Auth::PRIV_HIDE) as $child_family) {
329                    foreach ($child_family->children(Auth::PRIV_HIDE) as $grandchild) {
330                        preg_match_all('/\n2 DATE (.+)/', $grandchild->gedcom, $date_matches);
331                        // Assume grandchildren born after age of 30
332                        foreach ($date_matches[1] as $date_match) {
333                            $date = new Date($date_match);
334                            if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 30)) {
335                                return true;
336                            }
337                        }
338                    }
339                }
340            }
341        }
342
343        return false;
344    }
345
346    /**
347     * Find the highlighted media object for an individual
348     */
349    public function findHighlightedMediaFile(): MediaFile|null
350    {
351        $fact = $this->facts(['OBJE'])
352            ->first(static function (Fact $fact): bool {
353                $media = $fact->target();
354
355                return $media instanceof Media && $media->firstImageFile() instanceof MediaFile;
356            });
357
358        if ($fact instanceof Fact && $fact->target() instanceof Media) {
359            return $fact->target()->firstImageFile();
360        }
361
362        return null;
363    }
364
365    /**
366     * Display the preferred image for this individual.
367     * Use an icon if no image is available.
368     *
369     * @param int           $width      Pixels
370     * @param int           $height     Pixels
371     * @param string        $fit        "crop" or "contain"
372     * @param array<string> $attributes Additional HTML attributes
373     *
374     * @return string
375     */
376    public function displayImage(int $width, int $height, string $fit, array $attributes): string
377    {
378        $media_file = $this->findHighlightedMediaFile();
379
380        if ($media_file !== null) {
381            return $media_file->displayImage($width, $height, $fit, $attributes);
382        }
383
384        if ($this->tree->getPreference('USE_SILHOUETTE') === '1') {
385            return '<i class="icon-silhouette icon-silhouette-' . strtolower($this->sex()) . ' wt-icon-flip-rtl"></i>';
386        }
387
388        return '';
389    }
390
391    /**
392     * Get the date of birth
393     *
394     * @return Date
395     */
396    public function getBirthDate(): Date
397    {
398        foreach ($this->getAllBirthDates() as $date) {
399            if ($date->isOK()) {
400                return $date;
401            }
402        }
403
404        return new Date('');
405    }
406
407    /**
408     * Get the place of birth
409     *
410     * @return Place
411     */
412    public function getBirthPlace(): Place
413    {
414        foreach ($this->getAllBirthPlaces() as $place) {
415            return $place;
416        }
417
418        return new Place('', $this->tree);
419    }
420
421    /**
422     * Get the date of death
423     *
424     * @return Date
425     */
426    public function getDeathDate(): Date
427    {
428        foreach ($this->getAllDeathDates() as $date) {
429            if ($date->isOK()) {
430                return $date;
431            }
432        }
433
434        return new Date('');
435    }
436
437    /**
438     * Get the place of death
439     *
440     * @return Place
441     */
442    public function getDeathPlace(): Place
443    {
444        foreach ($this->getAllDeathPlaces() as $place) {
445            return $place;
446        }
447
448        return new Place('', $this->tree);
449    }
450
451    /**
452     * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”.
453     * Provide the place and full date using a tooltip.
454     * For consistent layout in charts, etc., show just a “–” when no dates are known.
455     * Note that this is a (non-breaking) en-dash, and not a hyphen.
456     *
457     * @return string
458     */
459    public function lifespan(): string
460    {
461        // Just the first part of the place name.
462        $birth_place = strip_tags($this->getBirthPlace()->shortName());
463        $death_place = strip_tags($this->getDeathPlace()->shortName());
464
465        // Remove markup from dates.  Use UTF_FSI / UTF_PDI instead of <bdi></bdi>, as
466        // we cannot use HTML markup in title attributes.
467        $birth_date = "\u{2068}" . strip_tags($this->getBirthDate()->display()) . "\u{2069}";
468        $death_date = "\u{2068}" . strip_tags($this->getDeathDate()->display()) . "\u{2069}";
469
470        // Use minimum and maximum dates - to agree with the age calculations.
471        $birth_year = $this->getBirthDate()->minimumDate()->format('%Y');
472        $death_year = $this->getDeathDate()->maximumDate()->format('%Y');
473
474        if ($birth_year === '') {
475            $birth_year = I18N::translate('…');
476        }
477
478        if ($death_year === '' && $this->isDead()) {
479            $death_year = I18N::translate('…');
480        }
481
482        /* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */
483        return I18N::translate(
484            '%1$s–%2$s',
485            '<span title="' . $birth_place . ' ' . $birth_date . '">' . $birth_year . '</span>',
486            '<span title="' . $death_place . ' ' . $death_date . '">' . $death_year . '</span>'
487        );
488    }
489
490    /**
491     * Get all the birth dates - for the individual lists.
492     *
493     * @return array<Date>
494     */
495    public function getAllBirthDates(): array
496    {
497        foreach (Gedcom::BIRTH_EVENTS as $event) {
498            $dates = $this->getAllEventDates([$event]);
499
500            if ($dates !== []) {
501                return $dates;
502            }
503        }
504
505        return [];
506    }
507
508    /**
509     * Gat all the birth places - for the individual lists.
510     *
511     * @return array<Place>
512     */
513    public function getAllBirthPlaces(): array
514    {
515        foreach (Gedcom::BIRTH_EVENTS as $event) {
516            $places = $this->getAllEventPlaces([$event]);
517
518            if ($places !== []) {
519                return $places;
520            }
521        }
522
523        return [];
524    }
525
526    /**
527     * Get all the death dates - for the individual lists.
528     *
529     * @return array<Date>
530     */
531    public function getAllDeathDates(): array
532    {
533        foreach (Gedcom::DEATH_EVENTS as $event) {
534            $dates = $this->getAllEventDates([$event]);
535
536            if ($dates !== []) {
537                return $dates;
538            }
539        }
540
541        return [];
542    }
543
544    /**
545     * Get all the death places - for the individual lists.
546     *
547     * @return array<Place>
548     */
549    public function getAllDeathPlaces(): array
550    {
551        foreach (Gedcom::DEATH_EVENTS as $event) {
552            $places = $this->getAllEventPlaces([$event]);
553
554            if ($places !== []) {
555                return $places;
556            }
557        }
558
559        return [];
560    }
561
562    /**
563     * Generate an estimate for the date of birth, based on dates of parents/children/spouses
564     *
565     * @return Date
566     */
567    public function getEstimatedBirthDate(): Date
568    {
569        if ($this->estimated_birth_date === null) {
570            foreach ($this->getAllBirthDates() as $date) {
571                if ($date->isOK()) {
572                    $this->estimated_birth_date = $date;
573                    break;
574                }
575            }
576            if ($this->estimated_birth_date === null) {
577                $min = [];
578                $max = [];
579                $tmp = $this->getDeathDate();
580                if ($tmp->isOK()) {
581                    $min[] = $tmp->minimumJulianDay() - 365 * (int) $this->tree->getPreference('MAX_ALIVE_AGE');
582                    $max[] = $tmp->maximumJulianDay();
583                }
584                foreach ($this->childFamilies() as $family) {
585                    $tmp = $family->getMarriageDate();
586                    if ($tmp->isOK()) {
587                        $min[] = $tmp->maximumJulianDay() - 365;
588                        $max[] = $tmp->minimumJulianDay() + 365 * 30;
589                    }
590                    $husband = $family->husband();
591                    if ($husband instanceof self) {
592                        $tmp = $husband->getBirthDate();
593                        if ($tmp->isOK()) {
594                            $min[] = $tmp->maximumJulianDay() + 365 * 15;
595                            $max[] = $tmp->minimumJulianDay() + 365 * 65;
596                        }
597                    }
598                    $wife = $family->wife();
599                    if ($wife instanceof self) {
600                        $tmp = $wife->getBirthDate();
601                        if ($tmp->isOK()) {
602                            $min[] = $tmp->maximumJulianDay() + 365 * 15;
603                            $max[] = $tmp->minimumJulianDay() + 365 * 45;
604                        }
605                    }
606                    foreach ($family->children() as $child) {
607                        $tmp = $child->getBirthDate();
608                        if ($tmp->isOK()) {
609                            $min[] = $tmp->maximumJulianDay() - 365 * 30;
610                            $max[] = $tmp->minimumJulianDay() + 365 * 30;
611                        }
612                    }
613                }
614                foreach ($this->spouseFamilies() as $family) {
615                    $tmp = $family->getMarriageDate();
616                    if ($tmp->isOK()) {
617                        $min[] = $tmp->maximumJulianDay() - 365 * 45;
618                        $max[] = $tmp->minimumJulianDay() - 365 * 15;
619                    }
620                    $spouse = $family->spouse($this);
621                    if ($spouse) {
622                        $tmp = $spouse->getBirthDate();
623                        if ($tmp->isOK()) {
624                            $min[] = $tmp->maximumJulianDay() - 365 * 25;
625                            $max[] = $tmp->minimumJulianDay() + 365 * 25;
626                        }
627                    }
628                    foreach ($family->children() as $child) {
629                        $tmp = $child->getBirthDate();
630                        if ($tmp->isOK()) {
631                            $min[] = $tmp->maximumJulianDay() - 365 * ($this->sex() === 'F' ? 45 : 65);
632                            $max[] = $tmp->minimumJulianDay() - 365 * 15;
633                        }
634                    }
635                }
636                if ($min && $max) {
637                    $gregorian_calendar = new GregorianCalendar();
638
639                    [$year] = $gregorian_calendar->jdToYmd(intdiv(max($min) + min($max), 2));
640                    $this->estimated_birth_date = new Date('EST ' . $year);
641                } else {
642                    $this->estimated_birth_date = new Date(''); // always return a date object
643                }
644            }
645        }
646
647        return $this->estimated_birth_date;
648    }
649
650    /**
651     * Generate an estimated date of death.
652     *
653     * @return Date
654     */
655    public function getEstimatedDeathDate(): Date
656    {
657        if ($this->estimated_death_date === null) {
658            foreach ($this->getAllDeathDates() as $date) {
659                if ($date->isOK()) {
660                    $this->estimated_death_date = $date;
661                    break;
662                }
663            }
664            if ($this->estimated_death_date === null) {
665                if ($this->getEstimatedBirthDate()->minimumJulianDay() !== 0) {
666                    $max_alive_age              = (int) $this->tree->getPreference('MAX_ALIVE_AGE');
667                    $this->estimated_death_date = $this->getEstimatedBirthDate()->addYears($max_alive_age, 'BEF');
668                } else {
669                    $this->estimated_death_date = new Date(''); // always return a date object
670                }
671            }
672        }
673
674        return $this->estimated_death_date;
675    }
676
677    /**
678     * Get the sex - M F or U
679     * Use the un-privatised gedcom record. We call this function during
680     * the privatize-gedcom function, and we are allowed to know this.
681     *
682     * @return string
683     */
684    public function sex(): string
685    {
686        if (preg_match('/\n1 SEX ([MFX])/', $this->gedcom . $this->pending, $match)) {
687            return $match[1];
688        }
689
690        return 'U';
691    }
692
693    /**
694     * Get a list of this individual’s spouse families
695     *
696     * @param int|null $access_level
697     *
698     * @return Collection<int,Family>
699     */
700    public function spouseFamilies(int|null $access_level = null): Collection
701    {
702        $access_level ??= Auth::accessLevel($this->tree);
703
704        if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') {
705            $access_level = Auth::PRIV_HIDE;
706        }
707
708        $families = new Collection();
709        foreach ($this->facts(['FAMS'], false, $access_level) as $fact) {
710            $family = $fact->target();
711            if ($family instanceof Family && $family->canShow($access_level)) {
712                $families->push($family);
713            }
714        }
715
716        return new Collection($families);
717    }
718
719    /**
720     * Get the current spouse of this individual.
721     *
722     * Where an individual has multiple spouses, assume they are stored
723     * in chronological order, and take the last one found.
724     *
725     * @return Individual|null
726     */
727    public function getCurrentSpouse(): Individual|null
728    {
729        $family = $this->spouseFamilies()->last();
730
731        if ($family instanceof Family) {
732            return $family->spouse($this);
733        }
734
735        return null;
736    }
737
738    /**
739     * Count the children belonging to this individual.
740     *
741     * @return int
742     */
743    public function numberOfChildren(): int
744    {
745        if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->gedcom(), $match)) {
746            return (int) $match[1];
747        }
748
749        $children = [];
750        foreach ($this->spouseFamilies() as $fam) {
751            foreach ($fam->children() as $child) {
752                $children[$child->xref()] = true;
753            }
754        }
755
756        return count($children);
757    }
758
759    /**
760     * Get a list of this individual’s child families (i.e. their parents).
761     *
762     * @param int|null $access_level
763     *
764     * @return Collection<int,Family>
765     */
766    public function childFamilies(int|null $access_level = null): Collection
767    {
768        $access_level ??= Auth::accessLevel($this->tree);
769
770        if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') {
771            $access_level = Auth::PRIV_HIDE;
772        }
773
774        $families = new Collection();
775
776        foreach ($this->facts(['FAMC'], false, $access_level) as $fact) {
777            $family = $fact->target();
778            if ($family instanceof Family && $family->canShow($access_level)) {
779                $families->push($family);
780            }
781        }
782
783        return $families;
784    }
785
786    /**
787     * Get a list of step-parent families.
788     *
789     * @return Collection<int,Family>
790     */
791    public function childStepFamilies(): Collection
792    {
793        $step_families = new Collection();
794        $families      = $this->childFamilies();
795        foreach ($families as $family) {
796            foreach ($family->spouses() as $parent) {
797                foreach ($parent->spouseFamilies() as $step_family) {
798                    if (!$families->containsStrict($step_family)) {
799                        $step_families->add($step_family);
800                    }
801                }
802            }
803        }
804
805        return $step_families->uniqueStrict(static fn (Family $family): string => $family->xref());
806    }
807
808    /**
809     * Get a list of step-parent families.
810     *
811     * @return Collection<int,Family>
812     */
813    public function spouseStepFamilies(): Collection
814    {
815        $step_families = [];
816        $families      = $this->spouseFamilies();
817
818        foreach ($families as $family) {
819            $spouse = $family->spouse($this);
820
821            if ($spouse instanceof self) {
822                foreach ($family->spouse($this)->spouseFamilies() as $step_family) {
823                    if (!$families->containsStrict($step_family)) {
824                        $step_families[] = $step_family;
825                    }
826                }
827            }
828        }
829
830        return new Collection($step_families);
831    }
832
833    /**
834     * A label for a parental family group
835     *
836     * @param Family $family
837     *
838     * @return string
839     */
840    public function getChildFamilyLabel(Family $family): string
841    {
842        $fact = $this->facts(['FAMC'])->first(static fn (Fact $fact): bool => $fact->target() === $family);
843
844        if ($fact instanceof Fact) {
845            $pedigree = $fact->attribute('PEDI');
846        } else {
847            $pedigree = '';
848        }
849
850        $values = [
851            PedigreeLinkageType::VALUE_BIRTH   => I18N::translate('Family with parents'),
852            PedigreeLinkageType::VALUE_ADOPTED => I18N::translate('Family with adoptive parents'),
853            PedigreeLinkageType::VALUE_FOSTER  => I18N::translate('Family with foster parents'),
854            /* I18N: “sealing” is a Mormon ceremony. */
855            PedigreeLinkageType::VALUE_SEALING => I18N::translate('Family with sealing parents'),
856            /* I18N: “rada” is an Arabic word, pronounced “ra DAH”. It is child-to-parent pedigree, established by wet-nursing. */
857            PedigreeLinkageType::VALUE_RADA    => I18N::translate('Family with rada parents'),
858        ];
859
860        return $values[$pedigree] ?? $values[PedigreeLinkageType::VALUE_BIRTH];
861    }
862
863    /**
864     * Create a label for a step family
865     *
866     * @param Family $step_family
867     *
868     * @return string
869     */
870    public function getStepFamilyLabel(Family $step_family): string
871    {
872        foreach ($this->childFamilies() as $family) {
873            if ($family !== $step_family) {
874                // Must be a step-family
875                foreach ($family->spouses() as $parent) {
876                    foreach ($step_family->spouses() as $step_parent) {
877                        if ($parent === $step_parent) {
878                            // One common parent - must be a step family
879                            if ($parent->sex() === 'M') {
880                                // Father’s family with someone else
881                                if ($step_family->spouse($step_parent) instanceof Individual) {
882                                    /* I18N: A step-family. %s is an individual’s name */
883                                    return I18N::translate('Father’s family with %s', $step_family->spouse($step_parent)->fullName());
884                                }
885
886                                /* I18N: A step-family. */
887                                return I18N::translate('Father’s family with an unknown individual');
888                            }
889
890                            // Mother’s family with someone else
891                            if ($step_family->spouse($step_parent) instanceof Individual) {
892                                /* I18N: A step-family. %s is an individual’s name */
893                                return I18N::translate('Mother’s family with %s', $step_family->spouse($step_parent)->fullName());
894                            }
895
896                            /* I18N: A step-family. */
897                            return I18N::translate('Mother’s family with an unknown individual');
898                        }
899                    }
900                }
901            }
902        }
903
904        // Perahps same parents - but a different family record?
905        return I18N::translate('Family with parents');
906    }
907
908    /**
909     * Get the description for the family.
910     *
911     * For example, "XXX's family with new wife".
912     *
913     * @param Family $family
914     *
915     * @return string
916     */
917    public function getSpouseFamilyLabel(Family $family): string
918    {
919        $spouse = $family->spouse($this);
920
921        if ($spouse instanceof Individual) {
922            /* I18N: %s is the spouse name */
923            return I18N::translate('Family with %s', $spouse->fullName());
924        }
925
926        return $family->fullName();
927    }
928
929    /**
930     * If this object has no name, what do we call it?
931     *
932     * @return string
933     */
934    public function getFallBackName(): string
935    {
936        return '@P.N. /@N.N./';
937    }
938
939    /**
940     * Convert a name record into ‘full’ and ‘sort’ versions.
941     * Use the NAME field to generate the ‘full’ version, as the
942     * gedcom spec says that this is the individual’s name, as they would write it.
943     * Use the SURN field to generate the sortable names. Note that this field
944     * may also be used for the ‘true’ surname, perhaps spelt differently to that
945     * recorded in the NAME field. e.g.
946     *
947     * 1 NAME Robert /de Gliderow/
948     * 2 GIVN Robert
949     * 2 SPFX de
950     * 2 SURN CLITHEROW
951     * 2 NICK The Bald
952     *
953     * full=>'Robert de Gliderow 'The Bald''
954     * sort=>'CLITHEROW, ROBERT'
955     *
956     * Handle multiple surnames, either as;
957     *
958     * 1 NAME Carlos /Vasquez/ y /Sante/
959     * or
960     * 1 NAME Carlos /Vasquez y Sante/
961     * 2 GIVN Carlos
962     * 2 SURN Vasquez,Sante
963     *
964     * @param string $type
965     * @param string $value
966     * @param string $gedcom
967     *
968     * @return void
969     */
970    protected function addName(string $type, string $value, string $gedcom): void
971    {
972        ////////////////////////////////////////////////////////////////////////////
973        // Extract the structured name parts - use for "sortable" names and indexes
974        ////////////////////////////////////////////////////////////////////////////
975
976        $sublevel = 1 + (int) substr($gedcom, 0, 1);
977        $GIVN     = preg_match('/\n' . $sublevel . ' GIVN (.+)/', $gedcom, $match) === 1 ? $match[1] : '';
978        $SURN     = preg_match('/\n' . $sublevel . ' SURN (.+)/', $gedcom, $match) === 1 ? $match[1] : '';
979
980        // SURN is an comma-separated list of surnames...
981        if ($SURN !== '') {
982            $SURNS = preg_split('/ *, */', $SURN);
983        } else {
984            $SURNS = [];
985        }
986
987        // ...so is GIVN - but nobody uses it like that
988        $GIVN = str_replace('/ *, */', ' ', $GIVN);
989
990        ////////////////////////////////////////////////////////////////////////////
991        // Extract the components from NAME - use for the "full" names
992        ////////////////////////////////////////////////////////////////////////////
993
994        // Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/'
995        if (substr_count($value, '/') % 2 === 1) {
996            $value .= '/';
997        }
998
999        // GEDCOM uses "//" to indicate an unknown surname
1000        $full = preg_replace('/\/\//', '/@N.N./', $value);
1001
1002        // Extract the surname.
1003        // Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/
1004        if (preg_match('/\/.*\//', $full, $match)) {
1005            $surname = str_replace('/', '', $match[0]);
1006        } else {
1007            $surname = '';
1008        }
1009
1010        // If we don’t have a SURN record, extract it from the NAME
1011        if (!$SURNS) {
1012            if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) {
1013                // There can be many surnames, each wrapped with '/'
1014                $SURNS = $matches[1];
1015                foreach ($SURNS as $n => $SURN) {
1016                    // Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only)
1017                    $SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN);
1018                }
1019            } else {
1020                // It is valid not to have a surname at all
1021                $SURNS = [''];
1022            }
1023        }
1024
1025        // If we don’t have a GIVN record, extract it from the NAME
1026        if (!$GIVN) {
1027            // remove surname
1028            $GIVN = preg_replace('/ ?\/.*\/ ?/', ' ', $full);
1029            // remove nickname
1030            $GIVN = preg_replace('/ ?".+"/', ' ', $GIVN);
1031            // multiple spaces, caused by the above
1032            $GIVN = preg_replace('/ {2,}/', ' ', $GIVN);
1033            // leading/trailing spaces, caused by the above
1034            $GIVN = preg_replace('/^ | $/', '', $GIVN);
1035        }
1036
1037        // Add placeholder for unknown given name
1038        if (!$GIVN) {
1039            $GIVN = self::PRAENOMEN_NESCIO;
1040            $pos  = (int) strpos($full, '/');
1041            $full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos);
1042        }
1043
1044        // Remove slashes - they don’t get displayed
1045        // $fullNN keeps the @N.N. placeholders, for the database
1046        // $full is for display on-screen
1047        $fullNN = str_replace('/', '', $full);
1048
1049        // Insert placeholders for any missing/unknown names
1050        $full = str_replace(self::NOMEN_NESCIO, I18N::translateContext('Unknown surname', '…'), $full);
1051        $full = str_replace(self::PRAENOMEN_NESCIO, I18N::translateContext('Unknown given name', '…'), $full);
1052        // Format for display
1053        $full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', e($full)) . '</span>';
1054        // Localise quotation marks around the nickname
1055        $full = preg_replace_callback('/&quot;([^&]*)&quot;/', static fn (array $matches): string => '<q class="wt-nickname">' . $matches[1] . '</q>', $full);
1056
1057        // A suffix of “*” indicates a preferred name
1058        $full = preg_replace('/([^ >\x{200C}]*)\*/u', '<span class="starredname">\\1</span>', $full);
1059
1060        // Remove prefered-name indicater - they don’t go in the database
1061        $GIVN   = str_replace('*', '', $GIVN);
1062        $fullNN = str_replace('*', '', $fullNN);
1063
1064        foreach ($SURNS as $SURN) {
1065            // Scottish 'Mc and Mac ' prefixes both sort under 'Mac'
1066            if (strcasecmp(substr($SURN, 0, 2), 'Mc') === 0) {
1067                $SURN = substr_replace($SURN, 'Mac', 0, 2);
1068            } elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') === 0) {
1069                $SURN = substr_replace($SURN, 'Mac', 0, 4);
1070            }
1071
1072            $this->getAllNames[] = [
1073                'type'    => $type,
1074                'sort'    => $SURN . ',' . $GIVN,
1075                'full'    => $full,
1076                // This is used for display
1077                'fullNN'  => $fullNN,
1078                // This goes into the database
1079                'surname' => $surname,
1080                // This goes into the database
1081                'givn'    => $GIVN,
1082                // This goes into the database
1083                'surn'    => $SURN,
1084                // This goes into the database
1085            ];
1086        }
1087    }
1088
1089    /**
1090     * Extract names from the GEDCOM record.
1091     *
1092     * @return void
1093     */
1094    public function extractNames(): void
1095    {
1096        $access_level = $this->canShowName() ? Auth::PRIV_HIDE : Auth::accessLevel($this->tree);
1097
1098        $this->extractNamesFromFacts(
1099            1,
1100            'NAME',
1101            $this->facts(['NAME'], false, $access_level)
1102        );
1103    }
1104
1105    /**
1106     * Extra info to display when displaying this record in a list of
1107     * selection items or favorites.
1108     *
1109     * @return string
1110     */
1111    public function formatListDetails(): string
1112    {
1113        return
1114            $this->formatFirstMajorFact(Gedcom::BIRTH_EVENTS, 1) .
1115            $this->formatFirstMajorFact(Gedcom::DEATH_EVENTS, 1);
1116    }
1117
1118    /**
1119     * Lock the database row, to prevent concurrent edits.
1120     */
1121    public function lock(): void
1122    {
1123        DB::table('individuals')
1124            ->where('i_file', '=', $this->tree->id())
1125            ->where('i_id', '=', $this->xref())
1126            ->lockForUpdate()
1127            ->get();
1128    }
1129}
1130