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