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