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