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