xref: /webtrees/app/Individual.php (revision c642dcbb44ad1c68eb1f1abb7a08d4518323d78f)
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     * Calculate whether this individual is living or dead.
216     * If not known to be dead, then assume living.
217     *
218     * @return bool
219     */
220    public function isDead(): bool
221    {
222        $MAX_ALIVE_AGE = (int) $this->tree->getPreference('MAX_ALIVE_AGE');
223        $today_jd      = Registry::timestampFactory()->now()->julianDay();
224
225        // "1 DEAT Y" or "1 DEAT/2 DATE" or "1 DEAT/2 PLAC"
226        if (preg_match('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ')(?: Y|(?:\n[2-9].+)*\n2 (DATE|PLAC) )/', $this->gedcom)) {
227            return true;
228        }
229
230        // If any event occurred more than $MAX_ALIVE_AGE years ago, then assume the individual is dead
231        if (preg_match_all('/\n2 DATE (.+)/', $this->gedcom, $date_matches)) {
232            foreach ($date_matches[1] as $date_match) {
233                $date = new Date($date_match);
234                if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * $MAX_ALIVE_AGE) {
235                    return true;
236                }
237            }
238            // The individual has one or more dated events. All are less than $MAX_ALIVE_AGE years ago.
239            // If one of these is a birth, the individual must be alive.
240            if (preg_match('/\n1 BIRT(?:\n[2-9].+)*\n2 DATE /', $this->gedcom)) {
241                return false;
242            }
243        }
244
245        // If we found no conclusive dates then check the dates of close relatives.
246
247        // Check parents (birth and adopted)
248        foreach ($this->childFamilies(Auth::PRIV_HIDE) as $family) {
249            foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
250                // Assume parents are no more than 45 years older than their children
251                preg_match_all('/\n2 DATE (.+)/', $parent->gedcom, $date_matches);
252                foreach ($date_matches[1] as $date_match) {
253                    $date = new Date($date_match);
254                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 45)) {
255                        return true;
256                    }
257                }
258            }
259        }
260
261        // Check spouses
262        foreach ($this->spouseFamilies(Auth::PRIV_HIDE) as $family) {
263            preg_match_all('/\n2 DATE (.+)/', $family->gedcom, $date_matches);
264            foreach ($date_matches[1] as $date_match) {
265                $date = new Date($date_match);
266                // Assume marriage occurs after age of 10
267                if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 10)) {
268                    return true;
269                }
270            }
271            // Check spouse dates
272            $spouse = $family->spouse($this, Auth::PRIV_HIDE);
273            if ($spouse) {
274                preg_match_all('/\n2 DATE (.+)/', $spouse->gedcom, $date_matches);
275                foreach ($date_matches[1] as $date_match) {
276                    $date = new Date($date_match);
277                    // Assume max age difference between spouses of 40 years
278                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 40)) {
279                        return true;
280                    }
281                }
282            }
283            // Check child dates
284            foreach ($family->children(Auth::PRIV_HIDE) as $child) {
285                preg_match_all('/\n2 DATE (.+)/', $child->gedcom, $date_matches);
286                // Assume children born after age of 15
287                foreach ($date_matches[1] as $date_match) {
288                    $date = new Date($date_match);
289                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 15)) {
290                        return true;
291                    }
292                }
293                // Check grandchildren
294                foreach ($child->spouseFamilies(Auth::PRIV_HIDE) as $child_family) {
295                    foreach ($child_family->children(Auth::PRIV_HIDE) as $grandchild) {
296                        preg_match_all('/\n2 DATE (.+)/', $grandchild->gedcom, $date_matches);
297                        // Assume grandchildren born after age of 30
298                        foreach ($date_matches[1] as $date_match) {
299                            $date = new Date($date_match);
300                            if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 30)) {
301                                return true;
302                            }
303                        }
304                    }
305                }
306            }
307        }
308
309        return false;
310    }
311
312    /**
313     * Find the highlighted media object for an individual
314     */
315    public function findHighlightedMediaFile(): MediaFile|null
316    {
317        $fact = $this->facts(['OBJE'])
318            ->first(static function (Fact $fact): bool {
319                $media = $fact->target();
320
321                return $media instanceof Media && $media->firstImageFile() instanceof MediaFile;
322            });
323
324        if ($fact instanceof Fact && $fact->target() instanceof Media) {
325            return $fact->target()->firstImageFile();
326        }
327
328        return null;
329    }
330
331    /**
332     * Display the preferred image for this individual.
333     * Use an icon if no image is available.
334     *
335     * @param int           $width      Pixels
336     * @param int           $height     Pixels
337     * @param string        $fit        "crop" or "contain"
338     * @param array<string> $attributes Additional HTML attributes
339     *
340     * @return string
341     */
342    public function displayImage(int $width, int $height, string $fit, array $attributes): string
343    {
344        $media_file = $this->findHighlightedMediaFile();
345
346        if ($media_file !== null) {
347            return $media_file->displayImage($width, $height, $fit, $attributes);
348        }
349
350        if ($this->tree->getPreference('USE_SILHOUETTE') === '1') {
351            return '<i class="icon-silhouette icon-silhouette-' . strtolower($this->sex()) . ' wt-icon-flip-rtl"></i>';
352        }
353
354        return '';
355    }
356
357    /**
358     * Get the date of birth
359     *
360     * @return Date
361     */
362    public function getBirthDate(): Date
363    {
364        foreach ($this->getAllBirthDates() as $date) {
365            if ($date->isOK()) {
366                return $date;
367            }
368        }
369
370        return new Date('');
371    }
372
373    /**
374     * Get the place of birth
375     *
376     * @return Place
377     */
378    public function getBirthPlace(): Place
379    {
380        foreach ($this->getAllBirthPlaces() as $place) {
381            return $place;
382        }
383
384        return new Place('', $this->tree);
385    }
386
387    /**
388     * Get the date of death
389     *
390     * @return Date
391     */
392    public function getDeathDate(): Date
393    {
394        foreach ($this->getAllDeathDates() as $date) {
395            if ($date->isOK()) {
396                return $date;
397            }
398        }
399
400        return new Date('');
401    }
402
403    /**
404     * Get the place of death
405     *
406     * @return Place
407     */
408    public function getDeathPlace(): Place
409    {
410        foreach ($this->getAllDeathPlaces() as $place) {
411            return $place;
412        }
413
414        return new Place('', $this->tree);
415    }
416
417    /**
418     * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”.
419     * Provide the place and full date using a tooltip.
420     * For consistent layout in charts, etc., show just a “–” when no dates are known.
421     * Note that this is a (non-breaking) en-dash, and not a hyphen.
422     *
423     * @return string
424     */
425    public function lifespan(): string
426    {
427        // Just the first part of the place name.
428        $birth_place = strip_tags($this->getBirthPlace()->shortName());
429        $death_place = strip_tags($this->getDeathPlace()->shortName());
430
431        // Remove markup from dates.  Use UTF_FSI / UTF_PDI instead of <bdi></bdi>, as
432        // we cannot use HTML markup in title attributes.
433        $birth_date = "\u{2068}" . strip_tags($this->getBirthDate()->display()) . "\u{2069}";
434        $death_date = "\u{2068}" . strip_tags($this->getDeathDate()->display()) . "\u{2069}";
435
436        // Use minimum and maximum dates - to agree with the age calculations.
437        $birth_year = $this->getBirthDate()->minimumDate()->format('%Y');
438        $death_year = $this->getDeathDate()->maximumDate()->format('%Y');
439
440        if ($birth_year === '') {
441            $birth_year = I18N::translate('…');
442        }
443
444        if ($death_year === '' && $this->isDead()) {
445            $death_year = I18N::translate('…');
446        }
447
448        /* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */
449        return I18N::translate(
450            '%1$s–%2$s',
451            '<span title="' . $birth_place . ' ' . $birth_date . '">' . $birth_year . '</span>',
452            '<span title="' . $death_place . ' ' . $death_date . '">' . $death_year . '</span>'
453        );
454    }
455
456    /**
457     * Get all the birth dates - for the individual lists.
458     *
459     * @return array<Date>
460     */
461    public function getAllBirthDates(): array
462    {
463        foreach (Gedcom::BIRTH_EVENTS as $event) {
464            $dates = $this->getAllEventDates([$event]);
465
466            if ($dates !== []) {
467                return $dates;
468            }
469        }
470
471        return [];
472    }
473
474    /**
475     * Gat all the birth places - for the individual lists.
476     *
477     * @return array<Place>
478     */
479    public function getAllBirthPlaces(): array
480    {
481        foreach (Gedcom::BIRTH_EVENTS as $event) {
482            $places = $this->getAllEventPlaces([$event]);
483
484            if ($places !== []) {
485                return $places;
486            }
487        }
488
489        return [];
490    }
491
492    /**
493     * Get all the death dates - for the individual lists.
494     *
495     * @return array<Date>
496     */
497    public function getAllDeathDates(): array
498    {
499        foreach (Gedcom::DEATH_EVENTS as $event) {
500            $dates = $this->getAllEventDates([$event]);
501
502            if ($dates !== []) {
503                return $dates;
504            }
505        }
506
507        return [];
508    }
509
510    /**
511     * Get all the death places - for the individual lists.
512     *
513     * @return array<Place>
514     */
515    public function getAllDeathPlaces(): array
516    {
517        foreach (Gedcom::DEATH_EVENTS as $event) {
518            $places = $this->getAllEventPlaces([$event]);
519
520            if ($places !== []) {
521                return $places;
522            }
523        }
524
525        return [];
526    }
527
528    /**
529     * Generate an estimate for the date of birth, based on dates of parents/children/spouses
530     *
531     * @return Date
532     */
533    public function getEstimatedBirthDate(): Date
534    {
535        if ($this->estimated_birth_date === null) {
536            foreach ($this->getAllBirthDates() as $date) {
537                if ($date->isOK()) {
538                    $this->estimated_birth_date = $date;
539                    break;
540                }
541            }
542            if ($this->estimated_birth_date === null) {
543                $min = [];
544                $max = [];
545                $tmp = $this->getDeathDate();
546                if ($tmp->isOK()) {
547                    $min[] = $tmp->minimumJulianDay() - 365 * (int) $this->tree->getPreference('MAX_ALIVE_AGE');
548                    $max[] = $tmp->maximumJulianDay();
549                }
550                foreach ($this->childFamilies() as $family) {
551                    $tmp = $family->getMarriageDate();
552                    if ($tmp->isOK()) {
553                        $min[] = $tmp->maximumJulianDay() - 365;
554                        $max[] = $tmp->minimumJulianDay() + 365 * 30;
555                    }
556                    $husband = $family->husband();
557                    if ($husband instanceof self) {
558                        $tmp = $husband->getBirthDate();
559                        if ($tmp->isOK()) {
560                            $min[] = $tmp->maximumJulianDay() + 365 * 15;
561                            $max[] = $tmp->minimumJulianDay() + 365 * 65;
562                        }
563                    }
564                    $wife = $family->wife();
565                    if ($wife instanceof self) {
566                        $tmp = $wife->getBirthDate();
567                        if ($tmp->isOK()) {
568                            $min[] = $tmp->maximumJulianDay() + 365 * 15;
569                            $max[] = $tmp->minimumJulianDay() + 365 * 45;
570                        }
571                    }
572                    foreach ($family->children() as $child) {
573                        $tmp = $child->getBirthDate();
574                        if ($tmp->isOK()) {
575                            $min[] = $tmp->maximumJulianDay() - 365 * 30;
576                            $max[] = $tmp->minimumJulianDay() + 365 * 30;
577                        }
578                    }
579                }
580                foreach ($this->spouseFamilies() as $family) {
581                    $tmp = $family->getMarriageDate();
582                    if ($tmp->isOK()) {
583                        $min[] = $tmp->maximumJulianDay() - 365 * 45;
584                        $max[] = $tmp->minimumJulianDay() - 365 * 15;
585                    }
586                    $spouse = $family->spouse($this);
587                    if ($spouse) {
588                        $tmp = $spouse->getBirthDate();
589                        if ($tmp->isOK()) {
590                            $min[] = $tmp->maximumJulianDay() - 365 * 25;
591                            $max[] = $tmp->minimumJulianDay() + 365 * 25;
592                        }
593                    }
594                    foreach ($family->children() as $child) {
595                        $tmp = $child->getBirthDate();
596                        if ($tmp->isOK()) {
597                            $min[] = $tmp->maximumJulianDay() - 365 * ($this->sex() === 'F' ? 45 : 65);
598                            $max[] = $tmp->minimumJulianDay() - 365 * 15;
599                        }
600                    }
601                }
602                if ($min && $max) {
603                    $gregorian_calendar = new GregorianCalendar();
604
605                    [$year] = $gregorian_calendar->jdToYmd(intdiv(max($min) + min($max), 2));
606                    $this->estimated_birth_date = new Date('EST ' . $year);
607                } else {
608                    $this->estimated_birth_date = new Date(''); // always return a date object
609                }
610            }
611        }
612
613        return $this->estimated_birth_date;
614    }
615
616    /**
617     * Generate an estimated date of death.
618     *
619     * @return Date
620     */
621    public function getEstimatedDeathDate(): Date
622    {
623        if ($this->estimated_death_date === null) {
624            foreach ($this->getAllDeathDates() as $date) {
625                if ($date->isOK()) {
626                    $this->estimated_death_date = $date;
627                    break;
628                }
629            }
630            if ($this->estimated_death_date === null) {
631                if ($this->getEstimatedBirthDate()->minimumJulianDay() !== 0) {
632                    $max_alive_age              = (int) $this->tree->getPreference('MAX_ALIVE_AGE');
633                    $this->estimated_death_date = $this->getEstimatedBirthDate()->addYears($max_alive_age, 'BEF');
634                } else {
635                    $this->estimated_death_date = new Date(''); // always return a date object
636                }
637            }
638        }
639
640        return $this->estimated_death_date;
641    }
642
643    /**
644     * Get the sex - M F or U
645     * Use the un-privatised gedcom record. We call this function during
646     * the privatize-gedcom function, and we are allowed to know this.
647     *
648     * @return string
649     */
650    public function sex(): string
651    {
652        if (preg_match('/\n1 SEX ([MFX])/', $this->gedcom . $this->pending, $match)) {
653            return $match[1];
654        }
655
656        return 'U';
657    }
658
659    /**
660     * Get a list of this individual’s spouse families
661     *
662     * @param int|null $access_level
663     *
664     * @return Collection<int,Family>
665     */
666    public function spouseFamilies(int|null $access_level = null): Collection
667    {
668        $access_level ??= Auth::accessLevel($this->tree);
669
670        if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') {
671            $access_level = Auth::PRIV_HIDE;
672        }
673
674        $families = new Collection();
675        foreach ($this->facts(['FAMS'], false, $access_level) as $fact) {
676            $family = $fact->target();
677            if ($family instanceof Family && $family->canShow($access_level)) {
678                $families->push($family);
679            }
680        }
681
682        return new Collection($families);
683    }
684
685    /**
686     * Get the current spouse of this individual.
687     *
688     * Where an individual has multiple spouses, assume they are stored
689     * in chronological order, and take the last one found.
690     *
691     * @return Individual|null
692     */
693    public function getCurrentSpouse(): Individual|null
694    {
695        $family = $this->spouseFamilies()->last();
696
697        if ($family instanceof Family) {
698            return $family->spouse($this);
699        }
700
701        return null;
702    }
703
704    /**
705     * Count the children belonging to this individual.
706     *
707     * @return int
708     */
709    public function numberOfChildren(): int
710    {
711        if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->gedcom(), $match)) {
712            return (int) $match[1];
713        }
714
715        $children = [];
716        foreach ($this->spouseFamilies() as $fam) {
717            foreach ($fam->children() as $child) {
718                $children[$child->xref()] = true;
719            }
720        }
721
722        return count($children);
723    }
724
725    /**
726     * Get a list of this individual’s child families (i.e. their parents).
727     *
728     * @param int|null $access_level
729     *
730     * @return Collection<int,Family>
731     */
732    public function childFamilies(int|null $access_level = null): Collection
733    {
734        $access_level ??= Auth::accessLevel($this->tree);
735
736        if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') {
737            $access_level = Auth::PRIV_HIDE;
738        }
739
740        $families = new Collection();
741
742        foreach ($this->facts(['FAMC'], false, $access_level) as $fact) {
743            $family = $fact->target();
744            if ($family instanceof Family && $family->canShow($access_level)) {
745                $families->push($family);
746            }
747        }
748
749        return $families;
750    }
751
752    /**
753     * Get a list of step-parent families.
754     *
755     * @return Collection<int,Family>
756     */
757    public function childStepFamilies(): Collection
758    {
759        $step_families = new Collection();
760        $families      = $this->childFamilies();
761        foreach ($families as $family) {
762            foreach ($family->spouses() as $parent) {
763                foreach ($parent->spouseFamilies() as $step_family) {
764                    if (!$families->containsStrict($step_family)) {
765                        $step_families->add($step_family);
766                    }
767                }
768            }
769        }
770
771        return $step_families->uniqueStrict(static fn (Family $family): string => $family->xref());
772    }
773
774    /**
775     * Get a list of step-parent families.
776     *
777     * @return Collection<int,Family>
778     */
779    public function spouseStepFamilies(): Collection
780    {
781        $step_families = [];
782        $families      = $this->spouseFamilies();
783
784        foreach ($families as $family) {
785            $spouse = $family->spouse($this);
786
787            if ($spouse instanceof self) {
788                foreach ($family->spouse($this)->spouseFamilies() as $step_family) {
789                    if (!$families->containsStrict($step_family)) {
790                        $step_families[] = $step_family;
791                    }
792                }
793            }
794        }
795
796        return new Collection($step_families);
797    }
798
799    /**
800     * A label for a parental family group
801     *
802     * @param Family $family
803     *
804     * @return string
805     */
806    public function getChildFamilyLabel(Family $family): string
807    {
808        $fact = $this->facts(['FAMC'])->first(static fn (Fact $fact): bool => $fact->target() === $family);
809
810        if ($fact instanceof Fact) {
811            $pedigree = $fact->attribute('PEDI');
812        } else {
813            $pedigree = '';
814        }
815
816        $values = [
817            PedigreeLinkageType::VALUE_BIRTH   => I18N::translate('Family with parents'),
818            PedigreeLinkageType::VALUE_ADOPTED => I18N::translate('Family with adoptive parents'),
819            PedigreeLinkageType::VALUE_FOSTER  => I18N::translate('Family with foster parents'),
820            /* I18N: “sealing” is a Mormon ceremony. */
821            PedigreeLinkageType::VALUE_SEALING => I18N::translate('Family with sealing parents'),
822            /* I18N: “rada” is an Arabic word, pronounced “ra DAH”. It is child-to-parent pedigree, established by wet-nursing. */
823            PedigreeLinkageType::VALUE_RADA    => I18N::translate('Family with rada parents'),
824        ];
825
826        return $values[$pedigree] ?? $values[PedigreeLinkageType::VALUE_BIRTH];
827    }
828
829    /**
830     * Create a label for a step family
831     *
832     * @param Family $step_family
833     *
834     * @return string
835     */
836    public function getStepFamilyLabel(Family $step_family): string
837    {
838        foreach ($this->childFamilies() as $family) {
839            if ($family !== $step_family) {
840                // Must be a step-family
841                foreach ($family->spouses() as $parent) {
842                    foreach ($step_family->spouses() as $step_parent) {
843                        if ($parent === $step_parent) {
844                            // One common parent - must be a step family
845                            if ($parent->sex() === 'M') {
846                                // Father’s family with someone else
847                                if ($step_family->spouse($step_parent) instanceof Individual) {
848                                    /* I18N: A step-family. %s is an individual’s name */
849                                    return I18N::translate('Father’s family with %s', $step_family->spouse($step_parent)->fullName());
850                                }
851
852                                /* I18N: A step-family. */
853                                return I18N::translate('Father’s family with an unknown individual');
854                            }
855
856                            // Mother’s family with someone else
857                            if ($step_family->spouse($step_parent) instanceof Individual) {
858                                /* I18N: A step-family. %s is an individual’s name */
859                                return I18N::translate('Mother’s family with %s', $step_family->spouse($step_parent)->fullName());
860                            }
861
862                            /* I18N: A step-family. */
863                            return I18N::translate('Mother’s family with an unknown individual');
864                        }
865                    }
866                }
867            }
868        }
869
870        // Perahps same parents - but a different family record?
871        return I18N::translate('Family with parents');
872    }
873
874    /**
875     * Get the description for the family.
876     *
877     * For example, "XXX's family with new wife".
878     *
879     * @param Family $family
880     *
881     * @return string
882     */
883    public function getSpouseFamilyLabel(Family $family): string
884    {
885        $spouse = $family->spouse($this);
886
887        if ($spouse instanceof Individual) {
888            /* I18N: %s is the spouse name */
889            return I18N::translate('Family with %s', $spouse->fullName());
890        }
891
892        return $family->fullName();
893    }
894
895    /**
896     * If this object has no name, what do we call it?
897     *
898     * @return string
899     */
900    public function getFallBackName(): string
901    {
902        return '@P.N. /@N.N./';
903    }
904
905    /**
906     * Convert a name record into ‘full’ and ‘sort’ versions.
907     * Use the NAME field to generate the ‘full’ version, as the
908     * gedcom spec says that this is the individual’s name, as they would write it.
909     * Use the SURN field to generate the sortable names. Note that this field
910     * may also be used for the ‘true’ surname, perhaps spelt differently to that
911     * recorded in the NAME field. e.g.
912     *
913     * 1 NAME Robert /de Gliderow/
914     * 2 GIVN Robert
915     * 2 SPFX de
916     * 2 SURN CLITHEROW
917     * 2 NICK The Bald
918     *
919     * full=>'Robert de Gliderow 'The Bald''
920     * sort=>'CLITHEROW, ROBERT'
921     *
922     * Handle multiple surnames, either as;
923     *
924     * 1 NAME Carlos /Vasquez/ y /Sante/
925     * or
926     * 1 NAME Carlos /Vasquez y Sante/
927     * 2 GIVN Carlos
928     * 2 SURN Vasquez,Sante
929     *
930     * @param string $type
931     * @param string $value
932     * @param string $gedcom
933     *
934     * @return void
935     */
936    protected function addName(string $type, string $value, string $gedcom): void
937    {
938        ////////////////////////////////////////////////////////////////////////////
939        // Extract the structured name parts - use for "sortable" names and indexes
940        ////////////////////////////////////////////////////////////////////////////
941
942        $sublevel = 1 + (int) substr($gedcom, 0, 1);
943        $GIVN     = preg_match('/\n' . $sublevel . ' GIVN (.+)/', $gedcom, $match) === 1 ? $match[1] : '';
944        $SURN     = preg_match('/\n' . $sublevel . ' SURN (.+)/', $gedcom, $match) === 1 ? $match[1] : '';
945
946        // SURN is an comma-separated list of surnames...
947        if ($SURN !== '') {
948            $SURNS = preg_split('/ *, */', $SURN);
949        } else {
950            $SURNS = [];
951        }
952
953        // ...so is GIVN - but nobody uses it like that
954        $GIVN = str_replace('/ *, */', ' ', $GIVN);
955
956        ////////////////////////////////////////////////////////////////////////////
957        // Extract the components from NAME - use for the "full" names
958        ////////////////////////////////////////////////////////////////////////////
959
960        // Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/'
961        if (substr_count($value, '/') % 2 === 1) {
962            $value .= '/';
963        }
964
965        // GEDCOM uses "//" to indicate an unknown surname
966        $full = preg_replace('/\/\//', '/@N.N./', $value);
967
968        // Extract the surname.
969        // Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/
970        if (preg_match('/\/.*\//', $full, $match)) {
971            $surname = str_replace('/', '', $match[0]);
972        } else {
973            $surname = '';
974        }
975
976        // If we don’t have a SURN record, extract it from the NAME
977        if (!$SURNS) {
978            if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) {
979                // There can be many surnames, each wrapped with '/'
980                $SURNS = $matches[1];
981                foreach ($SURNS as $n => $SURN) {
982                    // Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only)
983                    $SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN);
984                }
985            } else {
986                // It is valid not to have a surname at all
987                $SURNS = [''];
988            }
989        }
990
991        // If we don’t have a GIVN record, extract it from the NAME
992        if (!$GIVN) {
993            // remove surname
994            $GIVN = preg_replace('/ ?\/.*\/ ?/', ' ', $full);
995            // remove nickname
996            $GIVN = preg_replace('/ ?".+"/', ' ', $GIVN);
997            // multiple spaces, caused by the above
998            $GIVN = preg_replace('/ {2,}/', ' ', $GIVN);
999            // leading/trailing spaces, caused by the above
1000            $GIVN = preg_replace('/^ | $/', '', $GIVN);
1001        }
1002
1003        // Add placeholder for unknown given name
1004        if (!$GIVN) {
1005            $GIVN = self::PRAENOMEN_NESCIO;
1006            $pos  = (int) strpos($full, '/');
1007            $full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos);
1008        }
1009
1010        // Remove slashes - they don’t get displayed
1011        // $fullNN keeps the @N.N. placeholders, for the database
1012        // $full is for display on-screen
1013        $fullNN = str_replace('/', '', $full);
1014
1015        // Insert placeholders for any missing/unknown names
1016        $full = str_replace(self::NOMEN_NESCIO, I18N::translateContext('Unknown surname', '…'), $full);
1017        $full = str_replace(self::PRAENOMEN_NESCIO, I18N::translateContext('Unknown given name', '…'), $full);
1018        // Format for display
1019        $full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', e($full)) . '</span>';
1020        // Localise quotation marks around the nickname
1021        $full = preg_replace_callback('/&quot;([^&]*)&quot;/', static fn (array $matches): string => '<q class="wt-nickname">' . $matches[1] . '</q>', $full);
1022
1023        // A suffix of “*” indicates a preferred name
1024        $full = preg_replace('/([^ >\x{200C}]*)\*/u', '<span class="starredname">\\1</span>', $full);
1025
1026        // Remove prefered-name indicater - they don’t go in the database
1027        $GIVN   = str_replace('*', '', $GIVN);
1028        $fullNN = str_replace('*', '', $fullNN);
1029
1030        foreach ($SURNS as $SURN) {
1031            // Scottish 'Mc and Mac ' prefixes both sort under 'Mac'
1032            if (strcasecmp(substr($SURN, 0, 2), 'Mc') === 0) {
1033                $SURN = substr_replace($SURN, 'Mac', 0, 2);
1034            } elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') === 0) {
1035                $SURN = substr_replace($SURN, 'Mac', 0, 4);
1036            }
1037
1038            $this->getAllNames[] = [
1039                'type'    => $type,
1040                'sort'    => $SURN . ',' . $GIVN,
1041                'full'    => $full,
1042                // This is used for display
1043                'fullNN'  => $fullNN,
1044                // This goes into the database
1045                'surname' => $surname,
1046                // This goes into the database
1047                'givn'    => $GIVN,
1048                // This goes into the database
1049                'surn'    => $SURN,
1050                // This goes into the database
1051            ];
1052        }
1053    }
1054
1055    /**
1056     * Extract names from the GEDCOM record.
1057     *
1058     * @return void
1059     */
1060    public function extractNames(): void
1061    {
1062        $access_level = $this->canShowName() ? Auth::PRIV_HIDE : Auth::accessLevel($this->tree);
1063
1064        $this->extractNamesFromFacts(
1065            1,
1066            'NAME',
1067            $this->facts(['NAME'], false, $access_level)
1068        );
1069    }
1070
1071    /**
1072     * Extra info to display when displaying this record in a list of
1073     * selection items or favorites.
1074     *
1075     * @return string
1076     */
1077    public function formatListDetails(): string
1078    {
1079        return
1080            $this->formatFirstMajorFact(Gedcom::BIRTH_EVENTS, 1) .
1081            $this->formatFirstMajorFact(Gedcom::DEATH_EVENTS, 1);
1082    }
1083
1084    /**
1085     * Lock the database row, to prevent concurrent edits.
1086     */
1087    public function lock(): void
1088    {
1089        DB::table('individuals')
1090            ->where('i_file', '=', $this->tree->id())
1091            ->where('i_id', '=', $this->xref())
1092            ->lockForUpdate()
1093            ->get();
1094    }
1095}
1096