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