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