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