Individual   F
last analyzed

Complexity

Total Complexity 196

Size/Duplication

Total Lines 1091
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 402
dl 0
loc 1091
rs 2
c 2
b 0
f 0
wmc 196

35 Methods

Rating   Name   Duplication   Size   Complexity  
A getFallBackName() 0 3 1
A findHighlightedMediaFile() 0 14 4
A spouseStepFamilies() 0 18 5
A getAllBirthDates() 0 11 3
A childStepFamilies() 0 15 5
F isDead() 0 90 29
A childFamilies() 0 18 5
A formatListDetails() 0 5 1
D isRelated() 0 66 19
A spouseFamilies() 0 17 5
A getChildFamilyLabel() 0 21 2
A getEstimatedDeathDate() 0 20 6
A birthDateComparator() 0 3 1
A numberOfChildren() 0 14 4
B getStepFamilyLabel() 0 36 9
A getDeathPlace() 0 7 2
D getEstimatedBirthDate() 0 81 23
A lifespan() 0 28 4
A getSpouseFamilyLabel() 0 10 2
A getAllDeathDates() 0 11 3
A lock() 0 7 1
F addName() 0 114 14
A displayImage() 0 13 3
A deathDateComparator() 0 3 1
A getBirthPlace() 0 7 2
C canShowByType() 0 41 14
A getCurrentSpouse() 0 9 2
A canShowName() 0 5 2
A getAllDeathPlaces() 0 11 3
B createPrivateGedcomRecord() 0 25 8
A getDeathDate() 0 9 3
A extractNames() 0 8 2
A sex() 0 7 2
A getBirthDate() 0 9 3
A getAllBirthPlaces() 0 11 3

How to fix   Complexity   

Complex Class

Complex classes like Individual often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Individual, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2023 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 <https://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees;
21
22
use Closure;
23
use Fisharebest\ExtCalendar\GregorianCalendar;
24
use Fisharebest\Webtrees\Contracts\UserInterface;
25
use Fisharebest\Webtrees\Elements\PedigreeLinkageType;
26
use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage;
27
use Illuminate\Support\Collection;
28
29
use function array_key_exists;
30
use function count;
31
use function in_array;
32
use function preg_match;
33
34
/**
35
 * A GEDCOM individual (INDI) object.
36
 */
37
class Individual extends GedcomRecord
38
{
39
    public const RECORD_TYPE = 'INDI';
40
41
    // Placeholders to indicate unknown names
42
    public const NOMEN_NESCIO     = '@N.N.';
43
    public const PRAENOMEN_NESCIO = '@P.N.';
44
45
    protected const ROUTE_NAME = IndividualPage::class;
46
47
    /** Used in some lists to keep track of this individual’s generation in that list */
48
    public int|null $generation = null;
49
50
    private Date|null $estimated_birth_date = null;
51
52
    private Date|null $estimated_death_date = null;
53
54
    /**
55
     * A closure which will compare individuals by birth date.
56
     *
57
     * @return Closure(Individual,Individual):int
58
     */
59
    public static function birthDateComparator(): Closure
60
    {
61
        return static fn (Individual $x, Individual $y): int => Date::compare($x->getEstimatedBirthDate(), $y->getEstimatedBirthDate());
62
    }
63
64
    /**
65
     * A closure which will compare individuals by death date.
66
     *
67
     * @return Closure(Individual,Individual):int
68
     */
69
    public static function deathDateComparator(): Closure
70
    {
71
        return static fn (Individual $x, Individual $y): int => Date::compare($x->getEstimatedDeathDate(), $y->getEstimatedDeathDate());
72
    }
73
74
    /**
75
     * Can the name of this record be shown?
76
     *
77
     * @param int|null $access_level
78
     *
79
     * @return bool
80
     */
81
    public function canShowName(int|null $access_level = null): bool
82
    {
83
        $access_level ??= Auth::accessLevel($this->tree);
84
85
        return (int) $this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level || $this->canShow($access_level);
86
    }
87
88
    /**
89
     * Can this individual be shown?
90
     *
91
     * @param int $access_level
92
     *
93
     * @return bool
94
     */
95
    protected function canShowByType(int $access_level): bool
96
    {
97
        // Dead people...
98
        if ((int) $this->tree->getPreference('SHOW_DEAD_PEOPLE') >= $access_level && $this->isDead()) {
99
            $keep_alive             = false;
100
            $KEEP_ALIVE_YEARS_BIRTH = (int) $this->tree->getPreference('KEEP_ALIVE_YEARS_BIRTH');
101
            if ($KEEP_ALIVE_YEARS_BIRTH !== 0) {
102
                preg_match_all('/\n1 (?:' . implode('|', Gedcom::BIRTH_EVENTS) . ').*(?:\n[2-9].*)*\n2 DATE (.+)/', $this->gedcom, $matches, PREG_SET_ORDER);
103
                foreach ($matches as $match) {
104
                    $date = new Date($match[1]);
105
                    if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_BIRTH > date('Y')) {
106
                        $keep_alive = true;
107
                        break;
108
                    }
109
                }
110
            }
111
            $KEEP_ALIVE_YEARS_DEATH = (int) $this->tree->getPreference('KEEP_ALIVE_YEARS_DEATH');
112
            if ($KEEP_ALIVE_YEARS_DEATH !== 0) {
113
                preg_match_all('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ').*(?:\n[2-9].*)*\n2 DATE (.+)/', $this->gedcom, $matches, PREG_SET_ORDER);
114
                foreach ($matches as $match) {
115
                    $date = new Date($match[1]);
116
                    if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_DEATH > date('Y')) {
117
                        $keep_alive = true;
118
                        break;
119
                    }
120
                }
121
            }
122
            if (!$keep_alive) {
123
                return true;
124
            }
125
        }
