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