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