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