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