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