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 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 FunctionsImport::acceptAllChanges($xref, $this); 518 519 return new GedcomRecord($xref, $gedcom, null, $this); 520 } 521 522 return GedcomRecord::getInstance($xref, $this, $gedcom); 523 } 524 525 /** 526 * Generate a new XREF, unique across all family trees 527 * 528 * @return string 529 */ 530 public function getNewXref(): string 531 { 532 // Lock the row, so that only one new XREF may be generated at a time. 533 DB::table('site_setting') 534 ->where('setting_name', '=', 'next_xref') 535 ->lockForUpdate() 536 ->get(); 537 538 $prefix = 'X'; 539 540 $increment = 1.0; 541 do { 542 $num = (int) Site::getPreference('next_xref') + (int) $increment; 543 544 // This exponential increment allows us to scan over large blocks of 545 // existing data in a reasonable time. 546 $increment *= 1.01; 547 548 $xref = $prefix . $num; 549 550 // Records may already exist with this sequence number. 551 $already_used = 552 DB::table('individuals')->where('i_id', '=', $xref)->exists() || 553 DB::table('families')->where('f_id', '=', $xref)->exists() || 554 DB::table('sources')->where('s_id', '=', $xref)->exists() || 555 DB::table('media')->where('m_id', '=', $xref)->exists() || 556 DB::table('other')->where('o_id', '=', $xref)->exists() || 557 DB::table('change')->where('xref', '=', $xref)->exists(); 558 } while ($already_used); 559 560 Site::setPreference('next_xref', (string) $num); 561 562 return $xref; 563 } 564 565 /** 566 * Create a new family from GEDCOM data. 567 * 568 * @param string $gedcom 569 * 570 * @return Family 571 * @throws InvalidArgumentException 572 */ 573 public function createFamily(string $gedcom): GedcomRecord 574 { 575 if (!Str::startsWith($gedcom, '0 @@ FAM')) { 576 throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM'); 577 } 578 579 $xref = $this->getNewXref(); 580 $gedcom = '0 @' . $xref . '@ FAM' . Str::after($gedcom, '0 @@ FAM'); 581 582 // Create a change record 583 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 584 585 // Create a pending change 586 DB::table('change')->insert([ 587 'gedcom_id' => $this->id, 588 'xref' => $xref, 589 'old_gedcom' => '', 590 'new_gedcom' => $gedcom, 591 'user_id' => Auth::id(), 592 ]); 593 594 // Accept this pending change 595 if (Auth::user()->getPreference('auto_accept')) { 596 FunctionsImport::acceptAllChanges($xref, $this); 597 598 return new Family($xref, $gedcom, null, $this); 599 } 600 601 return new Family($xref, '', $gedcom, $this); 602 } 603 604 /** 605 * Create a new individual from GEDCOM data. 606 * 607 * @param string $gedcom 608 * 609 * @return Individual 610 * @throws InvalidArgumentException 611 */ 612 public function createIndividual(string $gedcom): GedcomRecord 613 { 614 if (!Str::startsWith($gedcom, '0 @@ INDI')) { 615 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI'); 616 } 617 618 $xref = $this->getNewXref(); 619 $gedcom = '0 @' . $xref . '@ INDI' . Str::after($gedcom, '0 @@ INDI'); 620 621 // Create a change record 622 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 623 624 // Create a pending change 625 DB::table('change')->insert([ 626 'gedcom_id' => $this->id, 627 'xref' => $xref, 628 'old_gedcom' => '', 629 'new_gedcom' => $gedcom, 630 'user_id' => Auth::id(), 631 ]); 632 633 // Accept this pending change 634 if (Auth::user()->getPreference('auto_accept')) { 635 FunctionsImport::acceptAllChanges($xref, $this); 636 637 return new Individual($xref, $gedcom, null, $this); 638 } 639 640 return new Individual($xref, '', $gedcom, $this); 641 } 642 643 /** 644 * Create a new media object from GEDCOM data. 645 * 646 * @param string $gedcom 647 * 648 * @return Media 649 * @throws InvalidArgumentException 650 */ 651 public function createMediaObject(string $gedcom): Media 652 { 653 if (!Str::startsWith($gedcom, '0 @@ OBJE')) { 654 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE'); 655 } 656 657 $xref = $this->getNewXref(); 658 $gedcom = '0 @' . $xref . '@ OBJE' . Str::after($gedcom, '0 @@ OBJE'); 659 660 // Create a change record 661 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 662 663 // Create a pending change 664 DB::table('change')->insert([ 665 'gedcom_id' => $this->id, 666 'xref' => $xref, 667 'old_gedcom' => '', 668 'new_gedcom' => $gedcom, 669 'user_id' => Auth::id(), 670 ]); 671 672 // Accept this pending change 673 if (Auth::user()->getPreference('auto_accept')) { 674 FunctionsImport::acceptAllChanges($xref, $this); 675 676 return new Media($xref, $gedcom, null, $this); 677 } 678 679 return new Media($xref, '', $gedcom, $this); 680 } 681 682 /** 683 * What is the most significant individual in this tree. 684 * 685 * @param UserInterface $user 686 * 687 * @return Individual 688 */ 689 public function significantIndividual(UserInterface $user): Individual 690 { 691 $individual = null; 692 693 if ($this->getUserPreference($user, 'rootid') !== '') { 694 $individual = Individual::getInstance($this->getUserPreference($user, 'rootid'), $this); 695 } 696 697 if ($individual === null && $this->getUserPreference($user, 'gedcomid') !== '') { 698 $individual = Individual::getInstance($this->getUserPreference($user, 'gedcomid'), $this); 699 } 700 701 if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') { 702 $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this); 703 } 704 if ($individual === null) { 705 $xref = (string) DB::table('individuals') 706 ->where('i_file', '=', $this->id()) 707 ->min('i_id'); 708 709 $individual = Individual::getInstance($xref, $this); 710 } 711 if ($individual === null) { 712 // always return a record 713 $individual = new Individual('I', '0 @I@ INDI', null, $this); 714 } 715 716 return $individual; 717 } 718 719 /** 720 * Where do we store our media files. 721 * 722 * @return FilesystemInterface 723 */ 724 public function mediaFilesystem(): FilesystemInterface 725 { 726 $media_dir = $this->getPreference('MEDIA_DIRECTORY', 'media/'); 727 $filesystem = app(FilesystemInterface::class); 728 $adapter = new ChrootAdapter($filesystem, $media_dir); 729 730 return new Filesystem($adapter); 731 } 732} 733