1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2020 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 <http://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Module; 21 22use Aura\Router\Route; 23use Fisharebest\Webtrees\Auth; 24use Fisharebest\Webtrees\Exceptions\FamilyNotFoundException; 25use Fisharebest\Webtrees\Exceptions\IndividualNotFoundException; 26use Fisharebest\Webtrees\Exceptions\MediaNotFoundException; 27use Fisharebest\Webtrees\Exceptions\NoteNotFoundException; 28use Fisharebest\Webtrees\Exceptions\RepositoryNotFoundException; 29use Fisharebest\Webtrees\Exceptions\SourceNotFoundException; 30use Fisharebest\Webtrees\Factory; 31use Fisharebest\Webtrees\Family; 32use Fisharebest\Webtrees\Gedcom; 33use Fisharebest\Webtrees\GedcomRecord; 34use Fisharebest\Webtrees\Http\RequestHandlers\FamilyPage; 35use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage; 36use Fisharebest\Webtrees\Http\RequestHandlers\MediaPage; 37use Fisharebest\Webtrees\Http\RequestHandlers\NotePage; 38use Fisharebest\Webtrees\Http\RequestHandlers\RepositoryPage; 39use Fisharebest\Webtrees\Http\RequestHandlers\SourcePage; 40use Fisharebest\Webtrees\I18N; 41use Fisharebest\Webtrees\Individual; 42use Fisharebest\Webtrees\Media; 43use Fisharebest\Webtrees\Menu; 44use Fisharebest\Webtrees\Note; 45use Fisharebest\Webtrees\Repository; 46use Fisharebest\Webtrees\Services\GedcomExportService; 47use Fisharebest\Webtrees\Services\UserService; 48use Fisharebest\Webtrees\Session; 49use Fisharebest\Webtrees\Source; 50use Fisharebest\Webtrees\Tree; 51use Illuminate\Support\Collection; 52use League\Flysystem\Filesystem; 53use League\Flysystem\FilesystemInterface; 54use League\Flysystem\ZipArchive\ZipArchiveAdapter; 55use Psr\Http\Message\ResponseFactoryInterface; 56use Psr\Http\Message\ResponseInterface; 57use Psr\Http\Message\ServerRequestInterface; 58use Psr\Http\Message\StreamFactoryInterface; 59use RuntimeException; 60 61use function app; 62use function array_filter; 63use function array_keys; 64use function array_map; 65use function array_search; 66use function assert; 67use function fopen; 68use function in_array; 69use function is_string; 70use function key; 71use function preg_match_all; 72use function redirect; 73use function rewind; 74use function route; 75use function str_replace; 76use function stream_get_meta_data; 77use function strip_tags; 78use function tmpfile; 79use function uasort; 80 81use const PREG_SET_ORDER; 82 83/** 84 * Class ClippingsCartModule 85 */ 86class ClippingsCartModule extends AbstractModule implements ModuleMenuInterface 87{ 88 use ModuleMenuTrait; 89 90 // Routes that have a record which can be added to the clipboard 91 private const ROUTES_WITH_RECORDS = [ 92 'Family' => FamilyPage::class, 93 'Individual' => IndividualPage::class, 94 'Media' => MediaPage::class, 95 'Note' => NotePage::class, 96 'Repository' => RepositoryPage::class, 97 'Source' => SourcePage::class, 98 ]; 99 100 /** @var int The default access level for this module. It can be changed in the control panel. */ 101 protected $access_level = Auth::PRIV_USER; 102 103 /** @var GedcomExportService */ 104 private $gedcom_export_service; 105 106 /** @var UserService */ 107 private $user_service; 108 109 /** 110 * ClippingsCartModule constructor. 111 * 112 * @param GedcomExportService $gedcom_export_service 113 * @param UserService $user_service 114 */ 115 public function __construct(GedcomExportService $gedcom_export_service, UserService $user_service) 116 { 117 $this->gedcom_export_service = $gedcom_export_service; 118 $this->user_service = $user_service; 119 } 120 121 /** 122 * How should this module be identified in the control panel, etc.? 123 * 124 * @return string 125 */ 126 public function title(): string 127 { 128 /* I18N: Name of a module */ 129 return I18N::translate('Clippings cart'); 130 } 131 132 /** 133 * A sentence describing what this module does. 134 * 135 * @return string 136 */ 137 public function description(): string 138 { 139 /* I18N: Description of the “Clippings cart” module */ 140 return I18N::translate('Select records from your family tree and save them as a GEDCOM file.'); 141 } 142 143 /** 144 * The default position for this menu. It can be changed in the control panel. 145 * 146 * @return int 147 */ 148 public function defaultMenuOrder(): int 149 { 150 return 6; 151 } 152 153 /** 154 * A menu, to be added to the main application menu. 155 * 156 * @param Tree $tree 157 * 158 * @return Menu|null 159 */ 160 public function getMenu(Tree $tree): ?Menu 161 { 162 /** @var ServerRequestInterface $request */ 163 $request = app(ServerRequestInterface::class); 164 165 $route = $request->getAttribute('route'); 166 assert($route instanceof Route); 167 168 $submenus = [ 169 new Menu($this->title(), route('module', [ 170 'module' => $this->name(), 171 'action' => 'Show', 172 'tree' => $tree->name(), 173 ]), 'menu-clippings-cart', ['rel' => 'nofollow']), 174 ]; 175 176 $action = array_search($route->name, self::ROUTES_WITH_RECORDS, true); 177 if ($action !== false) { 178 $xref = $route->attributes['xref']; 179 assert(is_string($xref)); 180 181 $add_route = route('module', [ 182 'module' => $this->name(), 183 'action' => 'Add' . $action, 184 'xref' => $xref, 185 'tree' => $tree->name(), 186 ]); 187 188 $submenus[] = new Menu(I18N::translate('Add to the clippings cart'), $add_route, 'menu-clippings-add', ['rel' => 'nofollow']); 189 } 190 191 if (!$this->isCartEmpty($tree)) { 192 $submenus[] = new Menu(I18N::translate('Empty the clippings cart'), route('module', [ 193 'module' => $this->name(), 194 'action' => 'Empty', 195 'tree' => $tree->name(), 196 ]), 'menu-clippings-empty', ['rel' => 'nofollow']); 197 198 $submenus[] = new Menu(I18N::translate('Download'), route('module', [ 199 'module' => $this->name(), 200 'action' => 'DownloadForm', 201 'tree' => $tree->name(), 202 ]), 'menu-clippings-download', ['rel' => 'nofollow']); 203 } 204 205 return new Menu($this->title(), '#', 'menu-clippings', ['rel' => 'nofollow'], $submenus); 206 } 207 208 /** 209 * @param ServerRequestInterface $request 210 * 211 * @return ResponseInterface 212 */ 213 public function postDownloadAction(ServerRequestInterface $request): ResponseInterface 214 { 215 $tree = $request->getAttribute('tree'); 216 assert($tree instanceof Tree); 217 218 $data_filesystem = $request->getAttribute('filesystem.data'); 219 assert($data_filesystem instanceof FilesystemInterface); 220 221 $params = (array) $request->getParsedBody(); 222 223 $privatize_export = $params['privatize_export']; 224 225 if ($privatize_export === 'none' && !Auth::isManager($tree)) { 226 $privatize_export = 'member'; 227 } 228 229 if ($privatize_export === 'gedadmin' && !Auth::isManager($tree)) { 230 $privatize_export = 'member'; 231 } 232 233 if ($privatize_export === 'user' && !Auth::isMember($tree)) { 234 $privatize_export = 'visitor'; 235 } 236 237 $convert = (bool) ($params['convert'] ?? false); 238 239 $cart = Session::get('cart', []); 240 241 $xrefs = array_keys($cart[$tree->name()] ?? []); 242 $xrefs = array_map('strval', $xrefs); // PHP converts numeric keys to integers. 243 244 // Create a new/empty .ZIP file 245 $temp_zip_file = stream_get_meta_data(tmpfile())['uri']; 246 $zip_adapter = new ZipArchiveAdapter($temp_zip_file); 247 $zip_filesystem = new Filesystem($zip_adapter); 248 249 $media_filesystem = $tree->mediaFilesystem($data_filesystem); 250 251 // Media file prefix 252 $path = $tree->getPreference('MEDIA_DIRECTORY'); 253 254 $encoding = $convert ? 'ANSI' : 'UTF-8'; 255 256 $records = new Collection(); 257 258 switch ($privatize_export) { 259 case 'gedadmin': 260 $access_level = Auth::PRIV_NONE; 261 break; 262 case 'user': 263 $access_level = Auth::PRIV_USER; 264 break; 265 case 'visitor': 266 $access_level = Auth::PRIV_PRIVATE; 267 break; 268 case 'none': 269 default: 270 $access_level = Auth::PRIV_HIDE; 271 break; 272 } 273 274 foreach ($xrefs as $xref) { 275 $object = Factory::gedcomRecord()->make($xref, $tree); 276 // The object may have been deleted since we added it to the cart.... 277 if ($object instanceof GedcomRecord) { 278 $record = $object->privatizeGedcom($access_level); 279 // Remove links to objects that aren't in the cart 280 preg_match_all('/\n1 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(\n[2-9].*)*/', $record, $matches, PREG_SET_ORDER); 281 foreach ($matches as $match) { 282 if (!in_array($match[1], $xrefs, true)) { 283 $record = str_replace($match[0], '', $record); 284 } 285 } 286 preg_match_all('/\n2 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(\n[3-9].*)*/', $record, $matches, PREG_SET_ORDER); 287 foreach ($matches as $match) { 288 if (!in_array($match[1], $xrefs, true)) { 289 $record = str_replace($match[0], '', $record); 290 } 291 } 292 preg_match_all('/\n3 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(\n[4-9].*)*/', $record, $matches, PREG_SET_ORDER); 293 foreach ($matches as $match) { 294 if (!in_array($match[1], $xrefs, true)) { 295 $record = str_replace($match[0], '', $record); 296 } 297 } 298 299 if ($object instanceof Individual || $object instanceof Family) { 300 $records->add($record . "\n1 SOUR @WEBTREES@\n2 PAGE " . $object->url()); 301 } elseif ($object instanceof Source) { 302 $records->add($record . "\n1 NOTE " . $object->url()); 303 } elseif ($object instanceof Media) { 304 // Add the media files to the archive 305 foreach ($object->mediaFiles() as $media_file) { 306 $from = $media_file->filename(); 307 $to = $path . $media_file->filename(); 308 if (!$media_file->isExternal() && $media_filesystem->has($from) && !$zip_filesystem->has($to)) { 309 $zip_filesystem->writeStream($to, $media_filesystem->readStream($from)); 310 } 311 } 312 $records->add($record); 313 } else { 314 $records->add($record); 315 } 316 } 317 } 318 319 $base_url = $request->getAttribute('base_url'); 320 321 // Create a source, to indicate the source of the data. 322 $record = "0 @WEBTREES@ SOUR\n1 TITL " . $base_url; 323 $author = $this->user_service->find((int) $tree->getPreference('CONTACT_USER_ID')); 324 if ($author !== null) { 325 $record .= "\n1 AUTH " . $author->realName(); 326 } 327 $records->add($record); 328 329 $stream = fopen('php://temp', 'wb+'); 330 331 if ($stream === false) { 332 throw new RuntimeException('Failed to create temporary stream'); 333 } 334 335 // We have already applied privacy filtering, so do not do it again. 336 $this->gedcom_export_service->export($tree, $stream, false, $encoding, Auth::PRIV_HIDE, $path, $records); 337 rewind($stream); 338 339 // Finally add the GEDCOM file to the .ZIP file. 340 $zip_filesystem->writeStream('clippings.ged', $stream); 341 342 // Need to force-close ZipArchive filesystems. 343 $zip_adapter->getArchive()->close(); 344 345 // Use a stream, so that we do not have to load the entire file into memory. 346 $stream = app(StreamFactoryInterface::class)->createStreamFromFile($temp_zip_file); 347 348 /** @var ResponseFactoryInterface $response_factory */ 349 $response_factory = app(ResponseFactoryInterface::class); 350 351 return $response_factory->createResponse() 352 ->withBody($stream) 353 ->withHeader('Content-Type', 'application/zip') 354 ->withHeader('Content-Disposition', 'attachment; filename="clippings.zip'); 355 } 356 357 /** 358 * @param ServerRequestInterface $request 359 * 360 * @return ResponseInterface 361 */ 362 public function getDownloadFormAction(ServerRequestInterface $request): ResponseInterface 363 { 364 $tree = $request->getAttribute('tree'); 365 assert($tree instanceof Tree); 366 367 $user = $request->getAttribute('user'); 368 $title = I18N::translate('Family tree clippings cart') . ' — ' . I18N::translate('Download'); 369 370 return $this->viewResponse('modules/clippings/download', [ 371 'is_manager' => Auth::isManager($tree, $user), 372 'is_member' => Auth::isMember($tree, $user), 373 'module' => $this->name(), 374 'title' => $title, 375 'tree' => $tree, 376 ]); 377 } 378 379 /** 380 * @param ServerRequestInterface $request 381 * 382 * @return ResponseInterface 383 */ 384 public function getEmptyAction(ServerRequestInterface $request): ResponseInterface 385 { 386 $tree = $request->getAttribute('tree'); 387 assert($tree instanceof Tree); 388 389 $cart = Session::get('cart', []); 390 $cart[$tree->name()] = []; 391 Session::put('cart', $cart); 392 393 $url = route('module', [ 394 'module' => $this->name(), 395 'action' => 'Show', 396 'tree' => $tree->name(), 397 ]); 398 399 return redirect($url); 400 } 401 402 /** 403 * @param ServerRequestInterface $request 404 * 405 * @return ResponseInterface 406 */ 407 public function postRemoveAction(ServerRequestInterface $request): ResponseInterface 408 { 409 $tree = $request->getAttribute('tree'); 410 assert($tree instanceof Tree); 411 412 $xref = $request->getQueryParams()['xref']; 413 414 $cart = Session::get('cart', []); 415 unset($cart[$tree->name()][$xref]); 416 Session::put('cart', $cart); 417 418 $url = route('module', [ 419 'module' => $this->name(), 420 'action' => 'Show', 421 'tree' => $tree->name(), 422 ]); 423 424 return redirect($url); 425 } 426 427 /** 428 * @param ServerRequestInterface $request 429 * 430 * @return ResponseInterface 431 */ 432 public function getShowAction(ServerRequestInterface $request): ResponseInterface 433 { 434 $tree = $request->getAttribute('tree'); 435 assert($tree instanceof Tree); 436 437 return $this->viewResponse('modules/clippings/show', [ 438 'records' => $this->allRecordsInCart($tree), 439 'title' => I18N::translate('Family tree clippings cart'), 440 'tree' => $tree, 441 ]); 442 } 443 444 /** 445 * @param ServerRequestInterface $request 446 * 447 * @return ResponseInterface 448 */ 449 public function getAddFamilyAction(ServerRequestInterface $request): ResponseInterface 450 { 451 $tree = $request->getAttribute('tree'); 452 assert($tree instanceof Tree); 453 454 $xref = $request->getQueryParams()['xref']; 455 456 $family = Factory::family()->make($xref, $tree); 457 458 if ($family === null) { 459 throw new FamilyNotFoundException(); 460 } 461 462 $options = $this->familyOptions($family); 463 464 $title = I18N::translate('Add %s to the clippings cart', $family->fullName()); 465 466 return $this->viewResponse('modules/clippings/add-options', [ 467 'options' => $options, 468 'default' => key($options), 469 'record' => $family, 470 'title' => $title, 471 'tree' => $tree, 472 ]); 473 } 474 475 /** 476 * @param Family $family 477 * 478 * @return string[] 479 */ 480 private function familyOptions(Family $family): array 481 { 482 $name = strip_tags($family->fullName()); 483 484 return [ 485 'parents' => $name, 486 /* I18N: %s is a family (husband + wife) */ 487 'members' => I18N::translate('%s and their children', $name), 488 /* I18N: %s is a family (husband + wife) */ 489 'descendants' => I18N::translate('%s and their descendants', $name), 490 ]; 491 } 492 493 /** 494 * @param ServerRequestInterface $request 495 * 496 * @return ResponseInterface 497 */ 498 public function postAddFamilyAction(ServerRequestInterface $request): ResponseInterface 499 { 500 $tree = $request->getAttribute('tree'); 501 assert($tree instanceof Tree); 502 503 $params = (array) $request->getParsedBody(); 504 505 $xref = $params['xref']; 506 $option = $params['option']; 507 508 $family = Factory::family()->make($xref, $tree); 509 510 if ($family === null) { 511 throw new FamilyNotFoundException(); 512 } 513 514 switch ($option) { 515 case 'parents': 516 $this->addFamilyToCart($family); 517 break; 518 519 case 'members': 520 $this->addFamilyAndChildrenToCart($family); 521 break; 522 523 case 'descendants': 524 $this->addFamilyAndDescendantsToCart($family); 525 break; 526 } 527 528 return redirect($family->url()); 529 } 530 531 /** 532 * @param Family $family 533 * 534 * @return void 535 */ 536 private function addFamilyToCart(Family $family): void 537 { 538 $this->addRecordToCart($family); 539 540 foreach ($family->spouses() as $spouse) { 541 $this->addRecordToCart($spouse); 542 } 543 } 544 545 /** 546 * @param Family $family 547 * 548 * @return void 549 */ 550 private function addFamilyAndChildrenToCart(Family $family): void 551 { 552 $this->addRecordToCart($family); 553 554 foreach ($family->spouses() as $spouse) { 555 $this->addRecordToCart($spouse); 556 } 557 foreach ($family->children() as $child) { 558 $this->addRecordToCart($child); 559 } 560 } 561 562 /** 563 * @param Family $family 564 * 565 * @return void 566 */ 567 private function addFamilyAndDescendantsToCart(Family $family): void 568 { 569 $this->addRecordToCart($family); 570 571 foreach ($family->spouses() as $spouse) { 572 $this->addRecordToCart($spouse); 573 } 574 foreach ($family->children() as $child) { 575 $this->addRecordToCart($child); 576 foreach ($child->spouseFamilies() as $child_family) { 577 $this->addFamilyAndDescendantsToCart($child_family); 578 } 579 } 580 } 581 582 /** 583 * @param ServerRequestInterface $request 584 * 585 * @return ResponseInterface 586 */ 587 public function getAddIndividualAction(ServerRequestInterface $request): ResponseInterface 588 { 589 $tree = $request->getAttribute('tree'); 590 assert($tree instanceof Tree); 591 592 $xref = $request->getQueryParams()['xref']; 593 594 $individual = Factory::individual()->make($xref, $tree); 595 596 if ($individual === null) { 597 throw new IndividualNotFoundException(); 598 } 599 600 $options = $this->individualOptions($individual); 601 602 $title = I18N::translate('Add %s to the clippings cart', $individual->fullName()); 603 604 return $this->viewResponse('modules/clippings/add-options', [ 605 'options' => $options, 606 'default' => key($options), 607 'record' => $individual, 608 'title' => $title, 609 'tree' => $tree, 610 ]); 611 } 612 613 /** 614 * @param Individual $individual 615 * 616 * @return string[] 617 */ 618 private function individualOptions(Individual $individual): array 619 { 620 $name = strip_tags($individual->fullName()); 621 622 if ($individual->sex() === 'F') { 623 return [ 624 'self' => $name, 625 'parents' => I18N::translate('%s, her parents and siblings', $name), 626 'spouses' => I18N::translate('%s, her spouses and children', $name), 627 'ancestors' => I18N::translate('%s and her ancestors', $name), 628 'ancestor_families' => I18N::translate('%s, her ancestors and their families', $name), 629 'descendants' => I18N::translate('%s, her spouses and descendants', $name), 630 ]; 631 } 632 633 return [ 634 'self' => $name, 635 'parents' => I18N::translate('%s, his parents and siblings', $name), 636 'spouses' => I18N::translate('%s, his spouses and children', $name), 637 'ancestors' => I18N::translate('%s and his ancestors', $name), 638 'ancestor_families' => I18N::translate('%s, his ancestors and their families', $name), 639 'descendants' => I18N::translate('%s, his spouses and descendants', $name), 640 ]; 641 } 642 643 /** 644 * @param ServerRequestInterface $request 645 * 646 * @return ResponseInterface 647 */ 648 public function postAddIndividualAction(ServerRequestInterface $request): ResponseInterface 649 { 650 $tree = $request->getAttribute('tree'); 651 assert($tree instanceof Tree); 652 653 $params = (array) $request->getParsedBody(); 654 655 $xref = $params['xref']; 656 $option = $params['option']; 657 658 $individual = Factory::individual()->make($xref, $tree); 659 660 if ($individual === null) { 661 throw new IndividualNotFoundException(); 662 } 663 664 switch ($option) { 665 case 'self': 666 $this->addRecordToCart($individual); 667 break; 668 669 case 'parents': 670 foreach ($individual->childFamilies() as $family) { 671 $this->addFamilyAndChildrenToCart($family); 672 } 673 break; 674 675 case 'spouses': 676 foreach ($individual->spouseFamilies() as $family) { 677 $this->addFamilyAndChildrenToCart($family); 678 } 679 break; 680 681 case 'ancestors': 682 $this->addAncestorsToCart($individual); 683 break; 684 685 case 'ancestor_families': 686 $this->addAncestorFamiliesToCart($individual); 687 break; 688 689 case 'descendants': 690 foreach ($individual->spouseFamilies() as $family) { 691 $this->addFamilyAndDescendantsToCart($family); 692 } 693 break; 694 } 695 696 return redirect($individual->url()); 697 } 698 699 /** 700 * @param Individual $individual 701 * 702 * @return void 703 */ 704 private function addAncestorsToCart(Individual $individual): void 705 { 706 $this->addRecordToCart($individual); 707 708 foreach ($individual->childFamilies() as $family) { 709 $this->addRecordToCart($family); 710 711 foreach ($family->spouses() as $parent) { 712 $this->addAncestorsToCart($parent); 713 } 714 } 715 } 716 717 /** 718 * @param Individual $individual 719 * 720 * @return void 721 */ 722 private function addAncestorFamiliesToCart(Individual $individual): void 723 { 724 foreach ($individual->childFamilies() as $family) { 725 $this->addFamilyAndChildrenToCart($family); 726 727 foreach ($family->spouses() as $parent) { 728 $this->addAncestorFamiliesToCart($parent); 729 } 730 } 731 } 732 733 /** 734 * @param ServerRequestInterface $request 735 * 736 * @return ResponseInterface 737 */ 738 public function getAddMediaAction(ServerRequestInterface $request): ResponseInterface 739 { 740 $tree = $request->getAttribute('tree'); 741 assert($tree instanceof Tree); 742 743 $xref = $request->getQueryParams()['xref']; 744 745 $media = Factory::media()->make($xref, $tree); 746 747 if ($media === null) { 748 throw new MediaNotFoundException(); 749 } 750 751 $options = $this->mediaOptions($media); 752 753 $title = I18N::translate('Add %s to the clippings cart', $media->fullName()); 754 755 return $this->viewResponse('modules/clippings/add-options', [ 756 'options' => $options, 757 'default' => key($options), 758 'record' => $media, 759 'title' => $title, 760 'tree' => $tree, 761 ]); 762 } 763 764 /** 765 * @param Media $media 766 * 767 * @return string[] 768 */ 769 private function mediaOptions(Media $media): array 770 { 771 $name = strip_tags($media->fullName()); 772 773 return [ 774 'self' => $name, 775 ]; 776 } 777 778 /** 779 * @param ServerRequestInterface $request 780 * 781 * @return ResponseInterface 782 */ 783 public function postAddMediaAction(ServerRequestInterface $request): ResponseInterface 784 { 785 $tree = $request->getAttribute('tree'); 786 assert($tree instanceof Tree); 787 788 $xref = $request->getQueryParams()['xref']; 789 790 $media = Factory::media()->make($xref, $tree); 791 792 if ($media === null) { 793 throw new MediaNotFoundException(); 794 } 795 796 $this->addRecordToCart($media); 797 798 return redirect($media->url()); 799 } 800 801 /** 802 * @param ServerRequestInterface $request 803 * 804 * @return ResponseInterface 805 */ 806 public function getAddNoteAction(ServerRequestInterface $request): ResponseInterface 807 { 808 $tree = $request->getAttribute('tree'); 809 assert($tree instanceof Tree); 810 811 $xref = $request->getQueryParams()['xref']; 812 813 $note = Factory::note()->make($xref, $tree); 814 815 if ($note === null) { 816 throw new NoteNotFoundException(); 817 } 818 819 $options = $this->noteOptions($note); 820 821 $title = I18N::translate('Add %s to the clippings cart', $note->fullName()); 822 823 return $this->viewResponse('modules/clippings/add-options', [ 824 'options' => $options, 825 'default' => key($options), 826 'record' => $note, 827 'title' => $title, 828 'tree' => $tree, 829 ]); 830 } 831 832 /** 833 * @param Note $note 834 * 835 * @return string[] 836 */ 837 private function noteOptions(Note $note): array 838 { 839 $name = strip_tags($note->fullName()); 840 841 return [ 842 'self' => $name, 843 ]; 844 } 845 846 /** 847 * @param ServerRequestInterface $request 848 * 849 * @return ResponseInterface 850 */ 851 public function postAddNoteAction(ServerRequestInterface $request): ResponseInterface 852 { 853 $tree = $request->getAttribute('tree'); 854 assert($tree instanceof Tree); 855 856 $xref = $request->getQueryParams()['xref']; 857 858 $note = Factory::note()->make($xref, $tree); 859 860 if ($note === null) { 861 throw new NoteNotFoundException(); 862 } 863 864 $this->addRecordToCart($note); 865 866 return redirect($note->url()); 867 } 868 869 /** 870 * @param ServerRequestInterface $request 871 * 872 * @return ResponseInterface 873 */ 874 public function getAddRepositoryAction(ServerRequestInterface $request): ResponseInterface 875 { 876 $tree = $request->getAttribute('tree'); 877 assert($tree instanceof Tree); 878 879 $xref = $request->getQueryParams()['xref']; 880 881 $repository = Factory::repository()->make($xref, $tree); 882 883 if ($repository === null) { 884 throw new RepositoryNotFoundException(); 885 } 886 887 $options = $this->repositoryOptions($repository); 888 889 $title = I18N::translate('Add %s to the clippings cart', $repository->fullName()); 890 891 return $this->viewResponse('modules/clippings/add-options', [ 892 'options' => $options, 893 'default' => key($options), 894 'record' => $repository, 895 'title' => $title, 896 'tree' => $tree, 897 ]); 898 } 899 900 /** 901 * @param Repository $repository 902 * 903 * @return string[] 904 */ 905 private function repositoryOptions(Repository $repository): array 906 { 907 $name = strip_tags($repository->fullName()); 908 909 return [ 910 'self' => $name, 911 ]; 912 } 913 914 /** 915 * @param ServerRequestInterface $request 916 * 917 * @return ResponseInterface 918 */ 919 public function postAddRepositoryAction(ServerRequestInterface $request): ResponseInterface 920 { 921 $tree = $request->getAttribute('tree'); 922 assert($tree instanceof Tree); 923 924 $xref = $request->getQueryParams()['xref']; 925 926 $repository = Factory::repository()->make($xref, $tree); 927 928 if ($repository === null) { 929 throw new RepositoryNotFoundException(); 930 } 931 932 $this->addRecordToCart($repository); 933 934 return redirect($repository->url()); 935 } 936 937 /** 938 * @param ServerRequestInterface $request 939 * 940 * @return ResponseInterface 941 */ 942 public function getAddSourceAction(ServerRequestInterface $request): ResponseInterface 943 { 944 $tree = $request->getAttribute('tree'); 945 assert($tree instanceof Tree); 946 947 $xref = $request->getQueryParams()['xref']; 948 949 $source = Factory::source()->make($xref, $tree); 950 951 if ($source === null) { 952 throw new SourceNotFoundException(); 953 } 954 955 $options = $this->sourceOptions($source); 956 957 $title = I18N::translate('Add %s to the clippings cart', $source->fullName()); 958 959 return $this->viewResponse('modules/clippings/add-options', [ 960 'options' => $options, 961 'default' => key($options), 962 'record' => $source, 963 'title' => $title, 964 'tree' => $tree, 965 ]); 966 } 967 968 /** 969 * @param Source $source 970 * 971 * @return string[] 972 */ 973 private function sourceOptions(Source $source): array 974 { 975 $name = strip_tags($source->fullName()); 976 977 return [ 978 'only' => strip_tags($source->fullName()), 979 'linked' => I18N::translate('%s and the individuals that reference it.', $name), 980 ]; 981 } 982 983 /** 984 * @param ServerRequestInterface $request 985 * 986 * @return ResponseInterface 987 */ 988 public function postAddSourceAction(ServerRequestInterface $request): ResponseInterface 989 { 990 $tree = $request->getAttribute('tree'); 991 assert($tree instanceof Tree); 992 993 $params = (array) $request->getParsedBody(); 994 995 $xref = $params['xref']; 996 $option = $params['option']; 997 998 $source = Factory::source()->make($xref, $tree); 999 1000 if ($source === null) { 1001 throw new SourceNotFoundException(); 1002 } 1003 1004 $this->addRecordToCart($source); 1005 1006 if ($option === 'linked') { 1007 foreach ($source->linkedIndividuals('SOUR') as $individual) { 1008 $this->addRecordToCart($individual); 1009 } 1010 foreach ($source->linkedFamilies('SOUR') as $family) { 1011 $this->addRecordToCart($family); 1012 } 1013 } 1014 1015 return redirect($source->url()); 1016 } 1017 1018 /** 1019 * Get all the records in the cart. 1020 * 1021 * @param Tree $tree 1022 * 1023 * @return GedcomRecord[] 1024 */ 1025 private function allRecordsInCart(Tree $tree): array 1026 { 1027 $cart = Session::get('cart', []); 1028 1029 $xrefs = array_keys($cart[$tree->name()] ?? []); 1030 $xrefs = array_map('strval', $xrefs); // PHP converts numeric keys to integers. 1031 1032 // Fetch all the records in the cart. 1033 $records = array_map(static function (string $xref) use ($tree): ?GedcomRecord { 1034 return Factory::gedcomRecord()->make($xref, $tree); 1035 }, $xrefs); 1036 1037 // Some records may have been deleted after they were added to the cart. 1038 $records = array_filter($records); 1039 1040 // Group and sort. 1041 uasort($records, static function (GedcomRecord $x, GedcomRecord $y): int { 1042 return $x::RECORD_TYPE <=> $y::RECORD_TYPE ?: GedcomRecord::nameComparator()($x, $y); 1043 }); 1044 1045 return $records; 1046 } 1047 1048 /** 1049 * Add a record (and direclty linked sources, notes, etc. to the cart. 1050 * 1051 * @param GedcomRecord $record 1052 * 1053 * @return void 1054 */ 1055 private function addRecordToCart(GedcomRecord $record): void 1056 { 1057 $cart = Session::get('cart', []); 1058 1059 $tree_name = $record->tree()->name(); 1060 1061 // Add this record 1062 $cart[$tree_name][$record->xref()] = true; 1063 1064 // Add directly linked media, notes, repositories and sources. 1065 preg_match_all('/\n\d (?:OBJE|NOTE|SOUR|REPO) @(' . Gedcom::REGEX_XREF . ')@/', $record->gedcom(), $matches); 1066 1067 foreach ($matches[1] as $match) { 1068 $cart[$tree_name][$match] = true; 1069 } 1070 1071 Session::put('cart', $cart); 1072 } 1073 1074 /** 1075 * @param Tree $tree 1076 * 1077 * @return bool 1078 */ 1079 private function isCartEmpty(Tree $tree): bool 1080 { 1081 $cart = Session::get('cart', []); 1082 $contents = $cart[$tree->name()] ?? []; 1083 1084 return $contents === []; 1085 } 1086} 1087