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