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