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