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