126
        // Consider relationship privacy (unless an admin is applying download restrictions)
127
        $user_path_length = (int) $this->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_PATH_LENGTH);
128
        $gedcomid         = $this->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF);
129
130
        if ($gedcomid !== '' && $user_path_length > 0) {
131
            return self::isRelated($this, $user_path_length);
132
        }
133
134
        // No restriction found - show living people to members only:
135
        return Auth::PRIV_USER >= $access_level;
136
    }
137
138
    /**
139
     * For relationship privacy calculations - is this individual a close relative?
140
     *
141
     * @param Individual $target
142
     * @param int        $distance
143
     *
144
     * @return bool
145
     */
146
    private static function isRelated(Individual $target, int $distance): bool
147
    {
148
        static $cache = null;
149
150
        $user_individual = Registry::individualFactory()->make($target->tree->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF), $target->tree);
151
        if ($user_individual instanceof Individual) {
152
            if (!$cache) {
153
                $cache = [
154
                    0 => [$user_individual],
155
                    1 => [],
156
                ];
157
                foreach ($user_individual->facts(['FAMC', 'FAMS'], false, Auth::PRIV_HIDE) as $fact) {
158
                    $family = $fact->target();
159
                    if ($family instanceof Family) {
160
                        $cache[1][] = $family;
161
                    }
162
                }
163
            }
164
        } else {
165
            // No individual linked to this account? Cannot use relationship privacy.
166
            return true;
167
        }
168
169
        // Double the distance, as we count the INDI-FAM and FAM-INDI links separately
170
        $distance *= 2;
171
172
        // Consider each path length in turn
173
        for ($n = 0; $n <= $distance; ++$n) {
174
            if (array_key_exists($n, $cache)) {
175
                // We have already calculated all records with this length
176
                if ($n % 2 === 0 && in_array($target, $cache[$n], true)) {
177
                    return true;
178
                }
179
            } else {
180
                // Need to calculate these paths
181
                $cache[$n] = [];
182
                if ($n % 2 === 0) {
183
                    // Add FAM->INDI links
184
                    foreach ($cache[$n - 1] as $family) {
185
                        foreach ($family->facts(['HUSB', 'WIFE', 'CHIL'], false, Auth::PRIV_HIDE) as $fact) {
186
                            $individual = $fact->target();
187
                            // Don’t backtrack
188
                            if ($individual instanceof self && !in_array($individual, $cache[$n - 2], true)) {
189
                                $cache[$n][] = $individual;
190
                            }
191
                        }
192
                    }
193
                    if (in_array($target, $cache[$n], true)) {
194
                        return true;
195
                    }
196
                } else {
197
                    // Add INDI->FAM links
198
                    foreach ($cache[$n - 1] as $individual) {
199
                        foreach ($individual->facts(['FAMC', 'FAMS'], false, Auth::PRIV_HIDE) as $fact) {
200
                            $family = $fact->target();
201
                            // Don’t backtrack
202
                            if ($family instanceof Family && !in_array($family, $cache[$n - 2], true)) {
203
                                $cache[$n][] = $family;
204
                            }
205
                        }
206
                    }
207
                }
208
            }
209
        }
210
211
        return false;
212
    }
213
214
    /**
215
     * Generate a private version of this record
216
     *
217
     * @param int $access_level
218
     *
219
     * @return string
220
     */
221
    protected function createPrivateGedcomRecord(int $access_level): string
