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