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