xref: /webtrees/app/Individual.php (revision fcfa5390e0212e053854eb4fa4c263c9f6fb1e21)
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): ?self
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        if ($access_level === null) {
154            $access_level = Auth::accessLevel($this->tree);
155        }
156
157        return $this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level || $this->canShow($access_level);
158    }
159
160    /**
161     * Can this individual be shown?
162     *
163     * @param int $access_level
164     *
165     * @return bool
166     */
167    protected function canShowByType(int $access_level): bool
168    {
169        // Dead people...
170        if ($this->tree->getPreference('SHOW_DEAD_PEOPLE') >= $access_level && $this->isDead()) {
171            $keep_alive             = false;
172            $KEEP_ALIVE_YEARS_BIRTH = (int) $this->tree->getPreference('KEEP_ALIVE_YEARS_BIRTH');
173            if ($KEEP_ALIVE_YEARS_BIRTH) {
174                preg_match_all('/\n1 (?:' . implode('|', Gedcom::BIRTH_EVENTS) . ').*(?:\n[2-9].*)*(?:\n2 DATE (.+))/', $this->gedcom, $matches, PREG_SET_ORDER);
175                foreach ($matches as $match) {
176                    $date = new Date($match[1]);
177                    if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_BIRTH > date('Y')) {
178                        $keep_alive = true;
179                        break;
180                    }
181                }
182            }
183            $KEEP_ALIVE_YEARS_DEATH = (int) $this->tree->getPreference('KEEP_ALIVE_YEARS_DEATH');
184            if ($KEEP_ALIVE_YEARS_DEATH) {
185                preg_match_all('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ').*(?:\n[2-9].*)*(?:\n2 DATE (.+))/', $this->gedcom, $matches, PREG_SET_ORDER);
186                foreach ($matches as $match) {
187                    $date = new Date($match[1]);
188                    if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_DEATH > date('Y')) {
189                        $keep_alive = true;
190                        break;
191                    }
192                }
193            }
194            if (!$keep_alive) {
195                return true;
196            }
197        }
198        // Consider relationship privacy (unless an admin is applying download restrictions)
199        $user_path_length = (int) $this->tree->getUserPreference(Auth::user(), User::PREF_TREE_PATH_LENGTH);
200        $gedcomid         = $this->tree->getUserPreference(Auth::user(), User::PREF_TREE_ACCOUNT_XREF);
201
202        if ($gedcomid !== '' && $user_path_length > 0) {
203            return self::isRelated($this, $user_path_length);
204        }
205
206        // No restriction found - show living people to members only:
207        return Auth::PRIV_USER >= $access_level;
208    }
209
210    /**
211     * For relationship privacy calculations - is this individual a close relative?
212     *
213     * @param Individual $target
214     * @param int        $distance
215     *
216     * @return bool
217     */
218    private static function isRelated(Individual $target, $distance): bool
219    {
220        static $cache = null;
221
222        $user_individual = self::getInstance($target->tree->getUserPreference(Auth::user(), User::PREF_TREE_ACCOUNT_XREF), $target->tree);
223        if ($user_individual) {
224            if (!$cache) {
225                $cache = [
226                    0 => [$user_individual],
227                    1 => [],
228                ];
229                foreach ($user_individual->facts(['FAMC', 'FAMS'], false, Auth::PRIV_HIDE) as $fact) {
230                    $family = $fact->target();
231                    if ($family instanceof Family) {
232                        $cache[1][] = $family;
233                    }
234                }
235            }
236        } else {
237            // No individual linked to this account? Cannot use relationship privacy.
238            return true;
239        }
240
241        // Double the distance, as we count the INDI-FAM and FAM-INDI links separately
242        $distance *= 2;
243
244        // Consider each path length in turn
245        for ($n = 0; $n <= $distance; ++$n) {
246            if (array_key_exists($n, $cache)) {
247                // We have already calculated all records with this length
248                if ($n % 2 === 0 && in_array($target, $cache[$n], true)) {
249                    return true;
250                }
251            } else {
252                // Need to calculate these paths
253                $cache[$n] = [];
254                if ($n % 2 === 0) {
255                    // Add FAM->INDI links
256                    foreach ($cache[$n - 1] as $family) {
257                        foreach ($family->facts(['HUSB', 'WIFE', 'CHIL'], false, Auth::PRIV_HIDE) as $fact) {
258                            $individual = $fact->target();
259                            // Don’t backtrack
260                            if ($individual instanceof self && !in_array($individual, $cache[$n - 2], true)) {
261                                $cache[$n][] = $individual;
262                            }
263                        }
264                    }
265                    if (in_array($target, $cache[$n], true)) {
266                        return true;
267                    }
268                } else {
269                    // Add INDI->FAM links
270                    foreach ($cache[$n - 1] as $individual) {
271                        foreach ($individual->facts(['FAMC', 'FAMS'], false, Auth::PRIV_HIDE) as $fact) {
272                            $family = $fact->target();
273                            // Don’t backtrack
274                            if ($family instanceof Family && !in_array($family, $cache[$n - 2], true)) {
275                                $cache[$n][] = $family;
276                            }
277                        }
278                    }
279                }
280            }
281        }
282
283        return false;
284    }
285
286    /**
287     * Generate a private version of this record
288     *
289     * @param int $access_level
290     *
291     * @return string
292     */
293    protected function createPrivateGedcomRecord(int $access_level): string
294    {
295        $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
296
297        $rec = '0 @' . $this->xref . '@ INDI';
298        if ($this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level) {
299            // Show all the NAME tags, including subtags
300            foreach ($this->facts(['NAME']) as $fact) {
301                $rec .= "\n" . $fact->gedcom();
302            }
303        }
304        // Just show the 1 FAMC/FAMS tag, not any subtags, which may contain private data
305        preg_match_all('/\n1 (?:FAMC|FAMS) @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom, $matches, PREG_SET_ORDER);
306        foreach ($matches as $match) {
307            $rela = Family::getInstance($match[1], $this->tree);
308            if ($rela && ($SHOW_PRIVATE_RELATIONSHIPS || $rela->canShow($access_level))) {
309                $rec .= $match[0];
310            }
311        }
312        // Don’t privatize sex.
313        if (preg_match('/\n1 SEX [MFU]/', $this->gedcom, $match)) {
314            $rec .= $match[0];
315        }
316
317        return $rec;
318    }
319
320    /**
321     * Fetch data from the database
322     *
323     * @param string $xref
324     * @param int    $tree_id
325     *
326     * @return string|null
327     */
328    protected static function fetchGedcomRecord(string $xref, int $tree_id): ?string
329    {
330        return DB::table('individuals')
331            ->where('i_id', '=', $xref)
332            ->where('i_file', '=', $tree_id)
333            ->value('i_gedcom');
334    }
335
336    /**
337     * Calculate whether this individual is living or dead.
338     * If not known to be dead, then assume living.
339     *
340     * @return bool
341     */
342    public function isDead(): bool
343    {
344        $MAX_ALIVE_AGE = (int) $this->tree->getPreference('MAX_ALIVE_AGE');
345        $today_jd      = Carbon::now()->julianDay();
346
347        // "1 DEAT Y" or "1 DEAT/2 DATE" or "1 DEAT/2 PLAC"
348        if (preg_match('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ')(?: Y|(?:\n[2-9].+)*\n2 (DATE|PLAC) )/', $this->gedcom)) {
349            return true;
350        }
351
352        // If any event occured more than $MAX_ALIVE_AGE years ago, then assume the individual is dead
353        if (preg_match_all('/\n2 DATE (.+)/', $this->gedcom, $date_matches)) {
354            foreach ($date_matches[1] as $date_match) {
355                $date = new Date($date_match);
356                if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * $MAX_ALIVE_AGE) {
357                    return true;
358                }
359            }
360            // The individual has one or more dated events. All are less than $MAX_ALIVE_AGE years ago.
361            // If one of these is a birth, the individual must be alive.
362            if (preg_match('/\n1 BIRT(?:\n[2-9].+)*\n2 DATE /', $this->gedcom)) {
363                return false;
364            }
365        }
366
367        // If we found no conclusive dates then check the dates of close relatives.
368
369        // Check parents (birth and adopted)
370        foreach ($this->childFamilies(Auth::PRIV_HIDE) as $family) {
371            foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
372                // Assume parents are no more than 45 years older than their children
373                preg_match_all('/\n2 DATE (.+)/', $parent->gedcom, $date_matches);
374                foreach ($date_matches[1] as $date_match) {
375                    $date = new Date($date_match);
376                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 45)) {
377                        return true;
378                    }
379                }
380            }
381        }
382
383        // Check spouses
384        foreach ($this->spouseFamilies(Auth::PRIV_HIDE) as $family) {
385            preg_match_all('/\n2 DATE (.+)/', $family->gedcom, $date_matches);
386            foreach ($date_matches[1] as $date_match) {
387                $date = new Date($date_match);
388                // Assume marriage occurs after age of 10
389                if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 10)) {
390                    return true;
391                }
392            }
393            // Check spouse dates
394            $spouse = $family->spouse($this, Auth::PRIV_HIDE);
395            if ($spouse) {
396                preg_match_all('/\n2 DATE (.+)/', $spouse->gedcom, $date_matches);
397                foreach ($date_matches[1] as $date_match) {
398                    $date = new Date($date_match);
399                    // Assume max age difference between spouses of 40 years
400                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 40)) {
401                        return true;
402                    }
403                }
404            }
405            // Check child dates
406            foreach ($family->children(Auth::PRIV_HIDE) as $child) {
407                preg_match_all('/\n2 DATE (.+)/', $child->gedcom, $date_matches);
408                // Assume children born after age of 15
409                foreach ($date_matches[1] as $date_match) {
410                    $date = new Date($date_match);
411                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 15)) {
412                        return true;
413                    }
414                }
415                // Check grandchildren
416                foreach ($child->spouseFamilies(Auth::PRIV_HIDE) as $child_family) {
417                    foreach ($child_family->children(Auth::PRIV_HIDE) as $grandchild) {
418                        preg_match_all('/\n2 DATE (.+)/', $grandchild->gedcom, $date_matches);
419                        // Assume grandchildren born after age of 30
420                        foreach ($date_matches[1] as $date_match) {
421                            $date = new Date($date_match);
422                            if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 30)) {
423                                return true;
424                            }
425                        }
426                    }
427                }
428            }
429        }
430
431        return false;
432    }
433
434    /**
435     * Find the highlighted media object for an individual
436     *
437     * @return MediaFile|null
438     */
439    public function findHighlightedMediaFile(): ?MediaFile
440    {
441        foreach ($this->facts(['OBJE']) as $fact) {
442            $media = $fact->target();
443            if ($media instanceof Media) {
444                foreach ($media->mediaFiles() as $media_file) {
445                    if ($media_file->isImage() && !$media_file->isExternal()) {
446                        return $media_file;
447                    }
448                }
449            }
450        }
451
452        return null;
453    }
454
455    /**
456     * Display the prefered image for this individual.
457     * Use an icon if no image is available.
458     *
459     * @param int      $width      Pixels
460     * @param int      $height     Pixels
461     * @param string   $fit        "crop" or "contain"
462     * @param string[] $attributes Additional HTML attributes
463     *
464     * @return string
465     */
466    public function displayImage($width, $height, $fit, $attributes): string
467    {
468        $media_file = $this->findHighlightedMediaFile();
469
470        if ($media_file !== null) {
471            return $media_file->displayImage($width, $height, $fit, $attributes);
472        }
473
474        if ($this->tree->getPreference('USE_SILHOUETTE')) {
475            return '<i class="icon-silhouette-' . $this->sex() . '"></i>';
476        }
477
478        return '';
479    }
480
481    /**
482     * Get the date of birth
483     *
484     * @return Date
485     */
486    public function getBirthDate(): Date
487    {
488        foreach ($this->getAllBirthDates() as $date) {
489            if ($date->isOK()) {
490                return $date;
491            }
492        }
493
494        return new Date('');
495    }
496
497    /**
498     * Get the place of birth
499     *
500     * @return Place
501     */
502    public function getBirthPlace(): Place
503    {
504        foreach ($this->getAllBirthPlaces() as $place) {
505            return $place;
506        }
507
508        return new Place('', $this->tree);
509    }
510
511    /**
512     * Get the year of birth
513     *
514     * @return string the year of birth
515     */
516    public function getBirthYear(): string
517    {
518        return $this->getBirthDate()->minimumDate()->format('%Y');
519    }
520
521    /**
522     * Get the date of death
523     *
524     * @return Date
525     */
526    public function getDeathDate(): Date
527    {
528        foreach ($this->getAllDeathDates() as $date) {
529            if ($date->isOK()) {
530                return $date;
531            }
532        }
533
534        return new Date('');
535    }
536
537    /**
538     * Get the place of death
539     *
540     * @return Place
541     */
542    public function getDeathPlace(): Place
543    {
544        foreach ($this->getAllDeathPlaces() as $place) {
545            return $place;
546        }
547
548        return new Place('', $this->tree);
549    }
550
551    /**
552     * get the death year
553     *
554     * @return string the year of death
555     */
556    public function getDeathYear(): string
557    {
558        return $this->getDeathDate()->minimumDate()->format('%Y');
559    }
560
561    /**
562     * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”.
563     * Provide the place and full date using a tooltip.
564     * For consistent layout in charts, etc., show just a “–” when no dates are known.
565     * Note that this is a (non-breaking) en-dash, and not a hyphen.
566     *
567     * @return string
568     */
569    public function getLifeSpan(): string
570    {
571        // Just the first part of the place name
572        $birth_place = strip_tags($this->getBirthPlace()->shortName());
573        $death_place = strip_tags($this->getDeathPlace()->shortName());
574        // Remove markup from dates
575        $birth_date = strip_tags($this->getBirthDate()->display());
576        $death_date = strip_tags($this->getDeathDate()->display());
577
578        /* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */
579        return I18N::translate(
580            '%1$s–%2$s',
581            '<span title="' . $birth_place . ' ' . $birth_date . '">' . $this->getBirthYear() . '</span>',
582            '<span title="' . $death_place . ' ' . $death_date . '">' . $this->getDeathYear() . '</span>'
583        );
584    }
585
586    /**
587     * Get all the birth dates - for the individual lists.
588     *
589     * @return Date[]
590     */
591    public function getAllBirthDates(): array
592    {
593        foreach (Gedcom::BIRTH_EVENTS as $event) {
594            $tmp = $this->getAllEventDates([$event]);
595            if ($tmp) {
596                return $tmp;
597            }
598        }
599
600        return [];
601    }
602
603    /**
604     * Gat all the birth places - for the individual lists.
605     *
606     * @return Place[]
607     */
608    public function getAllBirthPlaces(): array
609    {
610        foreach (Gedcom::BIRTH_EVENTS as $event) {
611            $places = $this->getAllEventPlaces([$event]);
612            if ($places !== []) {
613                return $places;
614            }
615        }
616
617        return [];
618    }
619
620    /**
621     * Get all the death dates - for the individual lists.
622     *
623     * @return Date[]
624     */
625    public function getAllDeathDates(): array
626    {
627        foreach (Gedcom::DEATH_EVENTS as $event) {
628            $tmp = $this->getAllEventDates([$event]);
629            if ($tmp) {
630                return $tmp;
631            }
632        }
633
634        return [];
635    }
636
637    /**
638     * Get all the death places - for the individual lists.
639     *
640     * @return Place[]
641     */
642    public function getAllDeathPlaces(): array
643    {
644        foreach (Gedcom::DEATH_EVENTS as $event) {
645            $places = $this->getAllEventPlaces([$event]);
646            if ($places !== []) {
647                return $places;
648            }
649        }
650
651        return [];
652    }
653
654    /**
655     * Generate an estimate for the date of birth, based on dates of parents/children/spouses
656     *
657     * @return Date
658     */
659    public function getEstimatedBirthDate(): Date
660    {
661        if ($this->estimated_birth_date === null) {
662            foreach ($this->getAllBirthDates() as $date) {
663                if ($date->isOK()) {
664                    $this->estimated_birth_date = $date;
665                    break;
666                }
667            }
668            if ($this->estimated_birth_date === null) {
669                $min = [];
670                $max = [];
671                $tmp = $this->getDeathDate();
672                if ($tmp->isOK()) {
673                    $min[] = $tmp->minimumJulianDay() - $this->tree->getPreference('MAX_ALIVE_AGE') * 365;
674                    $max[] = $tmp->maximumJulianDay();
675                }
676                foreach ($this->childFamilies() as $family) {
677                    $tmp = $family->getMarriageDate();
678                    if ($tmp->isOK()) {
679                        $min[] = $tmp->maximumJulianDay() - 365 * 1;
680                        $max[] = $tmp->minimumJulianDay() + 365 * 30;
681                    }
682                    $husband = $family->husband();
683                    if ($husband instanceof self) {
684                        $tmp = $husband->getBirthDate();
685                        if ($tmp->isOK()) {
686                            $min[] = $tmp->maximumJulianDay() + 365 * 15;
687                            $max[] = $tmp->minimumJulianDay() + 365 * 65;
688                        }
689                    }
690                    $wife = $family->wife();
691                    if ($wife instanceof self) {
692                        $tmp = $wife->getBirthDate();
693                        if ($tmp->isOK()) {
694                            $min[] = $tmp->maximumJulianDay() + 365 * 15;
695                            $max[] = $tmp->minimumJulianDay() + 365 * 45;
696                        }
697                    }
698                    foreach ($family->children() as $child) {
699                        $tmp = $child->getBirthDate();
700                        if ($tmp->isOK()) {
701                            $min[] = $tmp->maximumJulianDay() - 365 * 30;
702                            $max[] = $tmp->minimumJulianDay() + 365 * 30;
703                        }
704                    }
705                }
706                foreach ($this->spouseFamilies() as $family) {
707                    $tmp = $family->getMarriageDate();
708                    if ($tmp->isOK()) {
709                        $min[] = $tmp->maximumJulianDay() - 365 * 45;
710                        $max[] = $tmp->minimumJulianDay() - 365 * 15;
711                    }
712                    $spouse = $family->spouse($this);
713                    if ($spouse) {
714                        $tmp = $spouse->getBirthDate();
715                        if ($tmp->isOK()) {
716                            $min[] = $tmp->maximumJulianDay() - 365 * 25;
717                            $max[] = $tmp->minimumJulianDay() + 365 * 25;
718                        }
719                    }
720                    foreach ($family->children() as $child) {
721                        $tmp = $child->getBirthDate();
722                        if ($tmp->isOK()) {
723                            $min[] = $tmp->maximumJulianDay() - 365 * ($this->sex() === 'F' ? 45 : 65);
724                            $max[] = $tmp->minimumJulianDay() - 365 * 15;
725                        }
726                    }
727                }
728                if ($min && $max) {
729                    $gregorian_calendar = new GregorianCalendar();
730
731                    [$year] = $gregorian_calendar->jdToYmd(intdiv(max($min) + min($max), 2));
732                    $this->estimated_birth_date = new Date('EST ' . $year);
733                } else {
734                    $this->estimated_birth_date = new Date(''); // always return a date object
735                }
736            }
737        }
738
739        return $this->estimated_birth_date;
740    }
741
742    /**
743     * Generate an estimated date of death.
744     *
745     * @return Date
746     */
747    public function getEstimatedDeathDate(): Date
748    {
749        if ($this->estimated_death_date === null) {
750            foreach ($this->getAllDeathDates() as $date) {
751                if ($date->isOK()) {
752                    $this->estimated_death_date = $date;
753                    break;
754                }
755            }
756            if ($this->estimated_death_date === null) {
757                if ($this->getEstimatedBirthDate()->minimumJulianDay()) {
758                    $max_alive_age              = (int) $this->tree->getPreference('MAX_ALIVE_AGE');
759                    $this->estimated_death_date = $this->getEstimatedBirthDate()->addYears($max_alive_age, 'BEF');
760                } else {
761                    $this->estimated_death_date = new Date(''); // always return a date object
762                }
763            }
764        }
765
766        return $this->estimated_death_date;
767    }
768
769    /**
770     * Get the sex - M F or U
771     * Use the un-privatised gedcom record. We call this function during
772     * the privatize-gedcom function, and we are allowed to know this.
773     *
774     * @return string
775     */
776    public function sex(): string
777    {
778        if (preg_match('/\n1 SEX ([MF])/', $this->gedcom . $this->pending, $match)) {
779            return $match[1];
780        }
781
782        return 'U';
783    }
784
785    /**
786     * Generate the CSS class to be used for drawing this individual
787     *
788     * @return string
789     */
790    public function getBoxStyle(): string
791    {
792        $tmp = [
793            'M' => '',
794            'F' => 'F',
795            'U' => 'NN',
796        ];
797
798        return 'person_box' . $tmp[$this->sex()];
799    }
800
801    /**
802     * Get a list of this individual’s spouse families
803     *
804     * @param int|null $access_level
805     *
806     * @return Collection
807     */
808    public function spouseFamilies($access_level = null): Collection
809    {
810        if ($access_level === null) {
811            $access_level = Auth::accessLevel($this->tree);
812        }
813
814        $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
815
816        $families = new Collection();
817        foreach ($this->facts(['FAMS'], false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) {
818            $family = $fact->target();
819            if ($family instanceof Family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) {
820                $families->push($family);
821            }
822        }
823
824        return new Collection($families);
825    }
826
827    /**
828     * Get the current spouse of this individual.
829     *
830     * Where an individual has multiple spouses, assume they are stored
831     * in chronological order, and take the last one found.
832     *
833     * @return Individual|null
834     */
835    public function getCurrentSpouse(): ?Individual
836    {
837        $family = $this->spouseFamilies()->last();
838
839        if ($family instanceof Family) {
840            return $family->spouse($this);
841        }
842
843        return null;
844    }
845
846    /**
847     * Count the children belonging to this individual.
848     *
849     * @return int
850     */
851    public function numberOfChildren(): int
852    {
853        if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->gedcom(), $match)) {
854            return (int) $match[1];
855        }
856
857        $children = [];
858        foreach ($this->spouseFamilies() as $fam) {
859            foreach ($fam->children() as $child) {
860                $children[$child->xref()] = true;
861            }
862        }
863
864        return count($children);
865    }
866
867    /**
868     * Get a list of this individual’s child families (i.e. their parents).
869     *
870     * @param int|null $access_level
871     *
872     * @return Collection
873     */
874    public function childFamilies($access_level = null): Collection
875    {
876        if ($access_level === null) {
877            $access_level = Auth::accessLevel($this->tree);
878        }
879
880        $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
881
882        $families = new Collection();
883
884        foreach ($this->facts(['FAMC'], false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) {
885            $family = $fact->target();
886            if ($family instanceof Family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) {
887                $families->push($family);
888            }
889        }
890
891        return $families;
892    }
893
894    /**
895     * Get a list of step-parent families.
896     *
897     * @return Collection
898     */
899    public function childStepFamilies(): Collection
900    {
901        $step_families = new Collection();
902        $families      = $this->childFamilies();
903        foreach ($families as $family) {
904            foreach ($family->spouses() as $parent) {
905                foreach ($parent->spouseFamilies() as $step_family) {
906                    if (!$families->containsStrict($step_family)) {
907                        $step_families->add($step_family);
908                    }
909                }
910            }
911        }
912
913        return $step_families->unique();
914    }
915
916    /**
917     * Get a list of step-parent families.
918     *
919     * @return Collection
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) {
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     * get primary parents names for this individual
1026     *
1027     * @param string $classname optional css class
1028     * @param string $display   optional css style display
1029     *
1030     * @return string a div block with father & mother names
1031     */
1032    public function getPrimaryParentsNames($classname = '', $display = ''): string
1033    {
1034        $fam = $this->childFamilies()->first();
1035        if (!$fam) {
1036            return '';
1037        }
1038        $txt = '<div';
1039        if ($classname) {
1040            $txt .= ' class="' . $classname . '"';
1041        }
1042        if ($display) {
1043            $txt .= ' style="display:' . $display . '"';
1044        }
1045        $txt .= '>';
1046        $husb = $fam->husband();
1047        if ($husb) {
1048            // Temporarily reset the 'prefered' display name, as we always
1049            // want the default name, not the one selected for display on the indilist.
1050            $primary = $husb->getPrimaryName();
1051            $husb->setPrimaryName(null);
1052            /* I18N: %s is the name of an individual’s father */
1053            $txt .= I18N::translate('Father: %s', $husb->fullName()) . '<br>';
1054            $husb->setPrimaryName($primary);
1055        }
1056        $wife = $fam->wife();
1057        if ($wife) {
1058            // Temporarily reset the 'prefered' display name, as we always
1059            // want the default name, not the one selected for display on the indilist.
1060            $primary = $wife->getPrimaryName();
1061            $wife->setPrimaryName(null);
1062            /* I18N: %s is the name of an individual’s mother */
1063            $txt .= I18N::translate('Mother: %s', $wife->fullName());
1064            $wife->setPrimaryName($primary);
1065        }
1066        $txt .= '</div>';
1067
1068        return $txt;
1069    }
1070
1071    /**
1072     * If this object has no name, what do we call it?
1073     *
1074     * @return string
1075     */
1076    public function getFallBackName(): string
1077    {
1078        return '@P.N. /@N.N./';
1079    }
1080
1081    /**
1082     * Convert a name record into ‘full’ and ‘sort’ versions.
1083     * Use the NAME field to generate the ‘full’ version, as the
1084     * gedcom spec says that this is the individual’s name, as they would write it.
1085     * Use the SURN field to generate the sortable names. Note that this field
1086     * may also be used for the ‘true’ surname, perhaps spelt differently to that
1087     * recorded in the NAME field. e.g.
1088     *
1089     * 1 NAME Robert /de Gliderow/
1090     * 2 GIVN Robert
1091     * 2 SPFX de
1092     * 2 SURN CLITHEROW
1093     * 2 NICK The Bald
1094     *
1095     * full=>'Robert de Gliderow 'The Bald''
1096     * sort=>'CLITHEROW, ROBERT'
1097     *
1098     * Handle multiple surnames, either as;
1099     *
1100     * 1 NAME Carlos /Vasquez/ y /Sante/
1101     * or
1102     * 1 NAME Carlos /Vasquez y Sante/
1103     * 2 GIVN Carlos
1104     * 2 SURN Vasquez,Sante
1105     *
1106     * @param string $type
1107     * @param string $full
1108     * @param string $gedcom
1109     *
1110     * @return void
1111     */
1112    protected function addName(string $type, string $full, string $gedcom): void
1113    {
1114        ////////////////////////////////////////////////////////////////////////////
1115        // Extract the structured name parts - use for "sortable" names and indexes
1116        ////////////////////////////////////////////////////////////////////////////
1117
1118        $sublevel = 1 + (int) substr($gedcom, 0, 1);
1119        $GIVN     = preg_match("/\n{$sublevel} GIVN (.+)/", $gedcom, $match) ? $match[1] : '';
1120        $SURN     = preg_match("/\n{$sublevel} SURN (.+)/", $gedcom, $match) ? $match[1] : '';
1121        $NICK     = preg_match("/\n{$sublevel} NICK (.+)/", $gedcom, $match) ? $match[1] : '';
1122
1123        // SURN is an comma-separated list of surnames...
1124        if ($SURN !== '') {
1125            $SURNS = preg_split('/ *, */', $SURN);
1126        } else {
1127            $SURNS = [];
1128        }
1129
1130        // ...so is GIVN - but nobody uses it like that
1131        $GIVN = str_replace('/ *, */', ' ', $GIVN);
1132
1133        ////////////////////////////////////////////////////////////////////////////
1134        // Extract the components from NAME - use for the "full" names
1135        ////////////////////////////////////////////////////////////////////////////
1136
1137        // Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/'
1138        if (substr_count($full, '/') % 2 === 1) {
1139            $full .= '/';
1140        }
1141
1142        // GEDCOM uses "//" to indicate an unknown surname
1143        $full = preg_replace('/\/\//', '/@N.N./', $full);
1144
1145        // Extract the surname.
1146        // Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/
1147        if (preg_match('/\/.*\//', $full, $match)) {
1148            $surname = str_replace('/', '', $match[0]);
1149        } else {
1150            $surname = '';
1151        }
1152
1153        // If we don’t have a SURN record, extract it from the NAME
1154        if (!$SURNS) {
1155            if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) {
1156                // There can be many surnames, each wrapped with '/'
1157                $SURNS = $matches[1];
1158                foreach ($SURNS as $n => $SURN) {
1159                    // Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only)
1160                    $SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN);
1161                }
1162            } else {
1163                // It is valid not to have a surname at all
1164                $SURNS = [''];
1165            }
1166        }
1167
1168        // If we don’t have a GIVN record, extract it from the NAME
1169        if (!$GIVN) {
1170            $GIVN = preg_replace(
1171                [
1172                    '/ ?\/.*\/ ?/',
1173                    // remove surname
1174                    '/ ?".+"/',
1175                    // remove nickname
1176                    '/ {2,}/',
1177                    // multiple spaces, caused by the above
1178                    '/^ | $/',
1179                    // leading/trailing spaces, caused by the above
1180                ],
1181                [
1182                    ' ',
1183                    ' ',
1184                    ' ',
1185                    '',
1186                ],
1187                $full
1188            );
1189        }
1190
1191        // Add placeholder for unknown given name
1192        if (!$GIVN) {
1193            $GIVN = '@P.N.';
1194            $pos  = (int) strpos($full, '/');
1195            $full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos);
1196        }
1197
1198        // GEDCOM 5.5.1 nicknames should be specificied in a NICK field
1199        // GEDCOM 5.5   nicknames should be specified in the NAME field, surrounded by quotes
1200        if ($NICK && strpos($full, '"' . $NICK . '"') === false) {
1201            // A NICK field is present, but not included in the NAME.  Show it at the end.
1202            $full .= ' "' . $NICK . '"';
1203        }
1204
1205        // Remove slashes - they don’t get displayed
1206        // $fullNN keeps the @N.N. placeholders, for the database
1207        // $full is for display on-screen
1208        $fullNN = str_replace('/', '', $full);
1209
1210        // Insert placeholders for any missing/unknown names
1211        $full = str_replace('@N.N.', I18N::translateContext('Unknown surname', '…'), $full);
1212        $full = str_replace('@P.N.', I18N::translateContext('Unknown given name', '…'), $full);
1213        // Format for display
1214        $full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', e($full)) . '</span>';
1215        // Localise quotation marks around the nickname
1216        $full = preg_replace_callback('/&quot;([^&]*)&quot;/', static function (array $matches): string {
1217            return I18N::translate('“%s”', $matches[1]);
1218        }, $full);
1219
1220        // A suffix of “*” indicates a preferred name
1221        $full = preg_replace('/([^ >]*)\*/', '<span class="starredname">\\1</span>', $full);
1222
1223        // Remove prefered-name indicater - they don’t go in the database
1224        $GIVN   = str_replace('*', '', $GIVN);
1225        $fullNN = str_replace('*', '', $fullNN);
1226
1227        foreach ($SURNS as $SURN) {
1228            // Scottish 'Mc and Mac ' prefixes both sort under 'Mac'
1229            if (strcasecmp(substr($SURN, 0, 2), 'Mc') === 0) {
1230                $SURN = substr_replace($SURN, 'Mac', 0, 2);
1231            } elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') === 0) {
1232                $SURN = substr_replace($SURN, 'Mac', 0, 4);
1233            }
1234
1235            $this->getAllNames[] = [
1236                'type'    => $type,
1237                'sort'    => $SURN . ',' . $GIVN,
1238                'full'    => $full,
1239                // This is used for display
1240                'fullNN'  => $fullNN,
1241                // This goes into the database
1242                'surname' => $surname,
1243                // This goes into the database
1244                'givn'    => $GIVN,
1245                // This goes into the database
1246                'surn'    => $SURN,
1247                // This goes into the database
1248            ];
1249        }
1250    }
1251
1252    /**
1253     * Extract names from the GEDCOM record.
1254     *
1255     * @return void
1256     */
1257    public function extractNames(): void
1258    {
1259        $this->extractNamesFromFacts(
1260            1,
1261            'NAME',
1262            $this->facts(
1263                ['NAME'],
1264                false,
1265                Auth::accessLevel($this->tree),
1266                $this->canShowName()
1267            )
1268        );
1269    }
1270
1271    /**
1272     * Extra info to display when displaying this record in a list of
1273     * selection items or favorites.
1274     *
1275     * @return string
1276     */
1277    public function formatListDetails(): string
1278    {
1279        return
1280            $this->formatFirstMajorFact(Gedcom::BIRTH_EVENTS, 1) .
1281            $this->formatFirstMajorFact(Gedcom::DEATH_EVENTS, 1);
1282    }
1283}
1284