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