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