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