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