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 return '<i class="icon-sex_' . strtolower($sex) . '_' . ($size == 'small' ? '9x9' : '15x15') . '"></i>'; 794 } 795 796 /** 797 * Generate the CSS class to be used for drawing this individual 798 * 799 * @return string 800 */ 801 public function getBoxStyle(): string 802 { 803 $tmp = [ 804 'M' => '', 805 'F' => 'F', 806 'U' => 'NN', 807 ]; 808 809 return 'person_box' . $tmp[$this->getSex()]; 810 } 811 812 /** 813 * Get a list of this individual’s spouse families 814 * 815 * @param int|null $access_level 816 * 817 * @return Family[] 818 */ 819 public function getSpouseFamilies($access_level = null): array 820 { 821 if ($access_level === null) { 822 $access_level = Auth::accessLevel($this->tree); 823 } 824 825 $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS'); 826 827 $families = []; 828 foreach ($this->facts(['FAMS'], false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) { 829 $family = $fact->target(); 830 if ($family instanceof Family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) { 831 $families[] = $family; 832 } 833 } 834 835 return $families; 836 } 837 838 /** 839 * Get the current spouse of this individual. 840 * 841 * Where an individual has multiple spouses, assume they are stored 842 * in chronological order, and take the last one found. 843 * 844 * @return Individual|null 845 */ 846 public function getCurrentSpouse() 847 { 848 $tmp = $this->getSpouseFamilies(); 849 $family = end($tmp); 850 if ($family) { 851 return $family->getSpouse($this); 852 } 853 854 return null; 855 } 856 857 /** 858 * Count the children belonging to this individual. 859 * 860 * @return int 861 */ 862 public function getNumberOfChildren() 863 { 864 if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->gedcom(), $match)) { 865 return (int) $match[1]; 866 } 867 868 $children = []; 869 foreach ($this->getSpouseFamilies() as $fam) { 870 foreach ($fam->getChildren() as $child) { 871 $children[$child->xref()] = true; 872 } 873 } 874 875 return count($children); 876 } 877 878 /** 879 * Get a list of this individual’s child families (i.e. their parents). 880 * 881 * @param int|null $access_level 882 * 883 * @return Family[] 884 */ 885 public function getChildFamilies($access_level = null): array 886 { 887 if ($access_level === null) { 888 $access_level = Auth::accessLevel($this->tree); 889 } 890 891 $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS'); 892 893 $families = []; 894 foreach ($this->facts(['FAMC'], false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) { 895 $family = $fact->target(); 896 if ($family instanceof Family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) { 897 $families[] = $family; 898 } 899 } 900 901 return $families; 902 } 903 904 /** 905 * Get the preferred parents for this individual. 906 * 907 * An individual may multiple parents (e.g. birth, adopted, disputed). 908 * The preferred family record is: 909 * (a) the first one with an explicit tag "_PRIMARY Y" 910 * (b) the first one with a pedigree of "birth" 911 * (c) the first one with no pedigree (default is "birth") 912 * (d) the first one found 913 * 914 * @return Family|null 915 */ 916 public function getPrimaryChildFamily() 917 { 918 $families = $this->getChildFamilies(); 919 switch (count($families)) { 920 case 0: 921 return null; 922 case 1: 923 return $families[0]; 924 default: 925 // If there is more than one FAMC record, choose the preferred parents: 926 // a) records with '2 _PRIMARY' 927 foreach ($families as $fam) { 928 $famid = $fam->xref(); 929 if (preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 _PRIMARY Y)/", $this->gedcom())) { 930 return $fam; 931 } 932 } 933 // b) records with '2 PEDI birt' 934 foreach ($families as $fam) { 935 $famid = $fam->xref(); 936 if (preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 PEDI birth)/", $this->gedcom())) { 937 return $fam; 938 } 939 } 940 // c) records with no '2 PEDI' 941 foreach ($families as $fam) { 942 $famid = $fam->xref(); 943 if (!preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 PEDI)/", $this->gedcom())) { 944 return $fam; 945 } 946 } 947 948 // d) any record 949 return $families[0]; 950 } 951 } 952 953 /** 954 * Get a list of step-parent families. 955 * 956 * @return Family[] 957 */ 958 public function getChildStepFamilies(): array 959 { 960 $step_families = []; 961 $families = $this->getChildFamilies(); 962 foreach ($families as $family) { 963 $father = $family->getHusband(); 964 if ($father) { 965 foreach ($father->getSpouseFamilies() as $step_family) { 966 if (!in_array($step_family, $families, true)) { 967 $step_families[] = $step_family; 968 } 969 } 970 } 971 $mother = $family->getWife(); 972 if ($mother) { 973 foreach ($mother->getSpouseFamilies() as $step_family) { 974 if (!in_array($step_family, $families, true)) { 975 $step_families[] = $step_family; 976 } 977 } 978 } 979 } 980 981 return $step_families; 982 } 983 984 /** 985 * Get a list of step-parent families. 986 * 987 * @return Family[] 988 */ 989 public function getSpouseStepFamilies(): array 990 { 991 $step_families = []; 992 $families = $this->getSpouseFamilies(); 993 foreach ($families as $family) { 994 $spouse = $family->getSpouse($this); 995 if ($spouse) { 996 foreach ($family->getSpouse($this)->getSpouseFamilies() as $step_family) { 997 if (!in_array($step_family, $families, true)) { 998 $step_families[] = $step_family; 999 } 1000 } 1001 } 1002 } 1003 1004 return $step_families; 1005 } 1006 1007 /** 1008 * A label for a parental family group 1009 * 1010 * @param Family $family 1011 * 1012 * @return string 1013 */ 1014 public function getChildFamilyLabel(Family $family) 1015 { 1016 if (preg_match('/\n1 FAMC @' . $family->xref() . '@(?:\n[2-9].*)*\n2 PEDI (.+)/', $this->gedcom(), $match)) { 1017 // A specified pedigree 1018 return GedcomCodePedi::getChildFamilyLabel($match[1]); 1019 } 1020 1021 // Default (birth) pedigree 1022 return GedcomCodePedi::getChildFamilyLabel(''); 1023 } 1024 1025 /** 1026 * Create a label for a step family 1027 * 1028 * @param Family $step_family 1029 * 1030 * @return string 1031 */ 1032 public function getStepFamilyLabel(Family $step_family): string 1033 { 1034 foreach ($this->getChildFamilies() as $family) { 1035 if ($family !== $step_family) { 1036 // Must be a step-family 1037 foreach ($family->getSpouses() as $parent) { 1038 foreach ($step_family->getSpouses() as $step_parent) { 1039 if ($parent === $step_parent) { 1040 // One common parent - must be a step family 1041 if ($parent->getSex() == 'M') { 1042 // Father’s family with someone else 1043 if ($step_family->getSpouse($step_parent)) { 1044 /* I18N: A step-family. %s is an individual’s name */ 1045 return I18N::translate('Father’s family with %s', $step_family->getSpouse($step_parent)->getFullName()); 1046 } 1047 1048 /* I18N: A step-family. */ 1049 return I18N::translate('Father’s family with an unknown individual'); 1050 } 1051 1052 // Mother’s family with someone else 1053 if ($step_family->getSpouse($step_parent)) { 1054 /* I18N: A step-family. %s is an individual’s name */ 1055 return I18N::translate('Mother’s family with %s', $step_family->getSpouse($step_parent)->getFullName()); 1056 } 1057 1058 /* I18N: A step-family. */ 1059 return I18N::translate('Mother’s family with an unknown individual'); 1060 } 1061 } 1062 } 1063 } 1064 } 1065 1066 // Perahps same parents - but a different family record? 1067 return I18N::translate('Family with parents'); 1068 } 1069 1070 /** 1071 * Get the description for the family. 1072 * 1073 * For example, "XXX's family with new wife". 1074 * 1075 * @param Family $family 1076 * 1077 * @return string 1078 */ 1079 public function getSpouseFamilyLabel(Family $family) 1080 { 1081 $spouse = $family->getSpouse($this); 1082 if ($spouse) { 1083 /* I18N: %s is the spouse name */ 1084 return I18N::translate('Family with %s', $spouse->getFullName()); 1085 } 1086 1087 return $family->getFullName(); 1088 } 1089 1090 /** 1091 * get primary parents names for this individual 1092 * 1093 * @param string $classname optional css class 1094 * @param string $display optional css style display 1095 * 1096 * @return string a div block with father & mother names 1097 */ 1098 public function getPrimaryParentsNames($classname = '', $display = ''): string 1099 { 1100 $fam = $this->getPrimaryChildFamily(); 1101 if (!$fam) { 1102 return ''; 1103 } 1104 $txt = '<div'; 1105 if ($classname) { 1106 $txt .= ' class="' . $classname . '"'; 1107 } 1108 if ($display) { 1109 $txt .= ' style="display:' . $display . '"'; 1110 } 1111 $txt .= '>'; 1112 $husb = $fam->getHusband(); 1113 if ($husb) { 1114 // Temporarily reset the 'prefered' display name, as we always 1115 // want the default name, not the one selected for display on the indilist. 1116 $primary = $husb->getPrimaryName(); 1117 $husb->setPrimaryName(null); 1118 /* I18N: %s is the name of an individual’s father */ 1119 $txt .= I18N::translate('Father: %s', $husb->getFullName()) . '<br>'; 1120 $husb->setPrimaryName($primary); 1121 } 1122 $wife = $fam->getWife(); 1123 if ($wife) { 1124 // Temporarily reset the 'prefered' display name, as we always 1125 // want the default name, not the one selected for display on the indilist. 1126 $primary = $wife->getPrimaryName(); 1127 $wife->setPrimaryName(null); 1128 /* I18N: %s is the name of an individual’s mother */ 1129 $txt .= I18N::translate('Mother: %s', $wife->getFullName()); 1130 $wife->setPrimaryName($primary); 1131 } 1132 $txt .= '</div>'; 1133 1134 return $txt; 1135 } 1136 1137 /** 1138 * If this object has no name, what do we call it? 1139 * 1140 * @return string 1141 */ 1142 public function getFallBackName(): string 1143 { 1144 return '@P.N. /@N.N./'; 1145 } 1146 1147 /** 1148 * Convert a name record into ‘full’ and ‘sort’ versions. 1149 * Use the NAME field to generate the ‘full’ version, as the 1150 * gedcom spec says that this is the individual’s name, as they would write it. 1151 * Use the SURN field to generate the sortable names. Note that this field 1152 * may also be used for the ‘true’ surname, perhaps spelt differently to that 1153 * recorded in the NAME field. e.g. 1154 * 1155 * 1 NAME Robert /de Gliderow/ 1156 * 2 GIVN Robert 1157 * 2 SPFX de 1158 * 2 SURN CLITHEROW 1159 * 2 NICK The Bald 1160 * 1161 * full=>'Robert de Gliderow 'The Bald'' 1162 * sort=>'CLITHEROW, ROBERT' 1163 * 1164 * Handle multiple surnames, either as; 1165 * 1166 * 1 NAME Carlos /Vasquez/ y /Sante/ 1167 * or 1168 * 1 NAME Carlos /Vasquez y Sante/ 1169 * 2 GIVN Carlos 1170 * 2 SURN Vasquez,Sante 1171 * 1172 * @param string $type 1173 * @param string $full 1174 * @param string $gedcom 1175 */ 1176 protected function addName(string $type, string $full, string $gedcom) 1177 { 1178 //////////////////////////////////////////////////////////////////////////// 1179 // Extract the structured name parts - use for "sortable" names and indexes 1180 //////////////////////////////////////////////////////////////////////////// 1181 1182 $sublevel = 1 + (int) substr($gedcom, 0, 1); 1183 $GIVN = preg_match("/\n{$sublevel} GIVN (.+)/", $gedcom, $match) ? $match[1] : ''; 1184 $SURN = preg_match("/\n{$sublevel} SURN (.+)/", $gedcom, $match) ? $match[1] : ''; 1185 $NICK = preg_match("/\n{$sublevel} NICK (.+)/", $gedcom, $match) ? $match[1] : ''; 1186 1187 // SURN is an comma-separated list of surnames... 1188 if ($SURN !== '') { 1189 $SURNS = preg_split('/ *, */', $SURN); 1190 } else { 1191 $SURNS = []; 1192 } 1193 1194 // ...so is GIVN - but nobody uses it like that 1195 $GIVN = str_replace('/ *, */', ' ', $GIVN); 1196 1197 //////////////////////////////////////////////////////////////////////////// 1198 // Extract the components from NAME - use for the "full" names 1199 //////////////////////////////////////////////////////////////////////////// 1200 1201 // Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/' 1202 if (substr_count($full, '/') % 2 === 1) { 1203 $full = $full . '/'; 1204 } 1205 1206 // GEDCOM uses "//" to indicate an unknown surname 1207 $full = preg_replace('/\/\//', '/@N.N./', $full); 1208 1209 // Extract the surname. 1210 // Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/ 1211 if (preg_match('/\/.*\//', $full, $match)) { 1212 $surname = str_replace('/', '', $match[0]); 1213 } else { 1214 $surname = ''; 1215 } 1216 1217 // If we don’t have a SURN record, extract it from the NAME 1218 if (!$SURNS) { 1219 if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) { 1220 // There can be many surnames, each wrapped with '/' 1221 $SURNS = $matches[1]; 1222 foreach ($SURNS as $n => $SURN) { 1223 // Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only) 1224 $SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN); 1225 } 1226 } else { 1227 // It is valid not to have a surname at all 1228 $SURNS = ['']; 1229 } 1230 } 1231 1232 // If we don’t have a GIVN record, extract it from the NAME 1233 if (!$GIVN) { 1234 $GIVN = preg_replace( 1235 [ 1236 '/ ?\/.*\/ ?/', 1237 // remove surname 1238 '/ ?".+"/', 1239 // remove nickname 1240 '/ {2,}/', 1241 // multiple spaces, caused by the above 1242 '/^ | $/', 1243 // leading/trailing spaces, caused by the above 1244 ], 1245 [ 1246 ' ', 1247 ' ', 1248 ' ', 1249 '', 1250 ], 1251 $full 1252 ); 1253 } 1254 1255 // Add placeholder for unknown given name 1256 if (!$GIVN) { 1257 $GIVN = '@P.N.'; 1258 $pos = (int) strpos($full, '/'); 1259 $full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos); 1260 } 1261 1262 // GEDCOM 5.5.1 nicknames should be specificied in a NICK field 1263 // GEDCOM 5.5 nicknames should be specified in the NAME field, surrounded by quotes 1264 if ($NICK && strpos($full, '"' . $NICK . '"') === false) { 1265 // A NICK field is present, but not included in the NAME. Show it at the end. 1266 $full .= ' "' . $NICK . '"'; 1267 } 1268 1269 // Remove slashes - they don’t get displayed 1270 // $fullNN keeps the @N.N. placeholders, for the database 1271 // $full is for display on-screen 1272 $fullNN = str_replace('/', '', $full); 1273 1274 // Insert placeholders for any missing/unknown names 1275 $full = str_replace('@N.N.', I18N::translateContext('Unknown surname', '…'), $full); 1276 $full = str_replace('@P.N.', I18N::translateContext('Unknown given name', '…'), $full); 1277 // Format for display 1278 $full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', e($full)) . '</span>'; 1279 // Localise quotation marks around the nickname 1280 $full = preg_replace_callback('/"([^&]*)"/', function (array $matches): string { 1281 return I18N::translate('“%s”', $matches[1]); 1282 }, $full); 1283 1284 // A suffix of “*” indicates a preferred name 1285 $full = preg_replace('/([^ >]*)\*/', '<span class="starredname">\\1</span>', $full); 1286 1287 // Remove prefered-name indicater - they don’t go in the database 1288 $GIVN = str_replace('*', '', $GIVN); 1289 $fullNN = str_replace('*', '', $fullNN); 1290 1291 foreach ($SURNS as $SURN) { 1292 // Scottish 'Mc and Mac ' prefixes both sort under 'Mac' 1293 if (strcasecmp(substr($SURN, 0, 2), 'Mc') == 0) { 1294 $SURN = substr_replace($SURN, 'Mac', 0, 2); 1295 } elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') == 0) { 1296 $SURN = substr_replace($SURN, 'Mac', 0, 4); 1297 } 1298 1299 $this->getAllNames[] = [ 1300 'type' => $type, 1301 'sort' => $SURN . ',' . $GIVN, 1302 'full' => $full, 1303 // This is used for display 1304 'fullNN' => $fullNN, 1305 // This goes into the database 1306 'surname' => $surname, 1307 // This goes into the database 1308 'givn' => $GIVN, 1309 // This goes into the database 1310 'surn' => $SURN, 1311 // This goes into the database 1312 ]; 1313 } 1314 } 1315 1316 /** 1317 * Extract names from the GEDCOM record. 1318 * 1319 * @return void 1320 */ 1321 public function extractNames() 1322 { 1323 $this->extractNamesFromFacts( 1324 1, 1325 'NAME', 1326 $this->facts( 1327 ['NAME'], 1328 false, 1329 Auth::accessLevel($this->tree), 1330 $this->canShowName() 1331 ) 1332 ); 1333 } 1334 1335 /** 1336 * Extra info to display when displaying this record in a list of 1337 * selection items or favorites. 1338 * 1339 * @return string 1340 */ 1341 public function formatListDetails(): string 1342 { 1343 return 1344 $this->formatFirstMajorFact(Gedcom::BIRTH_EVENTS, 1) . 1345 $this->formatFirstMajorFact(Gedcom::DEATH_EVENTS, 1); 1346 } 1347} 1348