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