222
    {
223
        $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
224
225
        $rec = '0 @' . $this->xref . '@ INDI';
226
        if ((int) $this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level) {
227
            // Show all the NAME tags, including subtags
228
            foreach ($this->facts(['NAME']) as $fact) {
229
                $rec .= "\n" . $fact->gedcom();
230
            }
231
        }
232
        // Just show the 1 FAMC/FAMS tag, not any subtags, which may contain private data
233
        preg_match_all('/\n1 (?:FAMC|FAMS) @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom, $matches, PREG_SET_ORDER);
234
        foreach ($matches as $match) {
235
            $rela = Registry::familyFactory()->make($match[1], $this->tree);
236
            if ($rela && ($SHOW_PRIVATE_RELATIONSHIPS || $rela->canShow($access_level))) {
237
                $rec .= $match[0];
238
            }
239
        }
240
        // Don’t privatize sex.
241
        if (preg_match('/\n1 SEX [MFU]/', $this->gedcom, $match)) {
242
            $rec .= $match[0];
243
        }
244
245
        return $rec;
246
    }
247
248
    /**
249
     * Calculate whether this individual is living or dead.
250
     * If not known to be dead, then assume living.
251
     *
252
     * @return bool
253
     */
254
    public function isDead(): bool
255
    {
256
        $MAX_ALIVE_AGE = (int) $this->tree->getPreference('MAX_ALIVE_AGE');
257
        $today_jd      = Registry::timestampFactory()->now()->julianDay();
258
259
        // "1 DEAT Y" or "1 DEAT/2 DATE" or "1 DEAT/2 PLAC"
260
        if (preg_match('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ')(?: Y|(?:\n[2-9].+)*\n2 (DATE|PLAC) )/', $this->gedcom)) {
261
            return true;
262
        }
263
264
        // If any event occurred more than $MAX_ALIVE_AGE years ago, then assume the individual is dead
265
        if (preg_match_all('/\n2 DATE (.+)/', $this->gedcom, $date_matches)) {
266
            foreach ($date_matches[1] as $date_match) {
267
                $date = new Date($date_match);
268
                if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * $MAX_ALIVE_AGE) {
269
                    return true;
270
                }
271
            }
272
            // The individual has one or more dated events. All are less than $MAX_ALIVE_AGE years ago.
273
            // If one of these is a birth, the individual must be alive.
274
            if (preg_match('/\n1 BIRT(?:\n[2-9].+)*\n2 DATE /', $this->gedcom)) {
275
                return false;
276
            }
277
        }
278
279
        // If we found no conclusive dates then check the dates of close relatives.
280
281
        // Check parents (birth and adopted)
282
        foreach ($this->childFamilies(Auth::PRIV_HIDE) as $family) {
283
            foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
284
                // Assume parents are no more than 45 years older than their children
285
                preg_match_all('/\n2 DATE (.+)/', $parent->gedcom, $date_matches);
286
                foreach ($date_matches[1] as $date_match) {
287
                    $date = new Date($date_match);
288
                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 45)) {
289
                        return true;
290
                    }
291
                }
292
            }
293
        }
294
295
        // Check spouses
296
        foreach ($this->spouseFamilies(Auth::PRIV_HIDE) as $family) {
297
            preg_match_all('/\n2 DATE (.+)/', $family->gedcom, $date_matches);
298
            foreach ($date_matches[1] as $date_match) {
299
                $date = new Date($date_match);
300
                // Assume marriage occurs after age of 10
301
                if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 10)) {
302
                    return true;
303
                }
304
            }
305
            // Check spouse dates
306
            $spouse = $family->spouse($this, Auth::PRIV_HIDE);
307
            if ($spouse) {
308
                preg_match_all('/\n2 DATE (.+)/', $spouse->gedcom, $date_matches);
309
                foreach ($date_matches[1] as $date_match) {
310
                    $date = new Date($date_match);
311
                    // Assume max age difference between spouses of 40 years
312
                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 40)) {
313
                        return true;
314
                    }
315
                }
316
            }
317
            // Check child dates
318
            foreach ($family->children(Auth::PRIV_HIDE) as $child) {
319
                preg_match_all('/\n2 DATE (.+)/', $child->gedcom, $date_matches);
320
                // Assume children born after age of 15
321
                foreach ($date_matches[1] as $date_match) {
322
                    $date = new Date($date_match);
323
                    if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 15)) {
324
                        return true;
325
                    }
326
                }
327
                // Check grandchildren
328
                foreach ($child->spouseFamilies(Auth::PRIV_HIDE) as $child_family) {
329
                    foreach ($child_family->children(Auth::PRIV_HIDE) as $grandchild) {
330
                        preg_match_all('/\n2 DATE (.+)/', $grandchild->gedcom, $date_matches);
331
                        // Assume grandchildren born after age of 30
332
                        foreach ($date_matches[1] as $date_match) {
333
                            $date = new Date($date_match);
334
                            if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 30)) {
335
                                return true;
336
                            }
337
                        }
338
                    }
339
                }
340
            }
341
        }
342
343
        return false;
344
    }
345
346
    /**
347
     * Find the highlighted media object for an individual
348
     */
349
    public function findHighlightedMediaFile(): MediaFile|null
350
    {
351
        $fact = $this->facts(['OBJE'])
352
            ->first(static function (Fact $fact): bool {
353
                $media = $fact->target();
354
355
                return $media instanceof Media && $media->firstImageFile() instanceof MediaFile;
356
            });
357
358
        if ($fact instanceof Fact && $fact->target() instanceof Media) {
359
            return $fact->target()->firstImageFile();
360
        }
361
362
        return null;
363
    }
364
365
    /**
366
     * Display the preferred image for this individual.
367
     * Use an icon if no image is available.
368
     *
369
     * @param int           $width      Pixels
370
     * @param int           $height     Pixels
371
     * @param string        $fit        "crop" or "contain"
372
     * @param array<string> $attributes Additional HTML attributes
373
     *
374
     * @return string
375
     */
376
    public function displayImage(int $width, int $height, string $fit, array $attributes): string
377
    {
378
        $media_file = $this->findHighlightedMediaFile();
379
380
        if ($media_file !== null) {
381
            return $media_file->displayImage($width, $height, $fit, $attributes);
382
        }
383
384
        if ($this->tree->getPreference('USE_SILHOUETTE') === '1') {
385
            return '<i class="icon-silhouette icon-silhouette-' . strtolower($this->sex()) . ' wt-icon-flip-rtl"></i>';
386
        }
387
388
        return '';
389
    }
