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