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