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