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\FilesystemOperator; 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 * Create a new record from GEDCOM data. 316 * 317 * @param string $gedcom 318 * 319 * @return GedcomRecord|Individual|Family|Location|Note|Source|Repository|Media|Submitter|Submission 320 * @throws InvalidArgumentException 321 */ 322 public function createRecord(string $gedcom): GedcomRecord 323 { 324 if (!preg_match('/^0 @@ ([_A-Z]+)/', $gedcom, $match)) { 325 throw new InvalidArgumentException('GedcomRecord::createRecord(' . $gedcom . ') does not begin 0 @@'); 326 } 327 328 $xref = Registry::xrefFactory()->make($match[1]); 329 $gedcom = substr_replace($gedcom, $xref, 3, 0); 330 331 // Create a change record 332 $today = strtoupper(date('d M Y')); 333 $now = date('H:i:s'); 334 $gedcom .= "\n1 CHAN\n2 DATE " . $today . "\n3 TIME " . $now . "\n2 _WT_USER " . Auth::user()->userName(); 335 336 // Create a pending change 337 DB::table('change')->insert([ 338 'gedcom_id' => $this->id, 339 'xref' => $xref, 340 'old_gedcom' => '', 341 'new_gedcom' => $gedcom, 342 'user_id' => Auth::id(), 343 ]); 344 345 // Accept this pending change 346 if (Auth::user()->getPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS) === '1') { 347 $record = Registry::gedcomRecordFactory()->new($xref, $gedcom, null, $this); 348 349 app(PendingChangesService::class)->acceptRecord($record); 350 351 return $record; 352 } 353 354 return Registry::gedcomRecordFactory()->new($xref, '', $gedcom, $this); 355 } 356 357 /** 358 * Create a new family from GEDCOM data. 359 * 360 * @param string $gedcom 361 * 362 * @return Family 363 * @throws InvalidArgumentException 364 */ 365 public function createFamily(string $gedcom): GedcomRecord 366 { 367 if (!str_starts_with($gedcom, '0 @@ FAM')) { 368 throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM'); 369 } 370 371 $xref = Registry::xrefFactory()->make(Family::RECORD_TYPE); 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::familyFactory()->new($xref, $gedcom, null, $this); 391 392 app(PendingChangesService::class)->acceptRecord($record); 393 394 return $record; 395 } 396 397 return Registry::familyFactory()->new($xref, '', $gedcom, $this); 398 } 399 400 /** 401 * Create a new individual from GEDCOM data. 402 * 403 * @param string $gedcom 404 * 405 * @return Individual 406 * @throws InvalidArgumentException 407 */ 408 public function createIndividual(string $gedcom): GedcomRecord 409 { 410 if (!str_starts_with($gedcom, '0 @@ INDI')) { 411 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI'); 412 } 413 414 $xref = Registry::xrefFactory()->make(Individual::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::individualFactory()->new($xref, $gedcom, null, $this); 434 435 app(PendingChangesService::class)->acceptRecord($record); 436 437 return $record; 438 } 439 440 return Registry::individualFactory()->new($xref, '', $gedcom, $this); 441 } 442 443 /** 444 * Create a new media object from GEDCOM data. 445 * 446 * @param string $gedcom 447 * 448 * @return Media 449 * @throws InvalidArgumentException 450 */ 451 public function createMediaObject(string $gedcom): Media 452 { 453 if (!str_starts_with($gedcom, '0 @@ OBJE')) { 454 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE'); 455 } 456 457 $xref = Registry::xrefFactory()->make(Media::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::mediaFactory()->new($xref, $gedcom, null, $this); 477 478 app(PendingChangesService::class)->acceptRecord($record); 479 480 return $record; 481 } 482 483 return Registry::mediaFactory()->new($xref, '', $gedcom, $this); 484 } 485 486 /** 487 * What is the most significant individual in this tree. 488 * 489 * @param UserInterface $user 490 * @param string $xref 491 * 492 * @return Individual 493 */ 494 public function significantIndividual(UserInterface $user, $xref = ''): Individual 495 { 496 if ($xref === '') { 497 $individual = null; 498 } else { 499 $individual = Registry::individualFactory()->make($xref, $this); 500 501 if ($individual === null) { 502 $family = Registry::familyFactory()->make($xref, $this); 503 504 if ($family instanceof Family) { 505 $individual = $family->spouses()->first() ?? $family->children()->first(); 506 } 507 } 508 } 509 510 if ($individual === null && $this->getUserPreference($user, UserInterface::PREF_TREE_DEFAULT_XREF) !== '') { 511 $individual = Registry::individualFactory()->make($this->getUserPreference($user, UserInterface::PREF_TREE_DEFAULT_XREF), $this); 512 } 513 514 if ($individual === null && $this->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF) !== '') { 515 $individual = Registry::individualFactory()->make($this->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF), $this); 516 } 517 518 if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') { 519 $individual = Registry::individualFactory()->make($this->getPreference('PEDIGREE_ROOT_ID'), $this); 520 } 521 if ($individual === null) { 522 $xref = (string) DB::table('individuals') 523 ->where('i_file', '=', $this->id()) 524 ->min('i_id'); 525 526 $individual = Registry::individualFactory()->make($xref, $this); 527 } 528 if ($individual === null) { 529 // always return a record 530 $individual = Registry::individualFactory()->new('I', '0 @I@ INDI', null, $this); 531 } 532 533 return $individual; 534 } 535 536 /** 537 * Where do we store our media files. 538 * 539 * @param FilesystemOperator $data_filesystem 540 * 541 * @return FilesystemOperator 542 */ 543 public function mediaFilesystem(FilesystemOperator $data_filesystem): FilesystemOperator 544 { 545 $media_dir = $this->getPreference('MEDIA_DIRECTORY', 'media/'); 546 $adapter = new ChrootAdapter($data_filesystem, $media_dir); 547 548 return new Filesystem($adapter); 549 } 550} 551