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