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