390
391
    /**
392
     * Get the date of birth
393
     *
394
     * @return Date
395
     */
396
    public function getBirthDate(): Date
397
    {
398
        foreach ($this->getAllBirthDates() as $date) {
399
            if ($date->isOK()) {
400
                return $date;
401
            }
402
        }
403
404
        return new Date('');
405
    }
406
407
    /**
408
     * Get the place of birth
409
     *
410
     * @return Place
411
     */
412
    public function getBirthPlace(): Place
413
    {
414
        foreach ($this->getAllBirthPlaces() as $place) {
415
            return $place;
416
        }
417
418
        return new Place('', $this->tree);
419
    }
420
421
    /**
422
     * Get the date of death
423
     *
424
     * @return Date
425
     */
426
    public function getDeathDate(): Date
427
    {
428
        foreach ($this->getAllDeathDates() as $date) {
429
            if ($date->isOK()) {
430
                return $date;
431
            }
432
        }
433
434
        return new Date('');
435
    }
436
437
    /**
438
     * Get the place of death
439
     *
440
     * @return Place
441
     */
442
    public function getDeathPlace(): Place
443
    {
444
        foreach ($this->getAllDeathPlaces() as $place) {
445
            return $place;
446
        }
447
448
        return new Place('', $this->tree);
449
    }
450
451
    /**
452
     * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”.
453
     * Provide the place and full date using a tooltip.
454
     * For consistent layout in charts, etc., show just a “–” when no dates are known.
455
     * Note that this is a (non-breaking) en-dash, and not a hyphen.
456
     *
457
     * @return string
458
     */
459
    public function lifespan(): string
460
    {
461
        // Just the first part of the place name.
462
        $birth_place = strip_tags($this->getBirthPlace()->shortName());
463
        $death_place = strip_tags($this->getDeathPlace()->shortName());
464
465
        // Remove markup from dates.  Use UTF_FSI / UTF_PDI instead of <bdi></bdi>, as
466
        // we cannot use HTML markup in title attributes.
467
        $birth_date = "\u{2068}" . strip_tags($this->getBirthDate()->display()) . "\u{2069}";
468
        $death_date = "\u{2068}" . strip_tags($this->getDeathDate()->display()) . "\u{2069}";
469
470
        // Use minimum and maximum dates - to agree with the age calculations.
471
        $birth_year = $this->getBirthDate()->minimumDate()->format('%Y');
472
        $death_year = $this->getDeathDate()->maximumDate()->format('%Y');
473
474
        if ($birth_year === '') {
475
            $birth_year = I18N::translate('…');
476
        }
477
478
        if ($death_year === '' && $this->isDead()) {
479
            $death_year = I18N::translate('…');
480
        }
481
482
        /* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */
483
        return I18N::translate(
484
            '%1$s–%2$s',
485
            '<span title="' . $birth_place . ' ' . $birth_date . '">' . $birth_year . '</span>',
486
            '<span title="' . $death_place . ' ' . $death_date . '">' . $death_year . '</span>'
487
        );
488
    }
489
490
    /**
491
     * Get all the birth dates - for the individual lists.
492
     *
493
     * @return array<Date>
494
     */
495
    public function getAllBirthDates(): array
496
    {
497
        foreach (Gedcom::BIRTH_EVENTS as $event) {
498
            $dates = $this->getAllEventDates([$event]);
499
500
            if ($dates !== []) {
501
                return $dates;
502
            }
503
        }
504
505
        return [];
506
    }
507
508
    /**
509
     * Gat all the birth places - for the individual lists.
510
     *
511
     * @return array<Place>
512
     */
513
    public function getAllBirthPlaces(): array
514
    {
515
        foreach (Gedcom::BIRTH_EVENTS as $event) {
516
            $places = $this->getAllEventPlaces([$event]);
517
518
            if ($places !== []) {
519
                return $places;
520
            }
521
        }
522
523
        return [];
524
    }
525
526
    /**
527
     * Get all the death dates - for the individual lists.
528
     *
529
     * @return array<Date>
530
     */
531
    public function getAllDeathDates(): array
532
    {
533
        foreach (Gedcom::DEATH_EVENTS as $event) {
534
            $dates = $this->getAllEventDates([$event]);
535
536
            if ($dates !== []) {
537
                return $dates;
538
            }
539
        }
540
541
        return [];
542
    }
543
544
    /**
545
     * Get all the death places - for the individual lists.
546
     *
547
     * @return array<Place>
548
     */
549
    public function getAllDeathPlaces(): array
550
    {
551
        foreach (Gedcom::DEATH_EVENTS as $event) {
552
            $places = $this->getAllEventPlaces([$event]);
553
554
            if ($places !== []) {
555
                return $places;
556
            }
557
        }
558
559
        return [];
560
    }
561
562
    /**
563
     * Generate an estimate for the date of birth, based on dates of parents/children/spouses
564
     *
565
     * @return Date
566
     */
567
    public function getEstimatedBirthDate(): Date
