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