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