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