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