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