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