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