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