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