568
    {
569
        if ($this->estimated_birth_date === null) {
570
            foreach ($this->getAllBirthDates() as $date) {
571
                if ($date->isOK()) {
572
                    $this->estimated_birth_date = $date;
573
                    break;
574
                }
575
            }
576
            if ($this->estimated_birth_date === null) {
577
                $min = [];
578
                $max = [];
579
                $tmp = $this->getDeathDate();
580
                if ($tmp->isOK()) {
581
                    $min[] = $tmp->minimumJulianDay() - 365 * (int) $this->tree->getPreference('MAX_ALIVE_AGE');
582
                    $max[] = $tmp->maximumJulianDay();
583
                }
584
                foreach ($this->childFamilies() as $family) {
585
                    $tmp = $family->getMarriageDate();
586
                    if ($tmp->isOK()) {
587
                        $min[] = $tmp->maximumJulianDay() - 365;
588
                        $max[] = $tmp->minimumJulianDay() + 365 * 30;
589
                    }
590
                    $husband = $family->husband();
591
                    if ($husband instanceof self) {
592
                        $tmp = $husband->getBirthDate();
593
                        if ($tmp->isOK()) {
594
                            $min[] = $tmp->maximumJulianDay() + 365 * 15;
595
                            $max[] = $tmp->minimumJulianDay() + 365 * 65;
596
                        }
597
                    }
598
                    $wife = $family->wife();
599
                    if ($wife instanceof self) {
600
                        $tmp = $wife->getBirthDate();
601
                        if ($tmp->isOK()) {
602
                            $min[] = $tmp->maximumJulianDay() + 365 * 15;
603
                            $max[] = $tmp->minimumJulianDay() + 365 * 45;
604
                        }
605
                    }
606
                    foreach ($family->children() as $child) {
607
                        $tmp = $child->getBirthDate();
608
                        if ($tmp->isOK()) {
609
                            $min[] = $tmp->maximumJulianDay() - 365 * 30;
610
                            $max[] = $tmp->minimumJulianDay() + 365 * 30;
611
                        }
612
                    }
613
                }
614
                foreach ($this->spouseFamilies() as $family) {
615
                    $tmp = $family->getMarriageDate();
616
                    if ($tmp->isOK()) {
617
                        $min[] = $tmp->maximumJulianDay() - 365 * 45;
618
                        $max[] = $tmp->minimumJulianDay() - 365 * 15;
619
                    }
620
                    $spouse = $family->spouse($this);
621
                    if ($spouse) {
622
                        $tmp = $spouse->getBirthDate();
623
                        if ($tmp->isOK()) {
624
                            $min[] = $tmp->maximumJulianDay() - 365 * 25;
625
                            $max[] = $tmp->minimumJulianDay() + 365 * 25;
626
                        }
627
                    }
628
                    foreach ($family->children() as $child) {
629
                        $tmp = $child->getBirthDate();
630
                        if ($tmp->isOK()) {
631
                            $min[] = $tmp->maximumJulianDay() - 365 * ($this->sex() === 'F' ? 45 : 65);
632
                            $max[] = $tmp->minimumJulianDay() - 365 * 15;
633
                        }
634
                    }
635
                }
636
                if ($min && $max) {
637
                    $gregorian_calendar = new GregorianCalendar();
638
639
                    [$year] = $gregorian_calendar->jdToYmd(intdiv(max($min) + min($max), 2));
640
                    $this->estimated_birth_date = new Date('EST ' . $year);
641
                } else {
642
                    $this->estimated_birth_date = new Date(''); // always return a date object
643
                }
644
            }
645
        }
646
647
        return $this->estimated_birth_date;
648
    }
649
650
    /**
651
     * Generate an estimated date of death.
652
     *
653
     * @return Date
654
     */
655
    public function getEstimatedDeathDate(): Date
656
    {
657
        if ($this->estimated_death_date === null) {
658
            foreach ($this->getAllDeathDates() as $date) {
659
                if ($date->isOK()) {
660
                    $this->estimated_death_date = $date;
661
                    break;
662
                }
663
            }
664
            if ($this->estimated_death_date === null) {
665
                if ($this->getEstimatedBirthDate()->minimumJulianDay() !== 0) {
666
                    $max_alive_age              = (int) $this->tree->getPreference('MAX_ALIVE_AGE');
667
                    $this->estimated_death_date = $this->getEstimatedBirthDate()->addYears($max_alive_age, 'BEF');
668
                } else {
669
                    $this->estimated_death_date = new Date(''); // always return a date object
670
                }
671
            }
672
        }
673
674
        return $this->estimated_death_date;
675
    }
676
677
    /**
678
     * Get the sex - M F or U
679
     * Use the un-privatised gedcom record. We call this function during
680
     * the privatize-gedcom function, and we are allowed to know this.
681
     *
682
     * @return string
683
     */
684
    public function sex(): string
685
    {
686
        if (preg_match('/\n1 SEX ([MFX])/', $this->gedcom . $this->pending, $match)) {
687
            return $match[1];
688
        }
689
690
        return 'U';
691
    }
692
693
    /**
694
     * Get a list of this individual’s spouse families
695
     *
696
     * @param int|null $access_level
697
     *
698
     * @return Collection<int,Family>
699
     */
700
    public function spouseFamilies(int|null $access_level = null): Collection
