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