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