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