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