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