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