1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2019 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <http://www.gnu.org/licenses/>. 16 */ 17declare(strict_types=1); 18 19namespace Fisharebest\Webtrees; 20 21use Closure; 22use Fisharebest\Flysystem\Adapter\ChrootAdapter; 23use Fisharebest\Webtrees\Contracts\UserInterface; 24use Fisharebest\Webtrees\Functions\FunctionsExport; 25use Fisharebest\Webtrees\Functions\FunctionsImport; 26use Fisharebest\Webtrees\Services\TreeService; 27use Illuminate\Database\Capsule\Manager as DB; 28use Illuminate\Database\Query\Expression; 29use Illuminate\Support\Collection; 30use Illuminate\Support\Str; 31use InvalidArgumentException; 32use League\Flysystem\Filesystem; 33use League\Flysystem\FilesystemInterface; 34use Psr\Http\Message\StreamInterface; 35use stdClass; 36 37/** 38 * Provide an interface to the wt_gedcom table. 39 */ 40class Tree 41{ 42 private const RESN_PRIVACY = [ 43 'none' => Auth::PRIV_PRIVATE, 44 'privacy' => Auth::PRIV_USER, 45 'confidential' => Auth::PRIV_NONE, 46 'hidden' => Auth::PRIV_HIDE, 47 ]; 48 /** @var Tree[] All trees that we have permission to see, indexed by ID. */ 49 public static $trees = []; 50 /** @var int The tree's ID number */ 51 private $id; 52 /** @var string The tree's name */ 53 private $name; 54 /** @var string The tree's title */ 55 private $title; 56 /** @var int[] Default access rules for facts in this tree */ 57 private $fact_privacy; 58 /** @var int[] Default access rules for individuals in this tree */ 59 private $individual_privacy; 60 /** @var integer[][] Default access rules for individual facts in this tree */ 61 private $individual_fact_privacy; 62 /** @var string[] Cached copy of the wt_gedcom_setting table. */ 63 private $preferences = []; 64 /** @var string[][] Cached copy of the wt_user_gedcom_setting table. */ 65 private $user_preferences = []; 66 67 /** 68 * Create a tree object. This is a private constructor - it can only 69 * be called from Tree::getAll() to ensure proper initialisation. 70 * 71 * @param int $id 72 * @param string $name 73 * @param string $title 74 */ 75 public function __construct(int $id, string $name, string $title) 76 { 77 $this->id = $id; 78 $this->name = $name; 79 $this->title = $title; 80 $this->fact_privacy = []; 81 $this->individual_privacy = []; 82 $this->individual_fact_privacy = []; 83 84 // Load the privacy settings for this tree 85 $rows = DB::table('default_resn') 86 ->where('gedcom_id', '=', $this->id) 87 ->get(); 88 89 foreach ($rows as $row) { 90 // Convert GEDCOM privacy restriction to a webtrees access level. 91 $row->resn = self::RESN_PRIVACY[$row->resn]; 92 93 if ($row->xref !== null) { 94 if ($row->tag_type !== null) { 95 $this->individual_fact_privacy[$row->xref][$row->tag_type] = (int) $row->resn; 96 } else { 97 $this->individual_privacy[$row->xref] = (int) $row->resn; 98 } 99 } else { 100 $this->fact_privacy[$row->tag_type] = (int) $row->resn; 101 } 102 } 103 } 104 105 /** 106 * A closure which will create a record from a database row. 107 * 108 * @return Closure 109 */ 110 public static function rowMapper(): Closure 111 { 112 return static function (stdClass $row): Tree { 113 return new Tree((int) $row->tree_id, $row->tree_name, $row->tree_title); 114 }; 115 } 116 117 /** 118 * Find the tree with a specific ID. 119 * 120 * @param int $tree_id 121 * 122 * @return Tree 123 */ 124 public static function findById(int $tree_id): Tree 125 { 126 return self::getAll()[$tree_id]; 127 } 128 129 /** 130 * Fetch all the trees that we have permission to access. 131 * 132 * @return Tree[] 133 * @deprecated 134 */ 135 public static function getAll(): array 136 { 137 if (empty(self::$trees)) { 138 self::$trees = self::all()->all(); 139 } 140 141 return self::$trees; 142 } 143 144 /** 145 * All the trees that we have permission to access. 146 * 147 * @return Collection 148 * @deprecated 149 */ 150 public static function all(): Collection 151 { 152 return (new TreeService())->all(); 153 } 154 155 /** 156 * Create arguments to select_edit_control() 157 * Note - these will be escaped later 158 * 159 * @return string[] 160 */ 161 public static function getIdList(): array 162 { 163 $list = []; 164 foreach (self::getAll() as $tree) { 165 $list[$tree->id] = $tree->title; 166 } 167 168 return $list; 169 } 170 171 /** 172 * Create arguments to select_edit_control() 173 * Note - these will be escaped later 174 * 175 * @return string[] 176 */ 177 public static function getNameList(): array 178 { 179 $list = []; 180 foreach (self::getAll() as $tree) { 181 $list[$tree->name] = $tree->title; 182 } 183 184 return $list; 185 } 186 187 /** 188 * Create a new tree 189 * 190 * @param string $tree_name 191 * @param string $tree_title 192 * 193 * @return Tree 194 * @deprecated 195 */ 196 public static function create(string $tree_name, string $tree_title): Tree 197 { 198 return (new TreeService())->create($tree_name, $tree_title); 199 } 200 201 /** 202 * Find the tree with a specific name. 203 * 204 * @param string $name 205 * 206 * @return Tree|null 207 * @deprecated 208 */ 209 public static function findByName($name): ?Tree 210 { 211 foreach (self::getAll() as $tree) { 212 if ($tree->name === $name) { 213 return $tree; 214 } 215 } 216 217 return null; 218 } 219 220 /** 221 * Set the tree’s configuration settings. 222 * 223 * @param string $setting_name 224 * @param string $setting_value 225 * 226 * @return $this 227 */ 228 public function setPreference(string $setting_name, string $setting_value): Tree 229 { 230 if ($setting_value !== $this->getPreference($setting_name)) { 231 DB::table('gedcom_setting')->updateOrInsert([ 232 'gedcom_id' => $this->id, 233 'setting_name' => $setting_name, 234 ], [ 235 'setting_value' => $setting_value, 236 ]); 237 238 $this->preferences[$setting_name] = $setting_value; 239 240 Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '"', $this); 241 } 242 243 return $this; 244 } 245 246 /** 247 * Get the tree’s configuration settings. 248 * 249 * @param string $setting_name 250 * @param string $default 251 * 252 * @return string 253 */ 254 public function getPreference(string $setting_name, string $default = ''): string 255 { 256 if (empty($this->preferences)) { 257 $this->preferences = DB::table('gedcom_setting') 258 ->where('gedcom_id', '=', $this->id) 259 ->pluck('setting_value', 'setting_name') 260 ->all(); 261 } 262 263 return $this->preferences[$setting_name] ?? $default; 264 } 265 266 /** 267 * The name of this tree 268 * 269 * @return string 270 */ 271 public function name(): string 272 { 273 return $this->name; 274 } 275 276 /** 277 * The title of this tree 278 * 279 * @return string 280 */ 281 public function title(): string 282 { 283 return $this->title; 284 } 285 286 /** 287 * The fact-level privacy for this tree. 288 * 289 * @return int[] 290 */ 291 public function getFactPrivacy(): array 292 { 293 return $this->fact_privacy; 294 } 295 296 /** 297 * The individual-level privacy for this tree. 298 * 299 * @return int[] 300 */ 301 public function getIndividualPrivacy(): array 302 { 303 return $this->individual_privacy; 304 } 305 306 /** 307 * The individual-fact-level privacy for this tree. 308 * 309 * @return int[][] 310 */ 311 public function getIndividualFactPrivacy(): array 312 { 313 return $this->individual_fact_privacy; 314 } 315 316 /** 317 * Set the tree’s user-configuration settings. 318 * 319 * @param UserInterface $user 320 * @param string $setting_name 321 * @param string $setting_value 322 * 323 * @return $this 324 */ 325 public function setUserPreference(UserInterface $user, string $setting_name, string $setting_value): Tree 326 { 327 if ($this->getUserPreference($user, $setting_name) !== $setting_value) { 328 // Update the database 329 DB::table('user_gedcom_setting')->updateOrInsert([ 330 'gedcom_id' => $this->id(), 331 'user_id' => $user->id(), 332 'setting_name' => $setting_name, 333 ], [ 334 'setting_value' => $setting_value, 335 ]); 336 337 // Update the cache 338 $this->user_preferences[$user->id()][$setting_name] = $setting_value; 339 // Audit log of changes 340 Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->userName() . '"', $this); 341 } 342 343 return $this; 344 } 345 346 /** 347 * Get the tree’s user-configuration settings. 348 * 349 * @param UserInterface $user 350 * @param string $setting_name 351 * @param string $default 352 * 353 * @return string 354 */ 355 public function getUserPreference(UserInterface $user, string $setting_name, string $default = ''): string 356 { 357 // There are lots of settings, and we need to fetch lots of them on every page 358 // so it is quicker to fetch them all in one go. 359 if (!array_key_exists($user->id(), $this->user_preferences)) { 360 $this->user_preferences[$user->id()] = DB::table('user_gedcom_setting') 361 ->where('user_id', '=', $user->id()) 362 ->where('gedcom_id', '=', $this->id) 363 ->pluck('setting_value', 'setting_name') 364 ->all(); 365 } 366 367 return $this->user_preferences[$user->id()][$setting_name] ?? $default; 368 } 369 370 /** 371 * The ID of this tree 372 * 373 * @return int 374 */ 375 public function id(): int 376 { 377 return $this->id; 378 } 379 380 /** 381 * Can a user accept changes for this tree? 382 * 383 * @param UserInterface $user 384 * 385 * @return bool 386 */ 387 public function canAcceptChanges(UserInterface $user): bool 388 { 389 return Auth::isModerator($this, $user); 390 } 391 392 /** 393 * Are there any pending edits for this tree, than need reviewing by a moderator. 394 * 395 * @return bool 396 */ 397 public function hasPendingEdit(): bool 398 { 399 return DB::table('change') 400 ->where('gedcom_id', '=', $this->id) 401 ->where('status', '=', 'pending') 402 ->exists(); 403 } 404 405 /** 406 * Delete everything relating to a tree 407 * 408 * @return void 409 */ 410 public function delete(): void 411 { 412 // If this is the default tree, then unset it 413 if (Site::getPreference('DEFAULT_GEDCOM') === $this->name) { 414 Site::setPreference('DEFAULT_GEDCOM', ''); 415 } 416 417 $this->deleteGenealogyData(false); 418 419 DB::table('block_setting') 420 ->join('block', 'block.block_id', '=', 'block_setting.block_id') 421 ->where('gedcom_id', '=', $this->id) 422 ->delete(); 423 DB::table('block')->where('gedcom_id', '=', $this->id)->delete(); 424 DB::table('user_gedcom_setting')->where('gedcom_id', '=', $this->id)->delete(); 425 DB::table('gedcom_setting')->where('gedcom_id', '=', $this->id)->delete(); 426 DB::table('module_privacy')->where('gedcom_id', '=', $this->id)->delete(); 427 DB::table('hit_counter')->where('gedcom_id', '=', $this->id)->delete(); 428 DB::table('default_resn')->where('gedcom_id', '=', $this->id)->delete(); 429 DB::table('gedcom_chunk')->where('gedcom_id', '=', $this->id)->delete(); 430 DB::table('log')->where('gedcom_id', '=', $this->id)->delete(); 431 DB::table('gedcom')->where('gedcom_id', '=', $this->id)->delete(); 432 433 // After updating the database, we need to fetch a new (sorted) copy 434 self::$trees = []; 435 } 436 437 /** 438 * Delete all the genealogy data from a tree - in preparation for importing 439 * new data. Optionally retain the media data, for when the user has been 440 * editing their data offline using an application which deletes (or does not 441 * support) media data. 442 * 443 * @param bool $keep_media 444 * 445 * @return void 446 */ 447 public function deleteGenealogyData(bool $keep_media): void 448 { 449 DB::table('gedcom_chunk')->where('gedcom_id', '=', $this->id)->delete(); 450 DB::table('individuals')->where('i_file', '=', $this->id)->delete(); 451 DB::table('families')->where('f_file', '=', $this->id)->delete(); 452 DB::table('sources')->where('s_file', '=', $this->id)->delete(); 453 DB::table('other')->where('o_file', '=', $this->id)->delete(); 454 DB::table('places')->where('p_file', '=', $this->id)->delete(); 455 DB::table('placelinks')->where('pl_file', '=', $this->id)->delete(); 456 DB::table('name')->where('n_file', '=', $this->id)->delete(); 457 DB::table('dates')->where('d_file', '=', $this->id)->delete(); 458 DB::table('change')->where('gedcom_id', '=', $this->id)->delete(); 459 460 if ($keep_media) { 461 DB::table('link')->where('l_file', '=', $this->id) 462 ->where('l_type', '<>', 'OBJE') 463 ->delete(); 464 } else { 465 DB::table('link')->where('l_file', '=', $this->id)->delete(); 466 DB::table('media_file')->where('m_file', '=', $this->id)->delete(); 467 DB::table('media')->where('m_file', '=', $this->id)->delete(); 468 } 469 } 470 471 /** 472 * Export the tree to a GEDCOM file 473 * 474 * @param resource $stream 475 * 476 * @return void 477 */ 478 public function exportGedcom($stream): void 479 { 480 $buffer = FunctionsExport::reformatRecord(FunctionsExport::gedcomHeader($this, 'UTF-8')); 481 482 $union_families = DB::table('families') 483 ->where('f_file', '=', $this->id) 484 ->select(['f_gedcom AS gedcom', 'f_id AS xref', new Expression('LENGTH(f_id) AS len'), new Expression('2 AS n')]); 485 486 $union_sources = DB::table('sources') 487 ->where('s_file', '=', $this->id) 488 ->select(['s_gedcom AS gedcom', 's_id AS xref', new Expression('LENGTH(s_id) AS len'), new Expression('3 AS n')]); 489 490 $union_other = DB::table('other') 491 ->where('o_file', '=', $this->id) 492 ->whereNotIn('o_type', ['HEAD', 'TRLR']) 493 ->select(['o_gedcom AS gedcom', 'o_id AS xref', new Expression('LENGTH(o_id) AS len'), new Expression('4 AS n')]); 494 495 $union_media = DB::table('media') 496 ->where('m_file', '=', $this->id) 497 ->select(['m_gedcom AS gedcom', 'm_id AS xref', new Expression('LENGTH(m_id) AS len'), new Expression('5 AS n')]); 498 499 DB::table('individuals') 500 ->where('i_file', '=', $this->id) 501 ->select(['i_gedcom AS gedcom', 'i_id AS xref', new Expression('LENGTH(i_id) AS len'), new Expression('1 AS n')]) 502 ->union($union_families) 503 ->union($union_sources) 504 ->union($union_other) 505 ->union($union_media) 506 ->orderBy('n') 507 ->orderBy('len') 508 ->orderBy('xref') 509 ->chunk(1000, static function (Collection $rows) use ($stream, &$buffer): void { 510 foreach ($rows as $row) { 511 $buffer .= FunctionsExport::reformatRecord($row->gedcom); 512 if (strlen($buffer) > 65535) { 513 fwrite($stream, $buffer); 514 $buffer = ''; 515 } 516 } 517 }); 518 519 fwrite($stream, $buffer . '0 TRLR' . Gedcom::EOL); 520 } 521 522 /** 523 * Import data from a gedcom file into this tree. 524 * 525 * @param StreamInterface $stream The GEDCOM file. 526 * @param string $filename The preferred filename, for export/download. 527 * 528 * @return void 529 */ 530 public function importGedcomFile(StreamInterface $stream, string $filename): void 531 { 532 // Read the file in blocks of roughly 64K. Ensure that each block 533 // contains complete gedcom records. This will ensure we don’t split 534 // multi-byte characters, as well as simplifying the code to import 535 // each block. 536 537 $file_data = ''; 538 539 $this->deleteGenealogyData((bool) $this->getPreference('keep_media')); 540 $this->setPreference('gedcom_filename', $filename); 541 $this->setPreference('imported', '0'); 542 543 while (!$stream->eof()) { 544 $file_data .= $stream->read(65536); 545 // There is no strrpos() function that searches for substrings :-( 546 for ($pos = strlen($file_data) - 1; $pos > 0; --$pos) { 547 if ($file_data[$pos] === '0' && ($file_data[$pos - 1] === "\n" || $file_data[$pos - 1] === "\r")) { 548 // We’ve found the last record boundary in this chunk of data 549 break; 550 } 551 } 552 if ($pos) { 553 DB::table('gedcom_chunk')->insert([ 554 'gedcom_id' => $this->id, 555 'chunk_data' => substr($file_data, 0, $pos), 556 ]); 557 558 $file_data = substr($file_data, $pos); 559 } 560 } 561 DB::table('gedcom_chunk')->insert([ 562 'gedcom_id' => $this->id, 563 'chunk_data' => $file_data, 564 ]); 565 566 $stream->close(); 567 } 568 569 /** 570 * Create a new record from GEDCOM data. 571 * 572 * @param string $gedcom 573 * 574 * @return GedcomRecord|Individual|Family|Note|Source|Repository|Media 575 * @throws InvalidArgumentException 576 */ 577 public function createRecord(string $gedcom): GedcomRecord 578 { 579 if (!Str::startsWith($gedcom, '0 @@ ')) { 580 throw new InvalidArgumentException('GedcomRecord::createRecord(' . $gedcom . ') does not begin 0 @@'); 581 } 582 583 $xref = $this->getNewXref(); 584 $gedcom = '0 @' . $xref . '@ ' . Str::after($gedcom, '0 @@ '); 585 586 // Create a change record 587 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 588 589 // Create a pending change 590 DB::table('change')->insert([ 591 'gedcom_id' => $this->id, 592 'xref' => $xref, 593 'old_gedcom' => '', 594 'new_gedcom' => $gedcom, 595 'user_id' => Auth::id(), 596 ]); 597 598 // Accept this pending change 599 if (Auth::user()->getPreference('auto_accept')) { 600 FunctionsImport::acceptAllChanges($xref, $this); 601 602 return new GedcomRecord($xref, $gedcom, null, $this); 603 } 604 605 return GedcomRecord::getInstance($xref, $this, $gedcom); 606 } 607 608 /** 609 * Generate a new XREF, unique across all family trees 610 * 611 * @return string 612 */ 613 public function getNewXref(): string 614 { 615 // Lock the row, so that only one new XREF may be generated at a time. 616 DB::table('site_setting') 617 ->where('setting_name', '=', 'next_xref') 618 ->lockForUpdate() 619 ->get(); 620 621 $prefix = 'X'; 622 623 $increment = 1.0; 624 do { 625 $num = (int) Site::getPreference('next_xref') + (int) $increment; 626 627 // This exponential increment allows us to scan over large blocks of 628 // existing data in a reasonable time. 629 $increment *= 1.01; 630 631 $xref = $prefix . $num; 632 633 // Records may already exist with this sequence number. 634 $already_used = 635 DB::table('individuals')->where('i_id', '=', $xref)->exists() || 636 DB::table('families')->where('f_id', '=', $xref)->exists() || 637 DB::table('sources')->where('s_id', '=', $xref)->exists() || 638 DB::table('media')->where('m_id', '=', $xref)->exists() || 639 DB::table('other')->where('o_id', '=', $xref)->exists() || 640 DB::table('change')->where('xref', '=', $xref)->exists(); 641 } while ($already_used); 642 643 Site::setPreference('next_xref', (string) $num); 644 645 return $xref; 646 } 647 648 /** 649 * Create a new family from GEDCOM data. 650 * 651 * @param string $gedcom 652 * 653 * @return Family 654 * @throws InvalidArgumentException 655 */ 656 public function createFamily(string $gedcom): GedcomRecord 657 { 658 if (!Str::startsWith($gedcom, '0 @@ FAM')) { 659 throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM'); 660 } 661 662 $xref = $this->getNewXref(); 663 $gedcom = '0 @' . $xref . '@ FAM' . Str::after($gedcom, '0 @@ FAM'); 664 665 // Create a change record 666 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 667 668 // Create a pending change 669 DB::table('change')->insert([ 670 'gedcom_id' => $this->id, 671 'xref' => $xref, 672 'old_gedcom' => '', 673 'new_gedcom' => $gedcom, 674 'user_id' => Auth::id(), 675 ]); 676 677 // Accept this pending change 678 if (Auth::user()->getPreference('auto_accept')) { 679 FunctionsImport::acceptAllChanges($xref, $this); 680 681 return new Family($xref, $gedcom, null, $this); 682 } 683 684 return new Family($xref, '', $gedcom, $this); 685 } 686 687 /** 688 * Create a new individual from GEDCOM data. 689 * 690 * @param string $gedcom 691 * 692 * @return Individual 693 * @throws InvalidArgumentException 694 */ 695 public function createIndividual(string $gedcom): GedcomRecord 696 { 697 if (!Str::startsWith($gedcom, '0 @@ INDI')) { 698 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI'); 699 } 700 701 $xref = $this->getNewXref(); 702 $gedcom = '0 @' . $xref . '@ INDI' . Str::after($gedcom, '0 @@ INDI'); 703 704 // Create a change record 705 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 706 707 // Create a pending change 708 DB::table('change')->insert([ 709 'gedcom_id' => $this->id, 710 'xref' => $xref, 711 'old_gedcom' => '', 712 'new_gedcom' => $gedcom, 713 'user_id' => Auth::id(), 714 ]); 715 716 // Accept this pending change 717 if (Auth::user()->getPreference('auto_accept')) { 718 FunctionsImport::acceptAllChanges($xref, $this); 719 720 return new Individual($xref, $gedcom, null, $this); 721 } 722 723 return new Individual($xref, '', $gedcom, $this); 724 } 725 726 /** 727 * Create a new media object from GEDCOM data. 728 * 729 * @param string $gedcom 730 * 731 * @return Media 732 * @throws InvalidArgumentException 733 */ 734 public function createMediaObject(string $gedcom): Media 735 { 736 if (!Str::startsWith($gedcom, '0 @@ OBJE')) { 737 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE'); 738 } 739 740 $xref = $this->getNewXref(); 741 $gedcom = '0 @' . $xref . '@ OBJE' . Str::after($gedcom, '0 @@ OBJE'); 742 743 // Create a change record 744 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 745 746 // Create a pending change 747 DB::table('change')->insert([ 748 'gedcom_id' => $this->id, 749 'xref' => $xref, 750 'old_gedcom' => '', 751 'new_gedcom' => $gedcom, 752 'user_id' => Auth::id(), 753 ]); 754 755 // Accept this pending change 756 if (Auth::user()->getPreference('auto_accept')) { 757 FunctionsImport::acceptAllChanges($xref, $this); 758 759 return new Media($xref, $gedcom, null, $this); 760 } 761 762 return new Media($xref, '', $gedcom, $this); 763 } 764 765 /** 766 * What is the most significant individual in this tree. 767 * 768 * @param UserInterface $user 769 * 770 * @return Individual 771 */ 772 public function significantIndividual(UserInterface $user): Individual 773 { 774 $individual = null; 775 776 if ($this->getUserPreference($user, 'rootid') !== '') { 777 $individual = Individual::getInstance($this->getUserPreference($user, 'rootid'), $this); 778 } 779 780 if ($individual === null && $this->getUserPreference($user, 'gedcomid') !== '') { 781 $individual = Individual::getInstance($this->getUserPreference($user, 'gedcomid'), $this); 782 } 783 784 if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') { 785 $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this); 786 } 787 if ($individual === null) { 788 $xref = (string) DB::table('individuals') 789 ->where('i_file', '=', $this->id()) 790 ->min('i_id'); 791 792 $individual = Individual::getInstance($xref, $this); 793 } 794 if ($individual === null) { 795 // always return a record 796 $individual = new Individual('I', '0 @I@ INDI', null, $this); 797 } 798 799 return $individual; 800 } 801 802 /** 803 * Where do we store our media files. 804 * 805 * @return FilesystemInterface 806 */ 807 public function mediaFilesystem(): FilesystemInterface 808 { 809 $media_dir = $this->getPreference('MEDIA_DIRECTORY', 'media/'); 810 $filesystem = app(FilesystemInterface::class); 811 $adapter = new ChrootAdapter($filesystem, $media_dir); 812 813 return new Filesystem($adapter); 814 } 815} 816