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