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