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