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