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