701
    {
702
        $access_level ??= Auth::accessLevel($this->tree);
703
704
        if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') {
705
            $access_level = Auth::PRIV_HIDE;
706
        }
707
708
        $families = new Collection();
709
        foreach ($this->facts(['FAMS'], false, $access_level) as $fact) {
710
            $family = $fact->target();
711
            if ($family instanceof Family && $family->canShow($access_level)) {
712
                $families->push($family);
713
            }
714
        }
715
716
        return new Collection($families);
717
    }
718
719
    /**
720
     * Get the current spouse of this individual.
721
     *
722
     * Where an individual has multiple spouses, assume they are stored
723
     * in chronological order, and take the last one found.
724
     *
725
     * @return Individual|null
726
     */
727
    public function getCurrentSpouse(): Individual|null
728
    {
729
        $family = $this->spouseFamilies()->last();
730
731
        if ($family instanceof Family) {
732
            return $family->spouse($this);
733
        }
734
735
        return null;
736
    }
737
738
    /**
739
     * Count the children belonging to this individual.
740
     *
741
     * @return int
742
     */
743
    public function numberOfChildren(): int
744
    {
745
        if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->gedcom(), $match)) {
746
            return (int) $match[1];
747
        }
748
749
        $children = [];
750
        foreach ($this->spouseFamilies() as $fam) {
751
            foreach ($fam->children() as $child) {
752
                $children[$child->xref()] = true;
753
            }
754
        }
755
756
        return count($children);
757
    }
758
759
    /**
760
     * Get a list of this individual’s child families (i.e. their parents).
761
     *
762
     * @param int|null $access_level
763
     *
764
     * @return Collection<int,Family>
765
     */
766
    public function childFamilies(int|null $access_level = null): Collection
767
    {
768
        $access_level ??= Auth::accessLevel($this->tree);
769
770
        if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') {
771
            $access_level = Auth::PRIV_HIDE;
772
        }
773
774
        $families = new Collection();
775
776
        foreach ($this->facts(['FAMC'], false, $access_level) as $fact) {
777
            $family = $fact->target();
778
            if ($family instanceof Family && $family->canShow($access_level)) {
779
                $families->push($family);
780
            }
781
        }
782
783
        return $families;
784
    }
785
786
    /**
787
     * Get a list of step-parent families.
788
     *
789
     * @return Collection<int,Family>
790
     */
791
    public function childStepFamilies(): Collection
792
    {
793
        $step_families = new Collection();
794
        $families      = $this->childFamilies();
795
        foreach ($families as $family) {
796
            foreach ($family->spouses() as $parent) {
797
                foreach ($parent->spouseFamilies() as $step_family) {
798
                    if (!$families->containsStrict($step_family)) {
799
                        $step_families->add($step_family);
800
                    }
801
                }
802
            }
803
        }
804
805
        return $step_families->uniqueStrict(static fn (Family $family): string => $family->xref());
806
    }
807
808
    /**
809
     * Get a list of step-parent families.
810
     *
811
     * @return Collection<int,Family>
812
     */
813
    public function spouseStepFamilies(): Collection
814
    {
815
        $step_families = [];
816
        $families      = $this->spouseFamilies();
817
818
        foreach ($families as $family) {
819
            $spouse = $family->spouse($this);
820
821
            if ($spouse instanceof self) {
822
                foreach ($family->spouse($this)->spouseFamilies() as $step_family) {
823
                    if (!$families->containsStrict($step_family)) {
824
                        $step_families[] = $step_family;
825
                    }
826
                }
827
            }
828
        }
829
830
        return new Collection($step_families);
0 ignored issues
show
Bug introduced by
$step_families of type array is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $items of Illuminate\Support\Collection::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

830
        return new Collection(/** @scrutinizer ignore-type */ $step_families);
Loading history...
831
    }
832
833
    /**
834
     * A label for a parental family group
835
     *
836
     * @param Family $family
837
     *
838
     * @return string
839
     */
840
    public function getChildFamilyLabel(Family $family): string
841
    {
842
        $fact = $this->facts(['FAMC'])->first(static fn (Fact $fact): bool => $fact->target() === $family);
843
844
        if ($fact instanceof Fact) {
845
            $pedigree = $fact->attribute('PEDI');
846
        } else {
847
            $pedigree = '';
848
        }
849
850
        $values = [
851
            PedigreeLinkageType::VALUE_BIRTH   => I18N::translate('Family with parents'),
852
            PedigreeLinkageType::VALUE_ADOPTED => I18N::translate('Family with adoptive parents'),
853
            PedigreeLinkageType::VALUE_FOSTER  => I18N::translate('Family with foster parents'),
854
            /* I18N: “sealing” is a Mormon ceremony. */
855
            PedigreeLinkageType::VALUE_SEALING => I18N::translate('Family with sealing parents'),
856
            /* I18N: “rada” is an Arabic word, pronounced “ra DAH”. It is child-to-parent pedigree, established by wet-nursing. */
857
            PedigreeLinkageType::VALUE_RADA    => I18N::translate('Family with rada parents'),
858
        ];
859
860
        return $values[$pedigree] ?? $values[PedigreeLinkageType::VALUE_BIRTH];
861
    }
862
863
    /**
864
     * Create a label for a step family
865
     *
866
     * @param Family $step_family
867
     *
868
     * @return string
869
     */
