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