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