870
    public function getStepFamilyLabel(Family $step_family): string
871
    {
872
        foreach ($this->childFamilies() as $family) {
873
            if ($family !== $step_family) {
874
                // Must be a step-family
875
                foreach ($family->spouses() as $parent) {
876
                    foreach ($step_family->spouses() as $step_parent) {
877
                        if ($parent === $step_parent) {
878
                            // One common parent - must be a step family
879
                            if ($parent->sex() === 'M') {
880
                                // Father’s family with someone else
881
                                if ($step_family->spouse($step_parent) instanceof Individual) {
882
                                    /* I18N: A step-family. %s is an individual’s name */
883
                                    return I18N::translate('Father’s family with %s', $step_family->spouse($step_parent)->fullName());
884
                                }
885
886
                                /* I18N: A step-family. */
887
                                return I18N::translate('Father’s family with an unknown individual');
888
                            }
889
890
                            // Mother’s family with someone else
891
                            if ($step_family->spouse($step_parent) instanceof Individual) {
892
                                /* I18N: A step-family. %s is an individual’s name */
893
                                return I18N::translate('Mother’s family with %s', $step_family->spouse($step_parent)->fullName());
894
                            }
895
896
                            /* I18N: A step-family. */
897
                            return I18N::translate('Mother’s family with an unknown individual');
898
                        }
899
                    }
900
                }
901
            }
902
        }
903
904
        // Perahps same parents - but a different family record?
905
        return I18N::translate('Family with parents');
906
    }
907
908
    /**
909
     * Get the description for the family.
910
     *
911
     * For example, "XXX's family with new wife".
912
     *
913
     * @param Family $family
914
     *
915
     * @return string
916
     */
917
    public function getSpouseFamilyLabel(Family $family): string
918
    {
919
        $spouse = $family->spouse($this);
920
921
        if ($spouse instanceof Individual) {
922
            /* I18N: %s is the spouse name */
923
            return I18N::translate('Family with %s', $spouse->fullName());
924
        }
925
926
        return $family->fullName();
927
    }
928
929
    /**
930
     * If this object has no name, what do we call it?
931
     *
932
     * @return string
933
     */
934
    public function getFallBackName(): string
935
    {
936
        return '@P.N. /@N.N./';
937
    }
938
939
    /**
940
     * Convert a name record into ‘full’ and ‘sort’ versions.
941
     * Use the NAME field to generate the ‘full’ version, as the
942
     * gedcom spec says that this is the individual’s name, as they would write it.
943
     * Use the SURN field to generate the sortable names. Note that this field
944
     * may also be used for the ‘true’ surname, perhaps spelt differently to that
945
     * recorded in the NAME field. e.g.
946
     *
947
     * 1 NAME Robert /de Gliderow/
948
     * 2 GIVN Robert
949
     * 2 SPFX de
950
     * 2 SURN CLITHEROW
951
     * 2 NICK The Bald
952
     *
953
     * full=>'Robert de Gliderow 'The Bald''
954
     * sort=>'CLITHEROW, ROBERT'
955
     *
956
     * Handle multiple surnames, either as;
957
     *
958
     * 1 NAME Carlos /Vasquez/ y /Sante/
959
     * or
960
     * 1 NAME Carlos /Vasquez y Sante/
961
     * 2 GIVN Carlos
962
     * 2 SURN Vasquez,Sante
963
     *
964
     * @param string $type
965
     * @param string $value
966
     * @param string $gedcom
967
     *
968
     * @return void
969
     */
970
    protected function addName(string $type, string $value, string $gedcom): void
971
    {
972
        ////////////////////////////////////////////////////////////////////////////
973
        // Extract the structured name parts - use for "sortable" names and indexes
974
        ////////////////////////////////////////////////////////////////////////////
975
976
        $sublevel = 1 + (int) substr($gedcom, 0, 1);
977
        $GIVN     = preg_match('/\n' . $sublevel . ' GIVN (.+)/', $gedcom, $match) === 1 ? $match[1] : '';
978
        $SURN     = preg_match('/\n' . $sublevel . ' SURN (.+)/', $gedcom, $match) === 1 ? $match[1] : '';
979
980
        // SURN is an comma-separated list of surnames...
981
        if ($SURN !== '') {
982
            $SURNS = preg_split('/ *, */', $SURN);
983
        } else {
984
            $SURNS = [];
985
        }
986
987
        // ...so is GIVN - but nobody uses it like that
988
        $GIVN = str_replace('/ *, */', ' ', $GIVN);
989
990
        ////////////////////////////////////////////////////////////////////////////
991
        // Extract the components from NAME - use for the "full" names
992
        ////////////////////////////////////////////////////////////////////////////
993
994
        // Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/'
995
        if (substr_count($value, '/') % 2 === 1) {
996
            $value .= '/';
997
        }
998
999
        // GEDCOM uses "//" to indicate an unknown surname
1000
        $full = preg_replace('/\/\//', '/@N.N./', $value);
1001
1002
        // Extract the surname.
1003
        // Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/
1004
        if (preg_match('/\/.*\//', $full, $match)) {
1005
            $surname = str_replace('/', '', $match[0]);
1006
        } else {
1007
            $surname = '';
1008
        }
1009
1010
        // If we don’t have a SURN record, extract it from the NAME
1011
        if (!$SURNS) {
1012
            if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) {
1013
                // There can be many surnames, each wrapped with '/'
1014
                $SURNS = $matches[1];
1015
                foreach ($SURNS as $n => $SURN) {
1016
                    // Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only)
1017
                    $SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN);
1018
                }
1019
            } else {
1020
                // It is valid not to have a surname at all
1021
                $SURNS = [''];
1022
            }
