xref: /webtrees/app/Module/ClippingsCartModule.php (revision b6db7c1f82484c632c75d5225ce767d398ddc27f)
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()
67    {
68        return /* I18N: Name of a module */
69            I18N::translate('Clippings cart');
70    }
71
72    /** {@inheritdoc} */
73    public function getDescription()
74    {
75        return /* I18N: Description of the “Clippings cart” module */
76            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()
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()
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            'members'     => /* I18N: %s is a family (husband + wife) */
422                I18N::translate('%s and their children', $name),
423            'descendants' => /* I18N: %s is a family (husband + wife) */
424                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    private function addFamilyToCart(Family $family)
466    {
467        $this->addRecordToCart($family);
468
469        foreach ($family->getSpouses() as $spouse) {
470            $this->addRecordToCart($spouse);
471        }
472    }
473
474    /**
475     * @param Family $family
476     */
477    private function addFamilyAndChildrenToCart(Family $family)
478    {
479        $this->addRecordToCart($family);
480
481        foreach ($family->getSpouses() as $spouse) {
482            $this->addRecordToCart($spouse);
483        }
484        foreach ($family->getChildren() as $child) {
485            $this->addRecordToCart($child);
486        }
487    }
488
489    /**
490     * @param Family $family
491     */
492    private function addFamilyAndDescendantsToCart(Family $family)
493    {
494        $this->addRecordToCart($family);
495
496        foreach ($family->getSpouses() as $spouse) {
497            $this->addRecordToCart($spouse);
498        }
499        foreach ($family->getChildren() as $child) {
500            $this->addRecordToCart($child);
501            foreach ($child->getSpouseFamilies() as $child_family) {
502                $this->addFamilyAndDescendantsToCart($child_family);
503            }
504        }
505    }
506
507    /**
508     * @param Request $request
509     * @param Tree    $tree
510     *
511     * @return Response
512     */
513    public function getAddIndividualAction(Request $request, Tree $tree): Response
514    {
515        $xref = $request->get('xref');
516
517        $individual = Individual::getInstance($xref, $tree);
518
519        if ($individual === null) {
520            throw new IndividualNotFoundException;
521        }
522
523        $options = $this->individualOptions($individual);
524
525        $title = I18N::translate('Add %s to the clippings cart', $individual->getFullName());
526
527        return $this->viewResponse('modules/clippings/add-options', [
528            'options' => $options,
529            'default' => key($options),
530            'record'  => $individual,
531            'title'   => $title,
532            'tree'    => $tree,
533        ]);
534    }
535
536    /**
537     * @param Individual $individual
538     *
539     * @return string[]
540     */
541    private function individualOptions(Individual $individual): array
542    {
543        $name = strip_tags($individual->getFullName());
544
545        if ($individual->getSex() === 'F') {
546            return [
547                'self'              => $name,
548                'parents'           => I18N::translate('%s, her parents and siblings', $name),
549                'spouses'           => I18N::translate('%s, her spouses and children', $name),
550                'ancestors'         => I18N::translate('%s and her ancestors', $name),
551                'ancestor_families' => I18N::translate('%s, her ancestors and their families', $name),
552                'descendants'       => I18N::translate('%s, her spouses and descendants', $name),
553            ];
554        } else {
555            return [
556                'self'              => $name,
557                'parents'           => I18N::translate('%s, his parents and siblings', $name),
558                'spouses'           => I18N::translate('%s, his spouses and children', $name),
559                'ancestors'         => I18N::translate('%s and his ancestors', $name),
560                'ancestor_families' => I18N::translate('%s, his ancestors and their families', $name),
561                'descendants'       => I18N::translate('%s, his spouses and descendants', $name),
562            ];
563        }
564    }
565
566    /**
567     * @param Request $request
568     * @param Tree    $tree
569     *
570     * @return RedirectResponse
571     */
572    public function postAddIndividualAction(Request $request, Tree $tree): RedirectResponse
573    {
574        $xref   = $request->get('xref');
575        $option = $request->get('option');
576
577        $individual = Individual::getInstance($xref, $tree);
578
579        if ($individual === null) {
580            throw new IndividualNotFoundException;
581        }
582
583        switch ($option) {
584            case 'self':
585                $this->addRecordToCart($individual);
586                break;
587
588            case 'parents':
589                foreach ($individual->getChildFamilies() as $family) {
590                    $this->addFamilyAndChildrenToCart($family);
591                }
592                break;
593
594            case 'spouses':
595                foreach ($individual->getSpouseFamilies() as $family) {
596                    $this->addFamilyAndChildrenToCart($family);
597                }
598                break;
599
600            case 'ancestors':
601                $this->addAncestorsToCart($individual);
602                break;
603
604            case 'ancestor_families':
605                $this->addAncestorFamiliesToCart($individual);
606                break;
607
608            case 'descendants':
609                foreach ($individual->getSpouseFamilies() as $family) {
610                    $this->addFamilyAndDescendantsToCart($family);
611                }
612                break;
613        }
614
615        return new RedirectResponse($individual->url());
616    }
617
618    /**
619     * @param Individual $individual
620     */
621    private function addAncestorsToCart(Individual $individual)
622    {
623        $this->addRecordToCart($individual);
624
625        foreach ($individual->getChildFamilies() as $family) {
626            foreach ($family->getSpouses() as $parent) {
627                $this->addAncestorsToCart($parent);
628            }
629        }
630    }
631
632    /**
633     * @param Individual $individual
634     */
635    private function addAncestorFamiliesToCart(Individual $individual)
636    {
637        foreach ($individual->getChildFamilies() as $family) {
638            $this->addFamilyAndChildrenToCart($family);
639            foreach ($family->getSpouses() as $parent) {
640                $this->addAncestorsToCart($parent);
641            }
642        }
643    }
644
645    /**
646     * @param Request $request
647     * @param Tree    $tree
648     *
649     * @return Response
650     */
651    public function getAddMediaAction(Request $request, Tree $tree): Response
652    {
653        $xref = $request->get('xref');
654
655        $media = Media::getInstance($xref, $tree);
656
657        if ($media === null) {
658            throw new MediaNotFoundException;
659        }
660
661        $options = $this->mediaOptions($media);
662
663        $title = I18N::translate('Add %s to the clippings cart', $media->getFullName());
664
665        return $this->viewResponse('modules/clippings/add-options', [
666            'options' => $options,
667            'default' => key($options),
668            'record'  => $media,
669            'title'   => $title,
670            'tree'    => $tree,
671        ]);
672    }
673
674    /**
675     * @param Media $media
676     *
677     * @return string[]
678     */
679    private function mediaOptions(Media $media): array
680    {
681        $name = strip_tags($media->getFullName());
682
683        return [
684            'self' => $name,
685        ];
686    }
687
688    /**
689     * @param Request $request
690     * @param Tree    $tree
691     *
692     * @return RedirectResponse
693     */
694    public function postAddMediaAction(Request $request, Tree $tree): RedirectResponse
695    {
696        $xref = $request->get('xref');
697
698        $media = Media::getInstance($xref, $tree);
699
700        if ($media === null) {
701            throw new MediaNotFoundException;
702        }
703
704        $this->addRecordToCart($media);
705
706        return new RedirectResponse($media->url());
707    }
708
709    /**
710     * @param Request $request
711     * @param Tree    $tree
712     *
713     * @return Response
714     */
715    public function getAddNoteAction(Request $request, Tree $tree): Response
716    {
717        $xref = $request->get('xref');
718
719        $note = Note::getInstance($xref, $tree);
720
721        if ($note === null) {
722            throw new NoteNotFoundException;
723        }
724
725        $options = $this->noteOptions($note);
726
727        $title = I18N::translate('Add %s to the clippings cart', $note->getFullName());
728
729        return $this->viewResponse('modules/clippings/add-options', [
730            'options' => $options,
731            'default' => key($options),
732            'record'  => $note,
733            'title'   => $title,
734            'tree'    => $tree,
735        ]);
736    }
737
738    /**
739     * @param Note $note
740     *
741     * @return string[]
742     */
743    private function noteOptions(Note $note): array
744    {
745        $name = strip_tags($note->getFullName());
746
747        return [
748            'self' => $name,
749        ];
750    }
751
752    /**
753     * @param Request $request
754     * @param Tree    $tree
755     *
756     * @return RedirectResponse
757     */
758    public function postAddNoteAction(Request $request, Tree $tree): RedirectResponse
759    {
760        $xref = $request->get('xref');
761
762        $note = Note::getInstance($xref, $tree);
763
764        if ($note === null) {
765            throw new NoteNotFoundException;
766        }
767
768        $this->addRecordToCart($note);
769
770        return new RedirectResponse($note->url());
771    }
772
773    /**
774     * @param Request $request
775     * @param Tree    $tree
776     *
777     * @return Response
778     */
779    public function getAddRepositoryAction(Request $request, Tree $tree): Response
780    {
781        $xref = $request->get('xref');
782
783        $repository = Repository::getInstance($xref, $tree);
784
785        if ($repository === null) {
786            throw new RepositoryNotFoundException;
787        }
788
789        $options = $this->repositoryOptions($repository);
790
791        $title = I18N::translate('Add %s to the clippings cart', $repository->getFullName());
792
793        return $this->viewResponse('modules/clippings/add-options', [
794            'options' => $options,
795            'default' => key($options),
796            'record'  => $repository,
797            'title'   => $title,
798            'tree'    => $tree,
799        ]);
800    }
801
802    /**
803     * @param Repository $repository
804     *
805     * @return string[]
806     */
807    private function repositoryOptions(Repository $repository): array
808    {
809        $name = strip_tags($repository->getFullName());
810
811        return [
812            'self' => $name,
813        ];
814    }
815
816    /**
817     * @param Request $request
818     * @param Tree    $tree
819     *
820     * @return RedirectResponse
821     */
822    public function postAddRepositoryAction(Request $request, Tree $tree): RedirectResponse
823    {
824        $xref = $request->get('xref');
825
826        $repository = Repository::getInstance($xref, $tree);
827
828        if ($repository === null) {
829            throw new RepositoryNotFoundException;
830        }
831
832        $this->addRecordToCart($repository);
833
834        return new RedirectResponse($repository->url());
835    }
836
837    /**
838     * @param Request $request
839     * @param Tree    $tree
840     *
841     * @return Response
842     */
843    public function getAddSourceAction(Request $request, Tree $tree): Response
844    {
845        $xref = $request->get('xref');
846
847        $source = Source::getInstance($xref, $tree);
848
849        if ($source === null) {
850            throw new SourceNotFoundException;
851        }
852
853        $options = $this->sourceOptions($source);
854
855        $title = I18N::translate('Add %s to the clippings cart', $source->getFullName());
856
857        return $this->viewResponse('modules/clippings/add-options', [
858            'options' => $options,
859            'default' => key($options),
860            'record'  => $source,
861            'title'   => $title,
862            'tree'    => $tree,
863        ]);
864    }
865
866    /**
867     * @param Source $source
868     *
869     * @return string[]
870     */
871    private function sourceOptions(Source $source): array
872    {
873        $name = strip_tags($source->getFullName());
874
875        return [
876            'only'   => strip_tags($source->getFullName()),
877            'linked' => I18N::translate('%s and the individuals that reference it.', $name),
878        ];
879    }
880
881    /**
882     * @param Request $request
883     * @param Tree    $tree
884     *
885     * @return RedirectResponse
886     */
887    public function postAddSourceAction(Request $request, Tree $tree): RedirectResponse
888    {
889        $xref   = $request->get('xref');
890        $option = $request->get('option');
891
892        $source = Source::getInstance($xref, $tree);
893
894        if ($source === null) {
895            throw new SourceNotFoundException;
896        }
897
898        $this->addRecordToCart($source);
899
900        if ($option === 'linked') {
901            foreach ($source->linkedIndividuals('SOUR') as $individual) {
902                $this->addRecordToCart($individual);
903            }
904            foreach ($source->linkedFamilies('SOUR') as $family) {
905                $this->addRecordToCart($family);
906            }
907        }
908
909        return new RedirectResponse($source->url());
910    }
911
912    /**
913     * Get all the records in the cart.
914     *
915     * @param Tree $tree
916     *
917     * @return GedcomRecord[]
918     */
919    private function allRecordsInCart(Tree $tree): array
920    {
921        $cart = Session::get('cart', []);
922
923        $xrefs = array_keys($cart[$tree->getName()] ?? []);
924
925        // Fetch all the records in the cart.
926        $records = array_map(function (string $xref) use ($tree) {
927            return GedcomRecord::getInstance($xref, $tree);
928        }, $xrefs);
929
930        // Some records may have been deleted after they were added to the cart.
931        $records = array_filter($records);
932
933        // Group and sort.
934        uasort($records, function (GedcomRecord $x, GedcomRecord $y) {
935            return $x::RECORD_TYPE <=> $y::RECORD_TYPE ?: GedcomRecord::compare($x, $y);
936        });
937
938        return $records;
939    }
940
941    /**
942     * Add a record (and direclty linked sources, notes, etc. to the cart.
943     *
944     * @param GedcomRecord $record
945     */
946    private function addRecordToCart(GedcomRecord $record)
947    {
948        $cart = Session::get('cart', []);
949
950        $tree_name = $record->getTree()->getName();
951
952        // Add this record
953        $cart[$tree_name][$record->getXref()] = true;
954
955        // Add directly linked media, notes, repositories and sources.
956        preg_match_all('/\n\d (?:OBJE|NOTE|SOUR|REPO) @(' . WT_REGEX_XREF . ')@/', $record->getGedcom(), $matches);
957
958        foreach ($matches[1] as $match) {
959            $cart[$tree_name][$match] = true;
960        }
961
962        Session::put('cart', $cart);
963    }
964
965    /**
966     * @param Tree $tree
967     *
968     * @return bool
969     */
970    private function isCartEmpty(Tree $tree): bool
971    {
972        $cart = Session::get('cart', []);
973
974        return empty($cart[$tree->getName()]);
975    }
976
977    /**
978     * Only allow access to the routes/functions if the menu is active
979     *
980     * @param Tree $tree
981     */
982    private function checkModuleAccess(Tree $tree)
983    {
984        if (!array_key_exists($this->getName(), Module::getActiveMenus($tree))) {
985            throw new NotFoundHttpException;
986        }
987    }
988}
989