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 '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 'user_id' => Auth::id(), 423 ]); 424 425 // Accept this pending change 426 if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') { 427 $record = Registry::familyFactory()->new($xref, $gedcom, null, $this); 428 429 $pending_changes_service = Registry::container()->get(PendingChangesService::class); 430 $pending_changes_service->acceptRecord($record); 431 432 return $record; 433 } 434 435 return Registry::familyFactory()->new($xref, '', $gedcom, $this); 436 } 437 438 /** 439 * Create a new individual from GEDCOM data. 440 * 441 * @param string $gedcom 442 * 443 * @return Individual 444 * @throws InvalidArgumentException 445 */ 446 public function createIndividual(string $gedcom): GedcomRecord 447 { 448 if (!str_starts_with($gedcom, '0 @@ INDI')) { 449 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI'); 450 } 451 452 $xref = Registry::xrefFactory()->make(Individual::RECORD_TYPE); 453 $gedcom = substr_replace($gedcom, $xref, 3, 0); 454 455 // Create a change record 456 $today = strtoupper(date('d M Y')); 457 $now = date('H:i:s'); 458 $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName(); 459 460 // Create a pending change 461 DB::table('change')->insert([ 462 'gedcom_id' => $this->id, 463 'xref' => $xref, 464 'old_gedcom' => '', 465 'new_gedcom' => $gedcom, 466 'user_id' => Auth::id(), 467 ]); 468 469 // Accept this pending change 470 if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') { 471 $record = Registry::individualFactory()->new($xref, $gedcom, null, $this); 472 473 $pending_changes_service = Registry::container()->get(PendingChangesService::class); 474 $pending_changes_service->acceptRecord($record); 475 476 return $record; 477 } 478 479 return Registry::individualFactory()->new($xref, '', $gedcom, $this); 480 } 481 482 /** 483 * Create a new media object from GEDCOM data. 484 * 485 * @param string $gedcom 486 * 487 * @return Media 488 * @throws InvalidArgumentException 489 */ 490 public function createMediaObject(string $gedcom): Media 491 { 492 if (!str_starts_with($gedcom, '0 @@ OBJE')) { 493 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE'); 494 } 495 496 $xref = Registry::xrefFactory()->make(Media::RECORD_TYPE); 497 $gedcom = substr_replace($gedcom, $xref, 3, 0); 498 499 // Create a change record 500 $today = strtoupper(date('d M Y')); 501 $now = date('H:i:s'); 502 $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName(); 503 504 // Create a pending change 505 DB::table('change')->insert([ 506 'gedcom_id' => $this->id, 507 'xref' => $xref, 508 'old_gedcom' => '', 509 'new_gedcom' => $gedcom, 510 'user_id' => Auth::id(), 511 ]); 512 513 // Accept this pending change 514 if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') { 515 $record = Registry::mediaFactory()->new($xref, $gedcom, null, $this); 516 517 $pending_changes_service = Registry::container()->get(PendingChangesService::class); 518 $pending_changes_service->acceptRecord($record); 519 520 return $record; 521 } 522 523 return Registry::mediaFactory()->new($xref, '', $gedcom, $this); 524 } 525 526 /** 527 * What is the most significant individual in this tree. 528 * 529 * @param UserInterface $user 530 * @param string $xref 531 * 532 * @return Individual 533 */ 534 public function significantIndividual(UserInterface $user, string $xref = ''): Individual 535 { 536 if ($xref === '') { 537 $individual = null; 538 } else { 539 $individual = Registry::individualFactory()->make($xref, $this); 540 541 if ($individual === null) { 542 $family = Registry::familyFactory()->make($xref, $this); 543 544 if ($family instanceof Family) { 545 $individual = $family->spouses()->first() ?? $family->children()->first(); 546 } 547 } 548 } 549 550 if ($individual === null && $this->getUserPreference($user, UserInterface::PREF_TREE_DEFAULT_XREF) !== '') { 551 $individual = Registry::individualFactory()->make($this->getUserPreference($user, UserInterface::PREF_TREE_DEFAULT_XREF), $this); 552 } 553 554 if ($individual === null && $this->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF) !== '') { 555 $individual = Registry::individualFactory()->make($this->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF), $this); 556 } 557 558 if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') { 559 $individual = Registry::individualFactory()->make($this->getPreference('PEDIGREE_ROOT_ID'), $this); 560 } 561 if ($individual === null) { 562 $xref = DB::table('individuals') 563 ->where('i_file', '=', $this->id()) 564 ->min('i_id'); 565 566 if (is_string($xref)) { 567 $individual = Registry::individualFactory()->make($xref, $this); 568 } 569 } 570 if ($individual === null) { 571 // always return a record 572 $individual = Registry::individualFactory()->new('I', '0 @I@ INDI', null, $this); 573 } 574 575 return $individual; 576 } 577 578 /** 579 * Where do we store our media files. 580 * 581 * @return FilesystemOperator 582 */ 583 public function mediaFilesystem(): FilesystemOperator 584 { 585 return Registry::filesystem()->data($this->getPreference('MEDIA_DIRECTORY')); 586 } 587} 588