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