1023
        }
1024
1025
        // If we don’t have a GIVN record, extract it from the NAME
1026
        if (!$GIVN) {
1027
            // remove surname
1028
            $GIVN = preg_replace('/ ?\/.*\/ ?/', ' ', $full);
1029
            // remove nickname
1030
            $GIVN = preg_replace('/ ?".+"/', ' ', $GIVN);
1031
            // multiple spaces, caused by the above
1032
            $GIVN = preg_replace('/ {2,}/', ' ', $GIVN);
1033
            // leading/trailing spaces, caused by the above
1034
            $GIVN = preg_replace('/^ | $/', '', $GIVN);
1035
        }
1036
1037
        // Add placeholder for unknown given name
1038
        if (!$GIVN) {
1039
            $GIVN = self::PRAENOMEN_NESCIO;
1040
            $pos  = (int) strpos($full, '/');
1041
            $full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos);
1042
        }
1043
1044
        // Remove slashes - they don’t get displayed
1045
        // $fullNN keeps the @N.N. placeholders, for the database
1046
        // $full is for display on-screen
1047
        $fullNN = str_replace('/', '', $full);
1048
1049
        // Insert placeholders for any missing/unknown names
1050
        $full = str_replace(self::NOMEN_NESCIO, I18N::translateContext('Unknown surname', '…'), $full);
1051
        $full = str_replace(self::PRAENOMEN_NESCIO, I18N::translateContext('Unknown given name', '…'), $full);
1052
        // Format for display
1053
        $full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', e($full)) . '</span>';
1054
        // Localise quotation marks around the nickname
1055
        $full = preg_replace_callback('/&quot;([^&]*)&quot;/', static fn (array $matches): string => '<q class="wt-nickname">' . $matches[1] . '</q>', $full);
1056
1057
        // A suffix of “*” indicates a preferred name
1058
        $full = preg_replace('/([^ >\x{200C}]*)\*/u', '<span class="starredname">\\1</span>', $full);
1059
1060
        // Remove prefered-name indicater - they don’t go in the database
1061
        $GIVN   = str_replace('*', '', $GIVN);
1062
        $fullNN = str_replace('*', '', $fullNN);
1063
1064
        foreach ($SURNS as $SURN) {
1065
            // Scottish 'Mc and Mac ' prefixes both sort under 'Mac'
1066
            if (strcasecmp(substr($SURN, 0, 2), 'Mc') === 0) {
1067
                $SURN = substr_replace($SURN, 'Mac', 0, 2);
1068
            } elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') === 0) {
1069
                $SURN = substr_replace($SURN, 'Mac', 0, 4);
1070
            }
1071
1072
            $this->getAllNames[] = [
1073
                'type'    => $type,
1074
                'sort'    => $SURN . ',' . $GIVN,
0 ignored issues
show
Bug introduced by
Are you sure $SURN of type array|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1074
                'sort'    => /** @scrutinizer ignore-type */ $SURN . ',' . $GIVN,
Loading history...
1075
                'full'    => $full,
1076
                // This is used for display
1077
                'fullNN'  => $fullNN,
1078
                // This goes into the database
1079
                'surname' => $surname,
1080
                // This goes into the database
1081
                'givn'    => $GIVN,
1082
                // This goes into the database
1083
                'surn'    => $SURN,
1084
                // This goes into the database
1085
            ];
1086
        }
1087
    }
1088
1089
    /**
1090
     * Extract names from the GEDCOM record.
1091
     *
1092
     * @return void
1093
     */
1094
    public function extractNames(): void
1095
    {
1096
        $access_level = $this->canShowName() ? Auth::PRIV_HIDE : Auth::accessLevel($this->tree);
1097
1098
        $this->extractNamesFromFacts(
1099
            1,
1100
            'NAME',
1101
            $this->facts(['NAME'], false, $access_level)
1102
        );
1103
    }
1104
1105
    /**
1106
     * Extra info to display when displaying this record in a list of
1107
     * selection items or favorites.
1108
     *
1109
     * @return string
1110
     */
1111
    public function formatListDetails(): string
1112
    {
1113
        return
1114
            $this->formatFirstMajorFact(Gedcom::BIRTH_EVENTS, 1) .
1115
            $this->formatFirstMajorFact(Gedcom::DEATH_EVENTS, 1);
1116
    }
1117
1118
    /**
1119
     * Lock the database row, to prevent concurrent edits.
1120
     */
1121
    public function lock(): void
1122
    {
1123
        DB::table('individuals')
1124
            ->where('i_file', '=', $this->tree->id())
1125
            ->where('i_id', '=', $this->xref())
1126
            ->lockForUpdate()
1127
            ->get();
1128
    }
1129
}
1130