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