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 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees; 21 22use Closure; 23use Fisharebest\ExtCalendar\GregorianCalendar; 24use Fisharebest\Webtrees\Contracts\UserInterface; 25use Fisharebest\Webtrees\Elements\PedigreeLinkageType; 26use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage; 27use Illuminate\Support\Collection; 28 29use function array_key_exists; 30use function count; 31use function in_array; 32use function preg_match; 33 34/** 35 * A GEDCOM individual (INDI) object. 36 */ 37class 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 $generation = null; 49 50 private ?Date $estimated_birth_date = null; 51 52 private ?Date $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 * @return MediaFile|null 350 */ 351 public function findHighlightedMediaFile(): ?MediaFile 352 { 353 $fact = $this->facts(['OBJE']) 354 ->first(static function (Fact $fact): bool { 355 $media = $fact->target(); 356 357 return $media instanceof Media && $media->firstImageFile() instanceof MediaFile; 358 }); 359 360 if ($fact instanceof Fact && $fact->target() instanceof Media) { 361 return $fact->target()->firstImageFile(); 362 } 363 364 return null; 365 } 366 367 /** 368 * Display the preferred image for this individual. 369 * Use an icon if no image is available. 370 * 371 * @param int $width Pixels 372 * @param int $height Pixels 373 * @param string $fit "crop" or "contain" 374 * @param array<string> $attributes Additional HTML attributes 375 * 376 * @return string 377 */ 378 public function displayImage(int $width, int $height, string $fit, array $attributes): string 379 { 380 $media_file = $this->findHighlightedMediaFile(); 381 382 if ($media_file !== null) { 383 return $media_file->displayImage($width, $height, $fit, $attributes); 384 } 385 386 if ($this->tree->getPreference('USE_SILHOUETTE') === '1') { 387 return '<i class="icon-silhouette icon-silhouette-' . strtolower($this->sex()) . ' wt-icon-flip-rtl"></i>'; 388 } 389 390 return ''; 391 } 392 393 /** 394 * Get the date of birth 395 * 396 * @return Date 397 */ 398 public function getBirthDate(): Date 399 { 400 foreach ($this->getAllBirthDates() as $date) { 401 if ($date->isOK()) { 402 return $date; 403 } 404 } 405 406 return new Date(''); 407 } 408 409 /** 410 * Get the place of birth 411 * 412 * @return Place 413 */ 414 public function getBirthPlace(): Place 415 { 416 foreach ($this->getAllBirthPlaces() as $place) { 417 return $place; 418 } 419 420 return new Place('', $this->tree); 421 } 422 423 /** 424 * Get the date of death 425 * 426 * @return Date 427 */ 428 public function getDeathDate(): Date 429 { 430 foreach ($this->getAllDeathDates() as $date) { 431 if ($date->isOK()) { 432 return $date; 433 } 434 } 435 436 return new Date(''); 437 } 438 439 /** 440 * Get the place of death 441 * 442 * @return Place 443 */ 444 public function getDeathPlace(): Place 445 { 446 foreach ($this->getAllDeathPlaces() as $place) { 447 return $place; 448 } 449 450 return new Place('', $this->tree); 451 } 452 453 /** 454 * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”. 455 * Provide the place and full date using a tooltip. 456 * For consistent layout in charts, etc., show just a “–” when no dates are known. 457 * Note that this is a (non-breaking) en-dash, and not a hyphen. 458 * 459 * @return string 460 */ 461 public function lifespan(): string 462 { 463 // Just the first part of the place name. 464 $birth_place = strip_tags($this->getBirthPlace()->shortName()); 465 $death_place = strip_tags($this->getDeathPlace()->shortName()); 466 467 // Remove markup from dates. Use UTF_FSI / UTF_PDI instead of <bdi></bdi>, as 468 // we cannot use HTML markup in title attributes. 469 $birth_date = "\u{2068}" . strip_tags($this->getBirthDate()->display()) . "\u{2069}"; 470 $death_date = "\u{2068}" . strip_tags($this->getDeathDate()->display()) . "\u{2069}"; 471 472 // Use minimum and maximum dates - to agree with the age calculations. 473 $birth_year = $this->getBirthDate()->minimumDate()->format('%Y'); 474 $death_year = $this->getDeathDate()->maximumDate()->format('%Y'); 475 476 if ($birth_year === '') { 477 $birth_year = I18N::translate('…'); 478 } 479 480 if ($death_year === '' && $this->isDead()) { 481 $death_year = I18N::translate('…'); 482 } 483 484 /* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */ 485 return I18N::translate( 486 '%1$s–%2$s', 487 '<span title="' . $birth_place . ' ' . $birth_date . '">' . $birth_year . '</span>', 488 '<span title="' . $death_place . ' ' . $death_date . '">' . $death_year . '</span>' 489 ); 490 } 491 492 /** 493 * Get all the birth dates - for the individual lists. 494 * 495 * @return array<Date> 496 */ 497 public function getAllBirthDates(): array 498 { 499 foreach (Gedcom::BIRTH_EVENTS as $event) { 500 $dates = $this->getAllEventDates([$event]); 501 502 if ($dates !== []) { 503 return $dates; 504 } 505 } 506 507 return []; 508 } 509 510 /** 511 * Gat all the birth places - for the individual lists. 512 * 513 * @return array<Place> 514 */ 515 public function getAllBirthPlaces(): array 516 { 517 foreach (Gedcom::BIRTH_EVENTS as $event) { 518 $places = $this->getAllEventPlaces([$event]); 519 520 if ($places !== []) { 521 return $places; 522 } 523 } 524 525 return []; 526 } 527 528 /** 529 * Get all the death dates - for the individual lists. 530 * 531 * @return array<Date> 532 */ 533 public function getAllDeathDates(): array 534 { 535 foreach (Gedcom::DEATH_EVENTS as $event) { 536 $dates = $this->getAllEventDates([$event]); 537 538 if ($dates !== []) { 539 return $dates; 540 } 541 } 542 543 return []; 544 } 545 546 /** 547 * Get all the death places - for the individual lists. 548 * 549 * @return array<Place> 550 */ 551 public function getAllDeathPlaces(): array 552 { 553 foreach (Gedcom::DEATH_EVENTS as $event) { 554 $places = $this->getAllEventPlaces([$event]); 555 556 if ($places !== []) { 557 return $places; 558 } 559 } 560 561 return []; 562 } 563 564 /** 565 * Generate an estimate for the date of birth, based on dates of parents/children/spouses 566 * 567 * @return Date 568 */ 569 public function getEstimatedBirthDate(): Date 570 { 571 if ($this->estimated_birth_date === null) { 572 foreach ($this->getAllBirthDates() as $date) { 573 if ($date->isOK()) { 574 $this->estimated_birth_date = $date; 575 break; 576 } 577 } 578 if ($this->estimated_birth_date === null) { 579 $min = []; 580 $max = []; 581 $tmp = $this->getDeathDate(); 582 if ($tmp->isOK()) { 583 $min[] = $tmp->minimumJulianDay() - 365 * (int) $this->tree->getPreference('MAX_ALIVE_AGE'); 584 $max[] = $tmp->maximumJulianDay(); 585 } 586 foreach ($this->childFamilies() as $family) { 587 $tmp = $family->getMarriageDate(); 588 if ($tmp->isOK()) { 589 $min[] = $tmp->maximumJulianDay() - 365; 590 $max[] = $tmp->minimumJulianDay() + 365 * 30; 591 } 592 $husband = $family->husband(); 593 if ($husband instanceof self) { 594 $tmp = $husband->getBirthDate(); 595 if ($tmp->isOK()) { 596 $min[] = $tmp->maximumJulianDay() + 365 * 15; 597 $max[] = $tmp->minimumJulianDay() + 365 * 65; 598 } 599 } 600 $wife = $family->wife(); 601 if ($wife instanceof self) { 602 $tmp = $wife->getBirthDate(); 603 if ($tmp->isOK()) { 604 $min[] = $tmp->maximumJulianDay() + 365 * 15; 605 $max[] = $tmp->minimumJulianDay() + 365 * 45; 606 } 607 } 608 foreach ($family->children() as $child) { 609 $tmp = $child->getBirthDate(); 610 if ($tmp->isOK()) { 611 $min[] = $tmp->maximumJulianDay() - 365 * 30; 612 $max[] = $tmp->minimumJulianDay() + 365 * 30; 613 } 614 } 615 } 616 foreach ($this->spouseFamilies() as $family) { 617 $tmp = $family->getMarriageDate(); 618 if ($tmp->isOK()) { 619 $min[] = $tmp->maximumJulianDay() - 365 * 45; 620 $max[] = $tmp->minimumJulianDay() - 365 * 15; 621 } 622 $spouse = $family->spouse($this); 623 if ($spouse) { 624 $tmp = $spouse->getBirthDate(); 625 if ($tmp->isOK()) { 626 $min[] = $tmp->maximumJulianDay() - 365 * 25; 627 $max[] = $tmp->minimumJulianDay() + 365 * 25; 628 } 629 } 630 foreach ($family->children() as $child) { 631 $tmp = $child->getBirthDate(); 632 if ($tmp->isOK()) { 633 $min[] = $tmp->maximumJulianDay() - 365 * ($this->sex() === 'F' ? 45 : 65); 634 $max[] = $tmp->minimumJulianDay() - 365 * 15; 635 } 636 } 637 } 638 if ($min && $max) { 639 $gregorian_calendar = new GregorianCalendar(); 640 641 [$year] = $gregorian_calendar->jdToYmd(intdiv(max($min) + min($max), 2)); 642 $this->estimated_birth_date = new Date('EST ' . $year); 643 } else { 644 $this->estimated_birth_date = new Date(''); // always return a date object 645 } 646 } 647 } 648 649 return $this->estimated_birth_date; 650 } 651 652 /** 653 * Generate an estimated date of death. 654 * 655 * @return Date 656 */ 657 public function getEstimatedDeathDate(): Date 658 { 659 if ($this->estimated_death_date === null) { 660 foreach ($this->getAllDeathDates() as $date) { 661 if ($date->isOK()) { 662 $this->estimated_death_date = $date; 663 break; 664 } 665 } 666 if ($this->estimated_death_date === null) { 667 if ($this->getEstimatedBirthDate()->minimumJulianDay() !== 0) { 668 $max_alive_age = (int) $this->tree->getPreference('MAX_ALIVE_AGE'); 669 $this->estimated_death_date = $this->getEstimatedBirthDate()->addYears($max_alive_age, 'BEF'); 670 } else { 671 $this->estimated_death_date = new Date(''); // always return a date object 672 } 673 } 674 } 675 676 return $this->estimated_death_date; 677 } 678 679 /** 680 * Get the sex - M F or U 681 * Use the un-privatised gedcom record. We call this function during 682 * the privatize-gedcom function, and we are allowed to know this. 683 * 684 * @return string 685 */ 686 public function sex(): string 687 { 688 if (preg_match('/\n1 SEX ([MFX])/', $this->gedcom . $this->pending, $match)) { 689 return $match[1]; 690 } 691 692 return 'U'; 693 } 694 695 /** 696 * Get a list of this individual’s spouse families 697 * 698 * @param int|null $access_level 699 * 700 * @return Collection<int,Family> 701 */ 702 public function spouseFamilies(int|null $access_level = null): Collection 703 { 704 $access_level ??= Auth::accessLevel($this->tree); 705 706 if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') { 707 $access_level = Auth::PRIV_HIDE; 708 } 709 710 $families = new Collection(); 711 foreach ($this->facts(['FAMS'], false, $access_level) as $fact) { 712 $family = $fact->target(); 713 if ($family instanceof Family && $family->canShow($access_level)) { 714 $families->push($family); 715 } 716 } 717 718 return new Collection($families); 719 } 720 721 /** 722 * Get the current spouse of this individual. 723 * 724 * Where an individual has multiple spouses, assume they are stored 725 * in chronological order, and take the last one found. 726 * 727 * @return Individual|null 728 */ 729 public function getCurrentSpouse(): ?Individual 730 { 731 $family = $this->spouseFamilies()->last(); 732 733 if ($family instanceof Family) { 734 return $family->spouse($this); 735 } 736 737 return null; 738 } 739 740 /** 741 * Count the children belonging to this individual. 742 * 743 * @return int 744 */ 745 public function numberOfChildren(): int 746 { 747 if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->gedcom(), $match)) { 748 return (int) $match[1]; 749 } 750 751 $children = []; 752 foreach ($this->spouseFamilies() as $fam) { 753 foreach ($fam->children() as $child) { 754 $children[$child->xref()] = true; 755 } 756 } 757 758 return count($children); 759 } 760 761 /** 762 * Get a list of this individual’s child families (i.e. their parents). 763 * 764 * @param int|null $access_level 765 * 766 * @return Collection<int,Family> 767 */ 768 public function childFamilies(int|null $access_level = null): Collection 769 { 770 $access_level ??= Auth::accessLevel($this->tree); 771 772 if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') { 773 $access_level = Auth::PRIV_HIDE; 774 } 775 776 $families = new Collection(); 777 778 foreach ($this->facts(['FAMC'], false, $access_level) as $fact) { 779 $family = $fact->target(); 780 if ($family instanceof Family && $family->canShow($access_level)) { 781 $families->push($family); 782 } 783 } 784 785 return $families; 786 } 787 788 /** 789 * Get a list of step-parent families. 790 * 791 * @return Collection<int,Family> 792 */ 793 public function childStepFamilies(): Collection 794 { 795 $step_families = new Collection(); 796 $families = $this->childFamilies(); 797 foreach ($families as $family) { 798 foreach ($family->spouses() as $parent) { 799 foreach ($parent->spouseFamilies() as $step_family) { 800 if (!$families->containsStrict($step_family)) { 801 $step_families->add($step_family); 802 } 803 } 804 } 805 } 806 807 return $step_families->uniqueStrict(static fn(Family $family): string => $family->xref()); 808 } 809 810 /** 811 * Get a list of step-parent families. 812 * 813 * @return Collection<int,Family> 814 */ 815 public function spouseStepFamilies(): Collection 816 { 817 $step_families = []; 818 $families = $this->spouseFamilies(); 819 820 foreach ($families as $family) { 821 $spouse = $family->spouse($this); 822 823 if ($spouse instanceof self) { 824 foreach ($family->spouse($this)->spouseFamilies() as $step_family) { 825 if (!$families->containsStrict($step_family)) { 826 $step_families[] = $step_family; 827 } 828 } 829 } 830 } 831 832 return new Collection($step_families); 833 } 834 835 /** 836 * A label for a parental family group 837 * 838 * @param Family $family 839 * 840 * @return string 841 */ 842 public function getChildFamilyLabel(Family $family): string 843 { 844 $fact = $this->facts(['FAMC'])->first(static fn (Fact $fact): bool => $fact->target() === $family); 845 846 if ($fact instanceof Fact) { 847 $pedigree = $fact->attribute('PEDI'); 848 } else { 849 $pedigree = ''; 850 } 851 852 $values = [ 853 PedigreeLinkageType::VALUE_BIRTH => I18N::translate('Family with parents'), 854 PedigreeLinkageType::VALUE_ADOPTED => I18N::translate('Family with adoptive parents'), 855 PedigreeLinkageType::VALUE_FOSTER => I18N::translate('Family with foster parents'), 856 /* I18N: “sealing” is a Mormon ceremony. */ 857 PedigreeLinkageType::VALUE_SEALING => I18N::translate('Family with sealing parents'), 858 /* I18N: “rada” is an Arabic word, pronounced “ra DAH”. It is child-to-parent pedigree, established by wet-nursing. */ 859 PedigreeLinkageType::VALUE_RADA => I18N::translate('Family with rada parents'), 860 ]; 861 862 return $values[$pedigree] ?? $values[PedigreeLinkageType::VALUE_BIRTH]; 863 } 864 865 /** 866 * Create a label for a step family 867 * 868 * @param Family $step_family 869 * 870 * @return string 871 */ 872 public function getStepFamilyLabel(Family $step_family): string 873 { 874 foreach ($this->childFamilies() as $family) { 875 if ($family !== $step_family) { 876 // Must be a step-family 877 foreach ($family->spouses() as $parent) { 878 foreach ($step_family->spouses() as $step_parent) { 879 if ($parent === $step_parent) { 880 // One common parent - must be a step family 881 if ($parent->sex() === 'M') { 882 // Father’s family with someone else 883 if ($step_family->spouse($step_parent) instanceof Individual) { 884 /* I18N: A step-family. %s is an individual’s name */ 885 return I18N::translate('Father’s family with %s', $step_family->spouse($step_parent)->fullName()); 886 } 887 888 /* I18N: A step-family. */ 889 return I18N::translate('Father’s family with an unknown individual'); 890 } 891 892 // Mother’s family with someone else 893 if ($step_family->spouse($step_parent) instanceof Individual) { 894 /* I18N: A step-family. %s is an individual’s name */ 895 return I18N::translate('Mother’s family with %s', $step_family->spouse($step_parent)->fullName()); 896 } 897 898 /* I18N: A step-family. */ 899 return I18N::translate('Mother’s family with an unknown individual'); 900 } 901 } 902 } 903 } 904 } 905 906 // Perahps same parents - but a different family record? 907 return I18N::translate('Family with parents'); 908 } 909 910 /** 911 * Get the description for the family. 912 * 913 * For example, "XXX's family with new wife". 914 * 915 * @param Family $family 916 * 917 * @return string 918 */ 919 public function getSpouseFamilyLabel(Family $family): string 920 { 921 $spouse = $family->spouse($this); 922 923 if ($spouse instanceof Individual) { 924 /* I18N: %s is the spouse name */ 925 return I18N::translate('Family with %s', $spouse->fullName()); 926 } 927 928 return $family->fullName(); 929 } 930 931 /** 932 * If this object has no name, what do we call it? 933 * 934 * @return string 935 */ 936 public function getFallBackName(): string 937 { 938 return '@P.N. /@N.N./'; 939 } 940 941 /** 942 * Convert a name record into ‘full’ and ‘sort’ versions. 943 * Use the NAME field to generate the ‘full’ version, as the 944 * gedcom spec says that this is the individual’s name, as they would write it. 945 * Use the SURN field to generate the sortable names. Note that this field 946 * may also be used for the ‘true’ surname, perhaps spelt differently to that 947 * recorded in the NAME field. e.g. 948 * 949 * 1 NAME Robert /de Gliderow/ 950 * 2 GIVN Robert 951 * 2 SPFX de 952 * 2 SURN CLITHEROW 953 * 2 NICK The Bald 954 * 955 * full=>'Robert de Gliderow 'The Bald'' 956 * sort=>'CLITHEROW, ROBERT' 957 * 958 * Handle multiple surnames, either as; 959 * 960 * 1 NAME Carlos /Vasquez/ y /Sante/ 961 * or 962 * 1 NAME Carlos /Vasquez y Sante/ 963 * 2 GIVN Carlos 964 * 2 SURN Vasquez,Sante 965 * 966 * @param string $type 967 * @param string $value 968 * @param string $gedcom 969 * 970 * @return void 971 */ 972 protected function addName(string $type, string $value, string $gedcom): void 973 { 974 //////////////////////////////////////////////////////////////////////////// 975 // Extract the structured name parts - use for "sortable" names and indexes 976 //////////////////////////////////////////////////////////////////////////// 977 978 $sublevel = 1 + (int) substr($gedcom, 0, 1); 979 $GIVN = preg_match('/\n' . $sublevel . ' GIVN (.+)/', $gedcom, $match) === 1 ? $match[1] : ''; 980 $SURN = preg_match('/\n' . $sublevel . ' SURN (.+)/', $gedcom, $match) === 1 ? $match[1] : ''; 981 982 // SURN is an comma-separated list of surnames... 983 if ($SURN !== '') { 984 $SURNS = preg_split('/ *, */', $SURN); 985 } else { 986 $SURNS = []; 987 } 988 989 // ...so is GIVN - but nobody uses it like that 990 $GIVN = str_replace('/ *, */', ' ', $GIVN); 991 992 //////////////////////////////////////////////////////////////////////////// 993 // Extract the components from NAME - use for the "full" names 994 //////////////////////////////////////////////////////////////////////////// 995 996 // Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/' 997 if (substr_count($value, '/') % 2 === 1) { 998 $value .= '/'; 999 } 1000 1001 // GEDCOM uses "//" to indicate an unknown surname 1002 $full = preg_replace('/\/\//', '/@N.N./', $value); 1003 1004 // Extract the surname. 1005 // Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/ 1006 if (preg_match('/\/.*\//', $full, $match)) { 1007 $surname = str_replace('/', '', $match[0]); 1008 } else { 1009 $surname = ''; 1010 } 1011 1012 // If we don’t have a SURN record, extract it from the NAME 1013 if (!$SURNS) { 1014 if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) { 1015 // There can be many surnames, each wrapped with '/' 1016 $SURNS = $matches[1]; 1017 foreach ($SURNS as $n => $SURN) { 1018 // Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only) 1019 $SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN); 1020 } 1021 } else { 1022 // It is valid not to have a surname at all 1023 $SURNS = ['']; 1024 } 1025 } 1026 1027 // If we don’t have a GIVN record, extract it from the NAME 1028 if (!$GIVN) { 1029 // remove surname 1030 $GIVN = preg_replace('/ ?\/.*\/ ?/', ' ', $full); 1031 // remove nickname 1032 $GIVN = preg_replace('/ ?".+"/', ' ', $GIVN); 1033 // multiple spaces, caused by the above 1034 $GIVN = preg_replace('/ {2,}/', ' ', $GIVN); 1035 // leading/trailing spaces, caused by the above 1036 $GIVN = preg_replace('/^ | $/', '', $GIVN); 1037 } 1038 1039 // Add placeholder for unknown given name 1040 if (!$GIVN) { 1041 $GIVN = self::PRAENOMEN_NESCIO; 1042 $pos = (int) strpos($full, '/'); 1043 $full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos); 1044 } 1045 1046 // Remove slashes - they don’t get displayed 1047 // $fullNN keeps the @N.N. placeholders, for the database 1048 // $full is for display on-screen 1049 $fullNN = str_replace('/', '', $full); 1050 1051 // Insert placeholders for any missing/unknown names 1052 $full = str_replace(self::NOMEN_NESCIO, I18N::translateContext('Unknown surname', '…'), $full); 1053 $full = str_replace(self::PRAENOMEN_NESCIO, I18N::translateContext('Unknown given name', '…'), $full); 1054 // Format for display 1055 $full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', e($full)) . '</span>'; 1056 // Localise quotation marks around the nickname 1057 $full = preg_replace_callback('/"([^&]*)"/', static fn(array $matches): string => '<q class="wt-nickname">' . $matches[1] . '</q>', $full); 1058 1059 // A suffix of “*” indicates a preferred name 1060 $full = preg_replace('/([^ >\x{200C}]*)\*/u', '<span class="starredname">\\1</span>', $full); 1061 1062 // Remove prefered-name indicater - they don’t go in the database 1063 $GIVN = str_replace('*', '', $GIVN); 1064 $fullNN = str_replace('*', '', $fullNN); 1065 1066 foreach ($SURNS as $SURN) { 1067 // Scottish 'Mc and Mac ' prefixes both sort under 'Mac' 1068 if (strcasecmp(substr($SURN, 0, 2), 'Mc') === 0) { 1069 $SURN = substr_replace($SURN, 'Mac', 0, 2); 1070 } elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') === 0) { 1071 $SURN = substr_replace($SURN, 'Mac', 0, 4); 1072 } 1073 1074 $this->getAllNames[] = [ 1075 'type' => $type, 1076 'sort' => $SURN . ',' . $GIVN, 1077 'full' => $full, 1078 // This is used for display 1079 'fullNN' => $fullNN, 1080 // This goes into the database 1081 'surname' => $surname, 1082 // This goes into the database 1083 'givn' => $GIVN, 1084 // This goes into the database 1085 'surn' => $SURN, 1086 // This goes into the database 1087 ]; 1088 } 1089 } 1090 1091 /** 1092 * Extract names from the GEDCOM record. 1093 * 1094 * @return void 1095 */ 1096 public function extractNames(): void 1097 { 1098 $access_level = $this->canShowName() ? Auth::PRIV_HIDE : Auth::accessLevel($this->tree); 1099 1100 $this->extractNamesFromFacts( 1101 1, 1102 'NAME', 1103 $this->facts(['NAME'], false, $access_level) 1104 ); 1105 } 1106 1107 /** 1108 * Extra info to display when displaying this record in a list of 1109 * selection items or favorites. 1110 * 1111 * @return string 1112 */ 1113 public function formatListDetails(): string 1114 { 1115 return 1116 $this->formatFirstMajorFact(Gedcom::BIRTH_EVENTS, 1) . 1117 $this->formatFirstMajorFact(Gedcom::DEATH_EVENTS, 1); 1118 } 1119 1120 /** 1121 * Lock the database row, to prevent concurrent edits. 1122 */ 1123 public function lock(): void 1124 { 1125 DB::table('individuals') 1126 ->where('i_file', '=', $this->tree->id()) 1127 ->where('i_id', '=', $this->xref()) 1128 ->lockForUpdate() 1129 ->get(); 1130 } 1131} 1132