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