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