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