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