xref: /webtrees/app/Module/ClippingsCartModule.php (revision 1588a6dc6917beb43ceff4d8b55e2582d1bf9202)
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                switch ($object::RECORD_TYPE) {
247                    case 'INDI':
248                    case 'FAM':
249                        $filetext .= $record . "\n";
250                        $filetext .= "1 SOUR @WEBTREES@\n";
251                        $filetext .= '2 PAGE ' . WT_BASE_URL . $object->url() . "\n";
252                        break;
253                    case 'SOUR':
254                        $filetext .= $record . "\n";
255                        $filetext .= '1 NOTE ' . WT_BASE_URL . $object->url() . "\n";
256                        break;
257                    case 'OBJE':
258                        // Add the file to the archive
259                        foreach ($object->mediaFiles() as $media_file) {
260                            if (file_exists($media_file->getServerFilename())) {
261                                $fp = fopen($media_file->getServerFilename(), 'r');
262                                $zip_filesystem->writeStream($path . $media_file->filename(), $fp);
263                                fclose($fp);
264                            }
265                        }
266                        $filetext .= $record . "\n";
267                        break;
268                    default:
269                        $filetext .= $record . "\n";
270                        break;
271                }
272            }
273        }
274
275        // Create a source, to indicate the source of the data.
276        $filetext .= "0 @WEBTREES@ SOUR\n1 TITL " . WT_BASE_URL . "\n";
277        $author = User::findB($tree->getPreference('CONTACT_USER_ID'));
278        if ($author !== null) {
279            $filetext .= '1 AUTH ' . $author->getRealName() . "\n";
280        }
281        $filetext .= "0 TRLR\n";
282
283        // Make sure the preferred line endings are used
284        $filetext = preg_replace("/[\r\n]+/", Gedcom::EOL, $filetext);
285
286        if ($convert === 'yes') {
287            $filetext = str_replace('UTF-8', 'ANSI', $filetext);
288            $filetext = utf8_decode($filetext);
289        }
290
291        // Finally add the GEDCOM file to the .ZIP file.
292        $zip_filesystem->write('clippings.ged', $filetext);
293
294        // Need to force-close the filesystem
295        $zip_filesystem = null;
296
297        $response = new BinaryFileResponse($temp_zip_file);
298        $response->deleteFileAfterSend(true);
299
300        $response->headers->set('Content-Type', 'application/zip');
301        $response->setContentDisposition(
302            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
303            'clippings.zip'
304        );
305
306        return $response;
307    }
308
309    /**
310     * @param Tree $tree
311     * @param User $user
312     *
313     * @return Response
314     */
315    public function getDownloadFormAction(Tree $tree, User $user): Response
316    {
317        $title = I18N::translate('Family tree clippings cart') . ' — ' . I18N::translate('Download');
318
319        return $this->viewResponse('modules/clippings/download', [
320            'is_manager' => Auth::isManager($tree, $user),
321            'is_member'  => Auth::isMember($tree, $user),
322            'title'      => $title,
323        ]);
324    }
325
326    /**
327     * @param Tree $tree
328     *
329     * @return RedirectResponse
330     */
331    public function getEmptyAction(Tree $tree): RedirectResponse
332    {
333        $cart                   = Session::get('cart', []);
334        $cart[$tree->getName()] = [];
335        Session::put('cart', $cart);
336
337        $url = route('module', [
338            'module' => 'clippings',
339            'action' => 'Show',
340            'ged'    => $tree->getName(),
341        ]);
342
343        return new RedirectResponse($url);
344    }
345
346    /**
347     * @param Request $request
348     * @param Tree    $tree
349     *
350     * @return RedirectResponse
351     */
352    public function postRemoveAction(Request $request, Tree $tree): RedirectResponse
353    {
354        $xref = $request->get('xref');
355
356        $cart = Session::get('cart', []);
357        unset($cart[$tree->getName()][$xref]);
358        Session::put('cart', $cart);
359
360        $url = route('module', [
361            'module' => 'clippings',
362            'action' => 'Show',
363            'ged'    => $tree->getName(),
364        ]);
365
366        return new RedirectResponse($url);
367    }
368
369    /**
370     * @param Tree $tree
371     *
372     * @return Response
373     */
374    public function getShowAction(Tree $tree): Response
375    {
376        return $this->viewResponse('modules/clippings/show', [
377            'records' => $this->allRecordsInCart($tree),
378            'title'   => I18N::translate('Family tree clippings cart'),
379            'tree'    => $tree,
380        ]);
381    }
382
383    /**
384     * @param Request $request
385     * @param Tree    $tree
386     *
387     * @return Response
388     */
389    public function getAddFamilyAction(Request $request, Tree $tree): Response
390    {
391        $xref = $request->get('xref');
392
393        $family = Family::getInstance($xref, $tree);
394
395        if ($family === null) {
396            throw new FamilyNotFoundException();
397        }
398
399        $options = $this->familyOptions($family);
400
401        $title = I18N::translate('Add %s to the clippings cart', $family->getFullName());
402
403        return $this->viewResponse('modules/clippings/add-options', [
404            'options' => $options,
405            'default' => key($options),
406            'record'  => $family,
407            'title'   => $title,
408            'tree'    => $tree,
409        ]);
410    }
411
412    /**
413     * @param Family $family
414     *
415     * @return string[]
416     */
417    private function familyOptions(Family $family): array
418    {
419        $name = strip_tags($family->getFullName());
420
421        return [
422            'parents'     => $name,
423            /* I18N: %s is a family (husband + wife) */
424            'members'     => I18N::translate('%s and their children', $name),
425            /* I18N: %s is a family (husband + wife) */
426            'descendants' => I18N::translate('%s and their descendants', $name),
427        ];
428    }
429
430    /**
431     * @param Request $request
432     * @param Tree    $tree
433     *
434     * @return RedirectResponse
435     */
436    public function postAddFamilyAction(Request $request, Tree $tree): RedirectResponse
437    {
438        $xref   = $request->get('xref');
439        $option = $request->get('option');
440
441        $family = Family::getInstance($xref, $tree);
442
443        if ($family === null) {
444            throw new FamilyNotFoundException();
445        }
446
447        switch ($option) {
448            case 'parents':
449                $this->addFamilyToCart($family);
450                break;
451
452            case 'members':
453                $this->addFamilyAndChildrenToCart($family);
454                break;
455
456            case 'descendants':
457                $this->addFamilyAndDescendantsToCart($family);
458                break;
459        }
460
461        return new RedirectResponse($family->url());
462    }
463
464    /**
465     * @param Family $family
466     *
467     * @return void
468     */
469    private function addFamilyToCart(Family $family)
470    {
471        $this->addRecordToCart($family);
472
473        foreach ($family->getSpouses() as $spouse) {
474            $this->addRecordToCart($spouse);
475        }
476    }
477
478    /**
479     * @param Family $family
480     *
481     * @return void
482     */
483    private function addFamilyAndChildrenToCart(Family $family)
484    {
485        $this->addRecordToCart($family);
486
487        foreach ($family->getSpouses() as $spouse) {
488            $this->addRecordToCart($spouse);
489        }
490        foreach ($family->getChildren() as $child) {
491            $this->addRecordToCart($child);
492        }
493    }
494
495    /**
496     * @param Family $family
497     *
498     * @return void
499     */
500    private function addFamilyAndDescendantsToCart(Family $family)
501    {
502        $this->addRecordToCart($family);
503
504        foreach ($family->getSpouses() as $spouse) {
505            $this->addRecordToCart($spouse);
506        }
507        foreach ($family->getChildren() as $child) {
508            $this->addRecordToCart($child);
509            foreach ($child->getSpouseFamilies() as $child_family) {
510                $this->addFamilyAndDescendantsToCart($child_family);
511            }
512        }
513    }
514
515    /**
516     * @param Request $request
517     * @param Tree    $tree
518     *
519     * @return Response
520     */
521    public function getAddIndividualAction(Request $request, Tree $tree): Response
522    {
523        $xref = $request->get('xref');
524
525        $individual = Individual::getInstance($xref, $tree);
526
527        if ($individual === null) {
528            throw new IndividualNotFoundException();
529        }
530
531        $options = $this->individualOptions($individual);
532
533        $title = I18N::translate('Add %s to the clippings cart', $individual->getFullName());
534
535        return $this->viewResponse('modules/clippings/add-options', [
536            'options' => $options,
537            'default' => key($options),
538            'record'  => $individual,
539            'title'   => $title,
540            'tree'    => $tree,
541        ]);
542    }
543
544    /**
545     * @param Individual $individual
546     *
547     * @return string[]
548     */
549    private function individualOptions(Individual $individual): array
550    {
551        $name = strip_tags($individual->getFullName());
552
553        if ($individual->getSex() === 'F') {
554            return [
555                'self'              => $name,
556                'parents'           => I18N::translate('%s, her parents and siblings', $name),
557                'spouses'           => I18N::translate('%s, her spouses and children', $name),
558                'ancestors'         => I18N::translate('%s and her ancestors', $name),
559                'ancestor_families' => I18N::translate('%s, her ancestors and their families', $name),
560                'descendants'       => I18N::translate('%s, her spouses and descendants', $name),
561            ];
562        }
563
564        return [
565            'self'              => $name,
566            'parents'           => I18N::translate('%s, his parents and siblings', $name),
567            'spouses'           => I18N::translate('%s, his spouses and children', $name),
568            'ancestors'         => I18N::translate('%s and his ancestors', $name),
569            'ancestor_families' => I18N::translate('%s, his ancestors and their families', $name),
570            'descendants'       => I18N::translate('%s, his spouses and descendants', $name),
571        ];
572    }
573
574    /**
575     * @param Request $request
576     * @param Tree    $tree
577     *
578     * @return RedirectResponse
579     */
580    public function postAddIndividualAction(Request $request, Tree $tree): RedirectResponse
581    {
582        $xref   = $request->get('xref');
583        $option = $request->get('option');
584
585        $individual = Individual::getInstance($xref, $tree);
586
587        if ($individual === null) {
588            throw new IndividualNotFoundException();
589        }
590
591        switch ($option) {
592            case 'self':
593                $this->addRecordToCart($individual);
594                break;
595
596            case 'parents':
597                foreach ($individual->getChildFamilies() as $family) {
598                    $this->addFamilyAndChildrenToCart($family);
599                }
600                break;
601
602            case 'spouses':
603                foreach ($individual->getSpouseFamilies() as $family) {
604                    $this->addFamilyAndChildrenToCart($family);
605                }
606                break;
607
608            case 'ancestors':
609                $this->addAncestorsToCart($individual);
610                break;
611
612            case 'ancestor_families':
613                $this->addAncestorFamiliesToCart($individual);
614                break;
615
616            case 'descendants':
617                foreach ($individual->getSpouseFamilies() as $family) {
618                    $this->addFamilyAndDescendantsToCart($family);
619                }
620                break;
621        }
622
623        return new RedirectResponse($individual->url());
624    }
625
626    /**
627     * @param Individual $individual
628     *
629     * @return void
630     */
631    private function addAncestorsToCart(Individual $individual)
632    {
633        $this->addRecordToCart($individual);
634
635        foreach ($individual->getChildFamilies() as $family) {
636            foreach ($family->getSpouses() as $parent) {
637                $this->addAncestorsToCart($parent);
638            }
639        }
640    }
641
642    /**
643     * @param Individual $individual
644     *
645     * @return void
646     */
647    private function addAncestorFamiliesToCart(Individual $individual)
648    {
649        foreach ($individual->getChildFamilies() as $family) {
650            $this->addFamilyAndChildrenToCart($family);
651            foreach ($family->getSpouses() as $parent) {
652                $this->addAncestorsToCart($parent);
653            }
654        }
655    }
656
657    /**
658     * @param Request $request
659     * @param Tree    $tree
660     *
661     * @return Response
662     */
663    public function getAddMediaAction(Request $request, Tree $tree): Response
664    {
665        $xref = $request->get('xref');
666
667        $media = Media::getInstance($xref, $tree);
668
669        if ($media === null) {
670            throw new MediaNotFoundException();
671        }
672
673        $options = $this->mediaOptions($media);
674
675        $title = I18N::translate('Add %s to the clippings cart', $media->getFullName());
676
677        return $this->viewResponse('modules/clippings/add-options', [
678            'options' => $options,
679            'default' => key($options),
680            'record'  => $media,
681            'title'   => $title,
682            'tree'    => $tree,
683        ]);
684    }
685
686    /**
687     * @param Media $media
688     *
689     * @return string[]
690     */
691    private function mediaOptions(Media $media): array
692    {
693        $name = strip_tags($media->getFullName());
694
695        return [
696            'self' => $name,
697        ];
698    }
699
700    /**
701     * @param Request $request
702     * @param Tree    $tree
703     *
704     * @return RedirectResponse
705     */
706    public function postAddMediaAction(Request $request, Tree $tree): RedirectResponse
707    {
708        $xref = $request->get('xref');
709
710        $media = Media::getInstance($xref, $tree);
711
712        if ($media === null) {
713            throw new MediaNotFoundException();
714        }
715
716        $this->addRecordToCart($media);
717
718        return new RedirectResponse($media->url());
719    }
720
721    /**
722     * @param Request $request
723     * @param Tree    $tree
724     *
725     * @return Response
726     */
727    public function getAddNoteAction(Request $request, Tree $tree): Response
728    {
729        $xref = $request->get('xref');
730
731        $note = Note::getInstance($xref, $tree);
732
733        if ($note === null) {
734            throw new NoteNotFoundException();
735        }
736
737        $options = $this->noteOptions($note);
738
739        $title = I18N::translate('Add %s to the clippings cart', $note->getFullName());
740
741        return $this->viewResponse('modules/clippings/add-options', [
742            'options' => $options,
743            'default' => key($options),
744            'record'  => $note,
745            'title'   => $title,
746            'tree'    => $tree,
747        ]);
748    }
749
750    /**
751     * @param Note $note
752     *
753     * @return string[]
754     */
755    private function noteOptions(Note $note): array
756    {
757        $name = strip_tags($note->getFullName());
758
759        return [
760            'self' => $name,
761        ];
762    }
763
764    /**
765     * @param Request $request
766     * @param Tree    $tree
767     *
768     * @return RedirectResponse
769     */
770    public function postAddNoteAction(Request $request, Tree $tree): RedirectResponse
771    {
772        $xref = $request->get('xref');
773
774        $note = Note::getInstance($xref, $tree);
775
776        if ($note === null) {
777            throw new NoteNotFoundException();
778        }
779
780        $this->addRecordToCart($note);
781
782        return new RedirectResponse($note->url());
783    }
784
785    /**
786     * @param Request $request
787     * @param Tree    $tree
788     *
789     * @return Response
790     */
791    public function getAddRepositoryAction(Request $request, Tree $tree): Response
792    {
793        $xref = $request->get('xref');
794
795        $repository = Repository::getInstance($xref, $tree);
796
797        if ($repository === null) {
798            throw new RepositoryNotFoundException();
799        }
800
801        $options = $this->repositoryOptions($repository);
802
803        $title = I18N::translate('Add %s to the clippings cart', $repository->getFullName());
804
805        return $this->viewResponse('modules/clippings/add-options', [
806            'options' => $options,
807            'default' => key($options),
808            'record'  => $repository,
809            'title'   => $title,
810            'tree'    => $tree,
811        ]);
812    }
813
814    /**
815     * @param Repository $repository
816     *
817     * @return string[]
818     */
819    private function repositoryOptions(Repository $repository): array
820    {
821        $name = strip_tags($repository->getFullName());
822
823        return [
824            'self' => $name,
825        ];
826    }
827
828    /**
829     * @param Request $request
830     * @param Tree    $tree
831     *
832     * @return RedirectResponse
833     */
834    public function postAddRepositoryAction(Request $request, Tree $tree): RedirectResponse
835    {
836        $xref = $request->get('xref');
837
838        $repository = Repository::getInstance($xref, $tree);
839
840        if ($repository === null) {
841            throw new RepositoryNotFoundException();
842        }
843
844        $this->addRecordToCart($repository);
845
846        return new RedirectResponse($repository->url());
847    }
848
849    /**
850     * @param Request $request
851     * @param Tree    $tree
852     *
853     * @return Response
854     */
855    public function getAddSourceAction(Request $request, Tree $tree): Response
856    {
857        $xref = $request->get('xref');
858
859        $source = Source::getInstance($xref, $tree);
860
861        if ($source === null) {
862            throw new SourceNotFoundException();
863        }
864
865        $options = $this->sourceOptions($source);
866
867        $title = I18N::translate('Add %s to the clippings cart', $source->getFullName());
868
869        return $this->viewResponse('modules/clippings/add-options', [
870            'options' => $options,
871            'default' => key($options),
872            'record'  => $source,
873            'title'   => $title,
874            'tree'    => $tree,
875        ]);
876    }
877
878    /**
879     * @param Source $source
880     *
881     * @return string[]
882     */
883    private function sourceOptions(Source $source): array
884    {
885        $name = strip_tags($source->getFullName());
886
887        return [
888            'only'   => strip_tags($source->getFullName()),
889            'linked' => I18N::translate('%s and the individuals that reference it.', $name),
890        ];
891    }
892
893    /**
894     * @param Request $request
895     * @param Tree    $tree
896     *
897     * @return RedirectResponse
898     */
899    public function postAddSourceAction(Request $request, Tree $tree): RedirectResponse
900    {
901        $xref   = $request->get('xref');
902        $option = $request->get('option');
903
904        $source = Source::getInstance($xref, $tree);
905
906        if ($source === null) {
907            throw new SourceNotFoundException();
908        }
909
910        $this->addRecordToCart($source);
911
912        if ($option === 'linked') {
913            foreach ($source->linkedIndividuals('SOUR') as $individual) {
914                $this->addRecordToCart($individual);
915            }
916            foreach ($source->linkedFamilies('SOUR') as $family) {
917                $this->addRecordToCart($family);
918            }
919        }
920
921        return new RedirectResponse($source->url());
922    }
923
924    /**
925     * Get all the records in the cart.
926     *
927     * @param Tree $tree
928     *
929     * @return GedcomRecord[]
930     */
931    private function allRecordsInCart(Tree $tree): array
932    {
933        $cart = Session::get('cart', []);
934
935        $xrefs = array_keys($cart[$tree->getName()] ?? []);
936
937        // Fetch all the records in the cart.
938        $records = array_map(function (string $xref) use ($tree): GedcomRecord {
939            return GedcomRecord::getInstance($xref, $tree);
940        }, $xrefs);
941
942        // Some records may have been deleted after they were added to the cart.
943        $records = array_filter($records);
944
945        // Group and sort.
946        uasort($records, function (GedcomRecord $x, GedcomRecord $y): int {
947            return $x::RECORD_TYPE <=> $y::RECORD_TYPE ?: GedcomRecord::compare($x, $y);
948        });
949
950        return $records;
951    }
952
953    /**
954     * Add a record (and direclty linked sources, notes, etc. to the cart.
955     *
956     * @param GedcomRecord $record
957     *
958     * @return void
959     */
960    private function addRecordToCart(GedcomRecord $record)
961    {
962        $cart = Session::get('cart', []);
963
964        $tree_name = $record->getTree()->getName();
965
966        // Add this record
967        $cart[$tree_name][$record->getXref()] = true;
968
969        // Add directly linked media, notes, repositories and sources.
970        preg_match_all('/\n\d (?:OBJE|NOTE|SOUR|REPO) @(' . WT_REGEX_XREF . ')@/', $record->getGedcom(), $matches);
971
972        foreach ($matches[1] as $match) {
973            $cart[$tree_name][$match] = true;
974        }
975
976        Session::put('cart', $cart);
977    }
978
979    /**
980     * @param Tree $tree
981     *
982     * @return bool
983     */
984    private function isCartEmpty(Tree $tree): bool
985    {
986        $cart = Session::get('cart', []);
987
988        return empty($cart[$tree->getName()]);
989    }
990
991    /**
992     * Only allow access to the routes/functions if the menu is active
993     *
994     * @param Tree $tree
995     *
996     * @return void
997     *
998     * @throws NoteNotFoundException
999     */
1000    private function checkModuleAccess(Tree $tree)
1001    {
1002        if (!array_key_exists($this->getName(), Module::getActiveMenus($tree))) {
1003            throw new NotFoundHttpException();
1004        }
1005    }
1006}
1007