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