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