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