Passed
Push — master ( 8435e0...1afbbc )
by Greg
10:39
created

Individual::primaryChildFamily()   B

Complexity

Conditions 9
Paths 17

Size

Total Lines 34
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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