Passed
Push — master ( 5d0fff...4843b9 )
by Greg
09:06
created

Individual::lifespan()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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