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