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