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