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