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