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