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