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