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