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