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