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