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