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