xref: /webtrees/app/Module/ClippingsCartModule.php (revision 05fc85fd27092434b30e184e72b2b4df74bb4c34)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Module;
21
22use Aura\Router\Route;
23use Fisharebest\Webtrees\Auth;
24use Fisharebest\Webtrees\Family;
25use Fisharebest\Webtrees\Gedcom;
26use Fisharebest\Webtrees\GedcomRecord;
27use Fisharebest\Webtrees\Http\RequestHandlers\FamilyPage;
28use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage;
29use Fisharebest\Webtrees\Http\RequestHandlers\LocationPage;
30use Fisharebest\Webtrees\Http\RequestHandlers\MediaPage;
31use Fisharebest\Webtrees\Http\RequestHandlers\NotePage;
32use Fisharebest\Webtrees\Http\RequestHandlers\RepositoryPage;
33use Fisharebest\Webtrees\Http\RequestHandlers\SourcePage;
34use Fisharebest\Webtrees\Http\RequestHandlers\SubmitterPage;
35use Fisharebest\Webtrees\I18N;
36use Fisharebest\Webtrees\Individual;
37use Fisharebest\Webtrees\Location;
38use Fisharebest\Webtrees\Media;
39use Fisharebest\Webtrees\Menu;
40use Fisharebest\Webtrees\Note;
41use Fisharebest\Webtrees\Registry;
42use Fisharebest\Webtrees\Repository;
43use Fisharebest\Webtrees\Services\GedcomExportService;
44use Fisharebest\Webtrees\Services\UserService;
45use Fisharebest\Webtrees\Session;
46use Fisharebest\Webtrees\Source;
47use Fisharebest\Webtrees\Submitter;
48use Fisharebest\Webtrees\Tree;
49use Illuminate\Support\Collection;
50use League\Flysystem\Filesystem;
51use League\Flysystem\FilesystemException;
52use League\Flysystem\ZipArchive\FilesystemZipArchiveProvider;
53use League\Flysystem\ZipArchive\ZipArchiveAdapter;
54use Psr\Http\Message\ResponseFactoryInterface;
55use Psr\Http\Message\ResponseInterface;
56use Psr\Http\Message\ServerRequestInterface;
57use Psr\Http\Message\StreamFactoryInterface;
58use RuntimeException;
59
60use function app;
61use function array_filter;
62use function array_keys;
63use function array_map;
64use function array_search;
65use function assert;
66use function fopen;
67use function in_array;
68use function is_string;
69use function preg_match_all;
70use function redirect;
71use function rewind;
72use function route;
73use function str_replace;
74use function stream_get_meta_data;
75use function tmpfile;
76use function uasort;
77use function view;
78
79use const PREG_SET_ORDER;
80
81/**
82 * Class ClippingsCartModule
83 */
84class ClippingsCartModule extends AbstractModule implements ModuleMenuInterface
85{
86    use ModuleMenuTrait;
87
88    // What to add to the cart?
89    private const ADD_RECORD_ONLY        = 'record';
90    private const ADD_CHILDREN           = 'children';
91    private const ADD_DESCENDANTS        = 'descendants';
92    private const ADD_PARENT_FAMILIES    = 'parents';
93    private const ADD_SPOUSE_FAMILIES    = 'spouses';
94    private const ADD_ANCESTORS          = 'ancestors';
95    private const ADD_ANCESTOR_FAMILIES  = 'families';
96    private const ADD_LINKED_INDIVIDUALS = 'linked';
97
98    // Routes that have a record which can be added to the clipboard
99    private const ROUTES_WITH_RECORDS = [
100        'Family'     => FamilyPage::class,
101        'Individual' => IndividualPage::class,
102        'Media'      => MediaPage::class,
103        'Location'   => LocationPage::class,
104        'Note'       => NotePage::class,
105        'Repository' => RepositoryPage::class,
106        'Source'     => SourcePage::class,
107        'Submitter'  => SubmitterPage::class,
108    ];
109
110    /** @var int The default access level for this module.  It can be changed in the control panel. */
111    protected $access_level = Auth::PRIV_USER;
112
113    private GedcomExportService $gedcom_export_service;
114
115    private UserService $user_service;
116
117    private ResponseFactoryInterface $response_factory;
118
119    private StreamFactoryInterface $stream_factory;
120
121    /**
122     * ClippingsCartModule constructor.
123     *
124     * @param GedcomExportService      $gedcom_export_service
125     * @param ResponseFactoryInterface $response_factory
126     * @param StreamFactoryInterface   $stream_factory
127     * @param UserService              $user_service
128     */
129    public function __construct(
130        GedcomExportService $gedcom_export_service,
131        ResponseFactoryInterface $response_factory,
132        StreamFactoryInterface $stream_factory,
133        UserService $user_service
134    ) {
135        $this->gedcom_export_service = $gedcom_export_service;
136        $this->response_factory      = $response_factory;
137        $this->stream_factory        = $stream_factory;
138        $this->user_service          = $user_service;
139    }
140
141    /**
142     * A sentence describing what this module does.
143     *
144     * @return string
145     */
146    public function description(): string
147    {
148        /* I18N: Description of the “Clippings cart” module */
149        return I18N::translate('Select records from your family tree and save them as a GEDCOM file.');
150    }
151
152    /**
153     * The default position for this menu.  It can be changed in the control panel.
154     *
155     * @return int
156     */
157    public function defaultMenuOrder(): int
158    {
159        return 6;
160    }
161
162    /**
163     * A menu, to be added to the main application menu.
164     *
165     * @param Tree $tree
166     *
167     * @return Menu|null
168     */
169    public function getMenu(Tree $tree): ?Menu
170    {
171        /** @var ServerRequestInterface $request */
172        $request = app(ServerRequestInterface::class);
173
174        $route = $request->getAttribute('route');
175        assert($route instanceof Route);
176
177        $cart  = Session::get('cart', []);
178        $count = count($cart[$tree->name()] ?? []);
179        $badge = view('components/badge', ['count' => $count]);
180
181        $submenus = [
182            new Menu($this->title() . ' ' . $badge, route('module', [
183                'module' => $this->name(),
184                'action' => 'Show',
185                'tree'   => $tree->name(),
186            ]), 'menu-clippings-cart', ['rel' => 'nofollow']),
187        ];
188
189        $action = array_search($route->name, self::ROUTES_WITH_RECORDS, true);
190        if ($action !== false) {
191            $xref = $route->attributes['xref'];
192            assert(is_string($xref));
193
194            $add_route = route('module', [
195                'module' => $this->name(),
196                'action' => 'Add' . $action,
197                'xref'   => $xref,
198                'tree'   => $tree->name(),
199            ]);
200
201            $submenus[] = new Menu(I18N::translate('Add to the clippings cart'), $add_route, 'menu-clippings-add', ['rel' => 'nofollow']);
202        }
203
204        if (!$this->isCartEmpty($tree)) {
205            $submenus[] = new Menu(I18N::translate('Empty the clippings cart'), route('module', [
206                'module' => $this->name(),
207                'action' => 'Empty',
208                'tree'   => $tree->name(),
209            ]), 'menu-clippings-empty', ['rel' => 'nofollow']);
210
211            $submenus[] = new Menu(I18N::translate('Download'), route('module', [
212                'module' => $this->name(),
213                'action' => 'DownloadForm',
214                'tree'   => $tree->name(),
215            ]), 'menu-clippings-download', ['rel' => 'nofollow']);
216        }
217
218        return new Menu($this->title(), '#', 'menu-clippings', ['rel' => 'nofollow'], $submenus);
219    }
220
221    /**
222     * How should this module be identified in the control panel, etc.?
223     *
224     * @return string
225     */
226    public function title(): string
227    {
228        /* I18N: Name of a module */
229        return I18N::translate('Clippings cart');
230    }
231
232    /**
233     * @param Tree $tree
234     *
235     * @return bool
236     */
237    private function isCartEmpty(Tree $tree): bool
238    {
239        $cart     = Session::get('cart', []);
240        $contents = $cart[$tree->name()] ?? [];
241
242        return $contents === [];
243    }
244
245    /**
246     * @param ServerRequestInterface $request
247     *
248     * @return ResponseInterface
249     */
250    public function getDownloadFormAction(ServerRequestInterface $request): ResponseInterface
251    {
252        $tree = $request->getAttribute('tree');
253        assert($tree instanceof Tree);
254
255        $user  = $request->getAttribute('user');
256        $title = I18N::translate('Family tree clippings cart') . ' — ' . I18N::translate('Download');
257
258        return $this->viewResponse('modules/clippings/download', [
259            'is_manager' => Auth::isManager($tree, $user),
260            'is_member'  => Auth::isMember($tree, $user),
261            'module'     => $this->name(),
262            'title'      => $title,
263            'tree'       => $tree,
264        ]);
265    }
266
267    /**
268     * @param ServerRequestInterface $request
269     *
270     * @return ResponseInterface
271     * @throws FilesystemException
272     */
273    public function postDownloadAction(ServerRequestInterface $request): ResponseInterface
274    {
275        $tree = $request->getAttribute('tree');
276        assert($tree instanceof Tree);
277
278        $data_filesystem = Registry::filesystem()->data();
279
280        $params = (array) $request->getParsedBody();
281
282        $privatize_export = $params['privatize_export'] ?? 'none';
283
284        if ($privatize_export === 'none' && !Auth::isManager($tree)) {
285            $privatize_export = 'member';
286        }
287
288        if ($privatize_export === 'gedadmin' && !Auth::isManager($tree)) {
289            $privatize_export = 'member';
290        }
291
292        if ($privatize_export === 'user' && !Auth::isMember($tree)) {
293            $privatize_export = 'visitor';
294        }
295
296        $convert = (bool) ($params['convert'] ?? false);
297
298        $cart = Session::get('cart', []);
299
300        $xrefs = array_keys($cart[$tree->name()] ?? []);
301        $xrefs = array_map('strval', $xrefs); // PHP converts numeric keys to integers.
302
303        // Create a new/empty .ZIP file
304        $temp_zip_file  = stream_get_meta_data(tmpfile())['uri'];
305        $zip_provider   = new FilesystemZipArchiveProvider($temp_zip_file, 0755);
306        $zip_adapter    = new ZipArchiveAdapter($zip_provider);
307        $zip_filesystem = new Filesystem($zip_adapter);
308
309        $media_filesystem = $tree->mediaFilesystem($data_filesystem);
310
311        // Media file prefix
312        $path = $tree->getPreference('MEDIA_DIRECTORY');
313
314        $encoding = $convert ? 'ANSI' : 'UTF-8';
315
316        $records = new Collection();
317
318        switch ($privatize_export) {
319            case 'gedadmin':
320                $access_level = Auth::PRIV_NONE;
321                break;
322            case 'user':
323                $access_level = Auth::PRIV_USER;
324                break;
325            case 'visitor':
326                $access_level = Auth::PRIV_PRIVATE;
327                break;
328            case 'none':
329            default:
330                $access_level = Auth::PRIV_HIDE;
331                break;
332        }
333
334        foreach ($xrefs as $xref) {
335            $object = Registry::gedcomRecordFactory()->make($xref, $tree);
336            // The object may have been deleted since we added it to the cart....
337            if ($object instanceof GedcomRecord) {
338                $record = $object->privatizeGedcom($access_level);
339                // Remove links to objects that aren't in the cart
340                preg_match_all('/\n1 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(\n[2-9].*)*/', $record, $matches, PREG_SET_ORDER);
341                foreach ($matches as $match) {
342                    if (!in_array($match[1], $xrefs, true)) {
343                        $record = str_replace($match[0], '', $record);
344                    }
345                }
346                preg_match_all('/\n2 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(\n[3-9].*)*/', $record, $matches, PREG_SET_ORDER);
347                foreach ($matches as $match) {
348                    if (!in_array($match[1], $xrefs, true)) {
349                        $record = str_replace($match[0], '', $record);
350                    }
351                }
352                preg_match_all('/\n3 ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@(\n[4-9].*)*/', $record, $matches, PREG_SET_ORDER);
353                foreach ($matches as $match) {
354                    if (!in_array($match[1], $xrefs, true)) {
355                        $record = str_replace($match[0], '', $record);
356                    }
357                }
358
359                if ($object instanceof Individual || $object instanceof Family) {
360                    $records->add($record . "\n1 SOUR @WEBTREES@\n2 PAGE " . $object->url());
361                } elseif ($object instanceof Source) {
362                    $records->add($record . "\n1 NOTE " . $object->url());
363                } elseif ($object instanceof Media) {
364                    // Add the media files to the archive
365                    foreach ($object->mediaFiles() as $media_file) {
366                        $from = $media_file->filename();
367                        $to   = $path . $media_file->filename();
368                        if (!$media_file->isExternal() && $media_filesystem->fileExists($from)) {
369                            $zip_filesystem->writeStream($to, $media_filesystem->readStream($from));
370                        }
371                    }
372                    $records->add($record);
373                } else {
374                    $records->add($record);
375                }
376            }
377        }
378
379        $base_url = $request->getAttribute('base_url');
380
381        // Create a source, to indicate the source of the data.
382        $record = "0 @WEBTREES@ SOUR\n1 TITL " . $base_url;
383        $author = $this->user_service->find((int) $tree->getPreference('CONTACT_USER_ID'));
384        if ($author !== null) {
385            $record .= "\n1 AUTH " . $author->realName();
386        }
387        $records->add($record);
388
389        $stream = fopen('php://temp', 'wb+');
390
391        if ($stream === false) {
392            throw new RuntimeException('Failed to create temporary stream');
393        }
394
395        // We have already applied privacy filtering, so do not do it again.
396        $this->gedcom_export_service->export($tree, $stream, false, $encoding, Auth::PRIV_HIDE, $path, $records);
397        rewind($stream);
398
399        // Finally add the GEDCOM file to the .ZIP file.
400        $zip_filesystem->writeStream('clippings.ged', $stream);
401
402        // Use a stream, so that we do not have to load the entire file into memory.
403        $stream = $this->stream_factory->createStreamFromFile($temp_zip_file);
404
405        return $this->response_factory->createResponse()
406            ->withBody($stream)
407            ->withHeader('Content-Type', 'application/zip')
408            ->withHeader('Content-Disposition', 'attachment; filename="clippings.zip');
409    }
410
411    /**
412     * @param ServerRequestInterface $request
413     *
414     * @return ResponseInterface
415     */
416    public function getEmptyAction(ServerRequestInterface $request): ResponseInterface
417    {
418        $tree = $request->getAttribute('tree');
419        assert($tree instanceof Tree);
420
421        $cart                = Session::get('cart', []);
422        $cart[$tree->name()] = [];
423        Session::put('cart', $cart);
424
425        $url = route('module', [
426            'module' => $this->name(),
427            'action' => 'Show',
428            'tree'   => $tree->name(),
429        ]);
430
431        return redirect($url);
432    }
433
434    /**
435     * @param ServerRequestInterface $request
436     *
437     * @return ResponseInterface
438     */
439    public function postRemoveAction(ServerRequestInterface $request): ResponseInterface
440    {
441        $tree = $request->getAttribute('tree');
442        assert($tree instanceof Tree);
443
444        $xref = $request->getQueryParams()['xref'] ?? '';
445
446        $cart = Session::get('cart', []);
447        unset($cart[$tree->name()][$xref]);
448        Session::put('cart', $cart);
449
450        $url = route('module', [
451            'module' => $this->name(),
452            'action' => 'Show',
453            'tree'   => $tree->name(),
454        ]);
455
456        return redirect($url);
457    }
458
459    /**
460     * @param ServerRequestInterface $request
461     *
462     * @return ResponseInterface
463     */
464    public function getShowAction(ServerRequestInterface $request): ResponseInterface
465    {
466        $tree = $request->getAttribute('tree');
467        assert($tree instanceof Tree);
468
469        return $this->viewResponse('modules/clippings/show', [
470            'module'  => $this->name(),
471            'records' => $this->allRecordsInCart($tree),
472            'title'   => I18N::translate('Family tree clippings cart'),
473            'tree'    => $tree,
474        ]);
475    }
476
477    /**
478     * Get all the records in the cart.
479     *
480     * @param Tree $tree
481     *
482     * @return GedcomRecord[]
483     */
484    private function allRecordsInCart(Tree $tree): array
485    {
486        $cart = Session::get('cart', []);
487
488        $xrefs = array_keys($cart[$tree->name()] ?? []);
489        $xrefs = array_map('strval', $xrefs); // PHP converts numeric keys to integers.
490
491        // Fetch all the records in the cart.
492        $records = array_map(static function (string $xref) use ($tree): ?GedcomRecord {
493            return Registry::gedcomRecordFactory()->make($xref, $tree);
494        }, $xrefs);
495
496        // Some records may have been deleted after they were added to the cart.
497        $records = array_filter($records);
498
499        // Group and sort.
500        uasort($records, static function (GedcomRecord $x, GedcomRecord $y): int {
501            return $x->tag() <=> $y->tag() ?: GedcomRecord::nameComparator()($x, $y);
502        });
503
504        return $records;
505    }
506
507    /**
508     * @param ServerRequestInterface $request
509     *
510     * @return ResponseInterface
511     */
512    public function getAddFamilyAction(ServerRequestInterface $request): ResponseInterface
513    {
514        $tree = $request->getAttribute('tree');
515        assert($tree instanceof Tree);
516
517        $xref = $request->getQueryParams()['xref'] ?? '';
518
519        $family = Registry::familyFactory()->make($xref, $tree);
520        $family = Auth::checkFamilyAccess($family);
521        $name   = $family->fullName();
522
523        $options = [
524            self::ADD_RECORD_ONLY => $name,
525            /* I18N: %s is a family (husband + wife) */
526            self::ADD_CHILDREN    => I18N::translate('%s and their children', $name),
527            /* I18N: %s is a family (husband + wife) */
528            self::ADD_DESCENDANTS => I18N::translate('%s and their descendants', $name),
529        ];
530
531        $title = I18N::translate('Add %s to the clippings cart', $name);
532
533        return $this->viewResponse('modules/clippings/add-options', [
534            'options' => $options,
535            'record'  => $family,
536            'title'   => $title,
537            'tree'    => $tree,
538        ]);
539    }
540
541    /**
542     * @param ServerRequestInterface $request
543     *
544     * @return ResponseInterface
545     */
546    public function postAddFamilyAction(ServerRequestInterface $request): ResponseInterface
547    {
548        $tree = $request->getAttribute('tree');
549        assert($tree instanceof Tree);
550
551        $params = (array) $request->getParsedBody();
552
553        $xref   = $params['xref'] ?? '';
554        $option = $params['option'] ?? '';
555
556        $family = Registry::familyFactory()->make($xref, $tree);
557        $family = Auth::checkFamilyAccess($family);
558
559        switch ($option) {
560            case self::ADD_RECORD_ONLY:
561                $this->addFamilyToCart($family);
562                break;
563
564            case self::ADD_CHILDREN:
565                $this->addFamilyAndChildrenToCart($family);
566                break;
567
568            case self::ADD_DESCENDANTS:
569                $this->addFamilyAndDescendantsToCart($family);
570                break;
571        }
572
573        return redirect($family->url());
574    }
575
576
577    /**
578     * @param Family $family
579     *
580     * @return void
581     */
582    protected function addFamilyAndChildrenToCart(Family $family): void
583    {
584        $this->addFamilyToCart($family);
585
586        foreach ($family->children() as $child) {
587            $this->addIndividualToCart($child);
588        }
589    }
590
591    /**
592     * @param Family $family
593     *
594     * @return void
595     */
596    protected function addFamilyAndDescendantsToCart(Family $family): void
597    {
598        $this->addFamilyAndChildrenToCart($family);
599
600        foreach ($family->children() as $child) {
601            foreach ($child->spouseFamilies() as $child_family) {
602                $this->addFamilyAndDescendantsToCart($child_family);
603            }
604        }
605    }
606
607    /**
608     * @param ServerRequestInterface $request
609     *
610     * @return ResponseInterface
611     */
612    public function getAddIndividualAction(ServerRequestInterface $request): ResponseInterface
613    {
614        $tree = $request->getAttribute('tree');
615        assert($tree instanceof Tree);
616
617        $xref = $request->getQueryParams()['xref'] ?? '';
618
619        $individual = Registry::individualFactory()->make($xref, $tree);
620        $individual = Auth::checkIndividualAccess($individual);
621        $name       = $individual->fullName();
622
623        if ($individual->sex() === 'F') {
624            $options = [
625                self::ADD_RECORD_ONLY       => $name,
626                self::ADD_PARENT_FAMILIES   => I18N::translate('%s, her parents and siblings', $name),
627                self::ADD_SPOUSE_FAMILIES   => I18N::translate('%s, her spouses and children', $name),
628                self::ADD_ANCESTORS         => I18N::translate('%s and her ancestors', $name),
629                self::ADD_ANCESTOR_FAMILIES => I18N::translate('%s, her ancestors and their families', $name),
630                self::ADD_DESCENDANTS       => I18N::translate('%s, her spouses and descendants', $name),
631            ];
632        } else {
633            $options = [
634                self::ADD_RECORD_ONLY       => $name,
635                self::ADD_PARENT_FAMILIES   => I18N::translate('%s, his parents and siblings', $name),
636                self::ADD_SPOUSE_FAMILIES   => I18N::translate('%s, his spouses and children', $name),
637                self::ADD_ANCESTORS         => I18N::translate('%s and his ancestors', $name),
638                self::ADD_ANCESTOR_FAMILIES => I18N::translate('%s, his ancestors and their families', $name),
639                self::ADD_DESCENDANTS       => I18N::translate('%s, his spouses and descendants', $name),
640            ];
641        }
642
643        $title = I18N::translate('Add %s to the clippings cart', $name);
644
645        return $this->viewResponse('modules/clippings/add-options', [
646            'options' => $options,
647            'record'  => $individual,
648            'title'   => $title,
649            'tree'    => $tree,
650        ]);
651    }
652
653    /**
654     * @param ServerRequestInterface $request
655     *
656     * @return ResponseInterface
657     */
658    public function postAddIndividualAction(ServerRequestInterface $request): ResponseInterface
659    {
660        $tree = $request->getAttribute('tree');
661        assert($tree instanceof Tree);
662
663        $params = (array) $request->getParsedBody();
664
665        $xref   = $params['xref'] ?? '';
666        $option = $params['option'] ?? '';
667
668        $individual = Registry::individualFactory()->make($xref, $tree);
669        $individual = Auth::checkIndividualAccess($individual);
670
671        switch ($option) {
672            case self::ADD_RECORD_ONLY:
673                $this->addIndividualToCart($individual);
674                break;
675
676            case self::ADD_PARENT_FAMILIES:
677                foreach ($individual->childFamilies() as $family) {
678                    $this->addFamilyAndChildrenToCart($family);
679                }
680                break;
681
682            case self::ADD_SPOUSE_FAMILIES:
683                foreach ($individual->spouseFamilies() as $family) {
684                    $this->addFamilyAndChildrenToCart($family);
685                }
686                break;
687
688            case self::ADD_ANCESTORS:
689                $this->addAncestorsToCart($individual);
690                break;
691
692            case self::ADD_ANCESTOR_FAMILIES:
693                $this->addAncestorFamiliesToCart($individual);
694                break;
695
696            case self::ADD_DESCENDANTS:
697                foreach ($individual->spouseFamilies() as $family) {
698                    $this->addFamilyAndDescendantsToCart($family);
699                }
700                break;
701        }
702
703        return redirect($individual->url());
704    }
705
706    /**
707     * @param Individual $individual
708     *
709     * @return void
710     */
711    protected function addAncestorsToCart(Individual $individual): void
712    {
713        $this->addIndividualToCart($individual);
714
715        foreach ($individual->childFamilies() as $family) {
716            $this->addFamilyToCart($family);
717
718            foreach ($family->spouses() as $parent) {
719                $this->addAncestorsToCart($parent);
720            }
721        }
722    }
723
724    /**
725     * @param Individual $individual
726     *
727     * @return void
728     */
729    protected function addAncestorFamiliesToCart(Individual $individual): void
730    {
731        foreach ($individual->childFamilies() as $family) {
732            $this->addFamilyAndChildrenToCart($family);
733
734            foreach ($family->spouses() as $parent) {
735                $this->addAncestorFamiliesToCart($parent);
736            }
737        }
738    }
739
740    /**
741     * @param ServerRequestInterface $request
742     *
743     * @return ResponseInterface
744     */
745    public function getAddLocationAction(ServerRequestInterface $request): ResponseInterface
746    {
747        $tree = $request->getAttribute('tree');
748        assert($tree instanceof Tree);
749
750        $xref = $request->getQueryParams()['xref'] ?? '';
751
752        $location = Registry::locationFactory()->make($xref, $tree);
753        $location = Auth::checkLocationAccess($location);
754        $name     = $location->fullName();
755
756        $options = [
757            self::ADD_RECORD_ONLY => $name,
758        ];
759
760        $title = I18N::translate('Add %s to the clippings cart', $name);
761
762        return $this->viewResponse('modules/clippings/add-options', [
763            'options' => $options,
764            'record'  => $location,
765            'title'   => $title,
766            'tree'    => $tree,
767        ]);
768    }
769
770    /**
771     * @param ServerRequestInterface $request
772     *
773     * @return ResponseInterface
774     */
775    public function postAddLocationAction(ServerRequestInterface $request): ResponseInterface
776    {
777        $tree = $request->getAttribute('tree');
778        assert($tree instanceof Tree);
779
780        $xref = $request->getQueryParams()['xref'] ?? '';
781
782        $location = Registry::locationFactory()->make($xref, $tree);
783        $location = Auth::checkLocationAccess($location);
784
785        $this->addLocationToCart($location);
786
787        return redirect($location->url());
788    }
789
790    /**
791     * @param ServerRequestInterface $request
792     *
793     * @return ResponseInterface
794     */
795    public function getAddMediaAction(ServerRequestInterface $request): ResponseInterface
796    {
797        $tree = $request->getAttribute('tree');
798        assert($tree instanceof Tree);
799
800        $xref = $request->getQueryParams()['xref'] ?? '';
801
802        $media = Registry::mediaFactory()->make($xref, $tree);
803        $media = Auth::checkMediaAccess($media);
804        $name  = $media->fullName();
805
806        $options = [
807            self::ADD_RECORD_ONLY => $name,
808        ];
809
810        $title = I18N::translate('Add %s to the clippings cart', $name);
811
812        return $this->viewResponse('modules/clippings/add-options', [
813            'options' => $options,
814            'record'  => $media,
815            'title'   => $title,
816            'tree'    => $tree,
817        ]);
818    }
819
820    /**
821     * @param ServerRequestInterface $request
822     *
823     * @return ResponseInterface
824     */
825    public function postAddMediaAction(ServerRequestInterface $request): ResponseInterface
826    {
827        $tree = $request->getAttribute('tree');
828        assert($tree instanceof Tree);
829
830        $xref = $request->getQueryParams()['xref'] ?? '';
831
832        $media = Registry::mediaFactory()->make($xref, $tree);
833        $media = Auth::checkMediaAccess($media);
834
835        $this->addMediaToCart($media);
836
837        return redirect($media->url());
838    }
839
840    /**
841     * @param ServerRequestInterface $request
842     *
843     * @return ResponseInterface
844     */
845    public function getAddNoteAction(ServerRequestInterface $request): ResponseInterface
846    {
847        $tree = $request->getAttribute('tree');
848        assert($tree instanceof Tree);
849
850        $xref = $request->getQueryParams()['xref'] ?? '';
851
852        $note = Registry::noteFactory()->make($xref, $tree);
853        $note = Auth::checkNoteAccess($note);
854        $name = $note->fullName();
855
856        $options = [
857            self::ADD_RECORD_ONLY => $name,
858        ];
859
860        $title = I18N::translate('Add %s to the clippings cart', $name);
861
862        return $this->viewResponse('modules/clippings/add-options', [
863            'options' => $options,
864            'record'  => $note,
865            'title'   => $title,
866            'tree'    => $tree,
867        ]);
868    }
869
870    /**
871     * @param ServerRequestInterface $request
872     *
873     * @return ResponseInterface
874     */
875    public function postAddNoteAction(ServerRequestInterface $request): ResponseInterface
876    {
877        $tree = $request->getAttribute('tree');
878        assert($tree instanceof Tree);
879
880        $xref = $request->getQueryParams()['xref'] ?? '';
881
882        $note = Registry::noteFactory()->make($xref, $tree);
883        $note = Auth::checkNoteAccess($note);
884
885        $this->addNoteToCart($note);
886
887        return redirect($note->url());
888    }
889
890    /**
891     * @param ServerRequestInterface $request
892     *
893     * @return ResponseInterface
894     */
895    public function getAddRepositoryAction(ServerRequestInterface $request): ResponseInterface
896    {
897        $tree = $request->getAttribute('tree');
898        assert($tree instanceof Tree);
899
900        $xref = $request->getQueryParams()['xref'] ?? '';
901
902        $repository = Registry::repositoryFactory()->make($xref, $tree);
903        $repository = Auth::checkRepositoryAccess($repository);
904        $name       = $repository->fullName();
905
906        $options = [
907            self::ADD_RECORD_ONLY => $name,
908        ];
909
910        $title = I18N::translate('Add %s to the clippings cart', $name);
911
912        return $this->viewResponse('modules/clippings/add-options', [
913            'options' => $options,
914            'record'  => $repository,
915            'title'   => $title,
916            'tree'    => $tree,
917        ]);
918    }
919
920    /**
921     * @param ServerRequestInterface $request
922     *
923     * @return ResponseInterface
924     */
925    public function postAddRepositoryAction(ServerRequestInterface $request): ResponseInterface
926    {
927        $tree = $request->getAttribute('tree');
928        assert($tree instanceof Tree);
929
930        $xref = $request->getQueryParams()['xref'] ?? '';
931
932        $repository = Registry::repositoryFactory()->make($xref, $tree);
933        $repository = Auth::checkRepositoryAccess($repository);
934
935        $this->addRepositoryToCart($repository);
936
937        foreach ($repository->linkedSources('REPO') as $source) {
938            $this->addSourceToCart($source);
939        }
940
941        return redirect($repository->url());
942    }
943
944    /**
945     * @param ServerRequestInterface $request
946     *
947     * @return ResponseInterface
948     */
949    public function getAddSourceAction(ServerRequestInterface $request): ResponseInterface
950    {
951        $tree = $request->getAttribute('tree');
952        assert($tree instanceof Tree);
953
954        $xref = $request->getQueryParams()['xref'] ?? '';
955
956        $source = Registry::sourceFactory()->make($xref, $tree);
957        $source = Auth::checkSourceAccess($source);
958        $name   = $source->fullName();
959
960        $options = [
961            self::ADD_RECORD_ONLY        => $name,
962            self::ADD_LINKED_INDIVIDUALS => I18N::translate('%s and the individuals that reference it.', $name),
963        ];
964
965        $title = I18N::translate('Add %s to the clippings cart', $name);
966
967        return $this->viewResponse('modules/clippings/add-options', [
968            'options' => $options,
969            'record'  => $source,
970            'title'   => $title,
971            'tree'    => $tree,
972        ]);
973    }
974
975    /**
976     * @param ServerRequestInterface $request
977     *
978     * @return ResponseInterface
979     */
980    public function postAddSourceAction(ServerRequestInterface $request): ResponseInterface
981    {
982        $tree = $request->getAttribute('tree');
983        assert($tree instanceof Tree);
984
985        $params = (array) $request->getParsedBody();
986
987        $xref   = $params['xref'] ?? '';
988        $option = $params['option'] ?? '';
989
990        $source = Registry::sourceFactory()->make($xref, $tree);
991        $source = Auth::checkSourceAccess($source);
992
993        $this->addSourceToCart($source);
994
995        if ($option === self::ADD_LINKED_INDIVIDUALS) {
996            foreach ($source->linkedIndividuals('SOUR') as $individual) {
997                $this->addIndividualToCart($individual);
998            }
999            foreach ($source->linkedFamilies('SOUR') as $family) {
1000                $this->addFamilyToCart($family);
1001            }
1002        }
1003
1004        return redirect($source->url());
1005    }
1006
1007    /**
1008     * @param ServerRequestInterface $request
1009     *
1010     * @return ResponseInterface
1011     */
1012    public function getAddSubmitterAction(ServerRequestInterface $request): ResponseInterface
1013    {
1014        $tree = $request->getAttribute('tree');
1015        assert($tree instanceof Tree);
1016
1017        $xref = $request->getQueryParams()['xref'] ?? '';
1018
1019        $submitter = Registry::submitterFactory()->make($xref, $tree);
1020        $submitter = Auth::checkSubmitterAccess($submitter);
1021        $name      = $submitter->fullName();
1022
1023        $options = [
1024            self::ADD_RECORD_ONLY => $name,
1025        ];
1026
1027        $title = I18N::translate('Add %s to the clippings cart', $name);
1028
1029        return $this->viewResponse('modules/clippings/add-options', [
1030            'options' => $options,
1031            'record'  => $submitter,
1032            'title'   => $title,
1033            'tree'    => $tree,
1034        ]);
1035    }
1036
1037    /**
1038     * @param ServerRequestInterface $request
1039     *
1040     * @return ResponseInterface
1041     */
1042    public function postAddSubmitterAction(ServerRequestInterface $request): ResponseInterface
1043    {
1044        $tree = $request->getAttribute('tree');
1045        assert($tree instanceof Tree);
1046
1047        $xref = $request->getQueryParams()['xref'] ?? '';
1048
1049        $submitter = Registry::submitterFactory()->make($xref, $tree);
1050        $submitter = Auth::checkSubmitterAccess($submitter);
1051
1052        $this->addSubmitterToCart($submitter);
1053
1054        return redirect($submitter->url());
1055    }
1056
1057    /**
1058     * @param Family $family
1059     */
1060    protected function addFamilyToCart(Family $family): void
1061    {
1062        $cart = Session::get('cart', []);
1063        $tree = $family->tree()->name();
1064        $xref = $family->xref();
1065
1066        if (($cart[$tree][$xref] ?? false) === false) {
1067            $cart[$tree][$xref] = true;
1068
1069            Session::put('cart', $cart);
1070
1071            foreach ($family->spouses() as $spouse) {
1072                $this->addIndividualToCart($spouse);
1073            }
1074
1075            $this->addLocationLinksToCart($family);
1076            $this->addMediaLinksToCart($family);
1077            $this->addNoteLinksToCart($family);
1078            $this->addSourceLinksToCart($family);
1079            $this->addSubmitterLinksToCart($family);
1080        }
1081    }
1082
1083    /**
1084     * @param Individual $individual
1085     */
1086    protected function addIndividualToCart(Individual $individual): void
1087    {
1088        $cart = Session::get('cart', []);
1089        $tree = $individual->tree()->name();
1090        $xref = $individual->xref();
1091
1092        if (($cart[$tree][$xref] ?? false) === false) {
1093            $cart[$tree][$xref] = true;
1094
1095            Session::put('cart', $cart);
1096
1097            $this->addLocationLinksToCart($individual);
1098            $this->addMediaLinksToCart($individual);
1099            $this->addNoteLinksToCart($individual);
1100            $this->addSourceLinksToCart($individual);
1101        }
1102    }
1103
1104    /**
1105     * @param Location $location
1106     */
1107    protected function addLocationToCart(Location $location): void
1108    {
1109        $cart = Session::get('cart', []);
1110        $tree = $location->tree()->name();
1111        $xref = $location->xref();
1112
1113        if (($cart[$tree][$xref] ?? false) === false) {
1114            $cart[$tree][$xref] = true;
1115
1116            Session::put('cart', $cart);
1117
1118            $this->addLocationLinksToCart($location);
1119            $this->addMediaLinksToCart($location);
1120            $this->addNoteLinksToCart($location);
1121            $this->addSourceLinksToCart($location);
1122        }
1123    }
1124
1125    /**
1126     * @param GedcomRecord $record
1127     */
1128    protected function addLocationLinksToCart(GedcomRecord $record): void
1129    {
1130        preg_match_all('/\n\d _LOC @(' . Gedcom::REGEX_XREF . ')@/', $record->gedcom(), $matches);
1131
1132        foreach ($matches[1] as $xref) {
1133            $location = Registry::locationFactory()->make($xref, $record->tree());
1134
1135            if ($location instanceof Location && $location->canShow()) {
1136                $this->addLocationToCart($location);
1137            }
1138        }
1139    }
1140
1141    /**
1142     * @param Media $media
1143     */
1144    protected function addMediaToCart(Media $media): void
1145    {
1146        $cart = Session::get('cart', []);
1147        $tree = $media->tree()->name();
1148        $xref = $media->xref();
1149
1150        if (($cart[$tree][$xref] ?? false) === false) {
1151            $cart[$tree][$xref] = true;
1152
1153            Session::put('cart', $cart);
1154
1155            $this->addNoteLinksToCart($media);
1156        }
1157    }
1158
1159    /**
1160     * @param GedcomRecord $record
1161     */
1162    protected function addMediaLinksToCart(GedcomRecord $record): void
1163    {
1164        preg_match_all('/\n\d OBJE @(' . Gedcom::REGEX_XREF . ')@/', $record->gedcom(), $matches);
1165
1166        foreach ($matches[1] as $xref) {
1167            $media = Registry::mediaFactory()->make($xref, $record->tree());
1168
1169            if ($media instanceof Media && $media->canShow()) {
1170                $this->addMediaToCart($media);
1171            }
1172        }
1173    }
1174
1175    /**
1176     * @param Note $note
1177     */
1178    protected function addNoteToCart(Note $note): void
1179    {
1180        $cart = Session::get('cart', []);
1181        $tree = $note->tree()->name();
1182        $xref = $note->xref();
1183
1184        if (($cart[$tree][$xref] ?? false) === false) {
1185            $cart[$tree][$xref] = true;
1186
1187            Session::put('cart', $cart);
1188        }
1189    }
1190
1191    /**
1192     * @param GedcomRecord $record
1193     */
1194    protected function addNoteLinksToCart(GedcomRecord $record): void
1195    {
1196        preg_match_all('/\n\d NOTE @(' . Gedcom::REGEX_XREF . ')@/', $record->gedcom(), $matches);
1197
1198        foreach ($matches[1] as $xref) {
1199            $note = Registry::noteFactory()->make($xref, $record->tree());
1200
1201            if ($note instanceof Note && $note->canShow()) {
1202                $this->addNoteToCart($note);
1203            }
1204        }
1205    }
1206
1207    /**
1208     * @param Source $source
1209     */
1210    protected function addSourceToCart(Source $source): void
1211    {
1212        $cart = Session::get('cart', []);
1213        $tree = $source->tree()->name();
1214        $xref = $source->xref();
1215
1216        if (($cart[$tree][$xref] ?? false) === false) {
1217            $cart[$tree][$xref] = true;
1218
1219            Session::put('cart', $cart);
1220
1221            $this->addNoteLinksToCart($source);
1222            $this->addRepositoryLinksToCart($source);
1223        }
1224    }
1225
1226    /**
1227     * @param GedcomRecord $record
1228     */
1229    protected function addSourceLinksToCart(GedcomRecord $record): void
1230    {
1231        preg_match_all('/\n\d SOUR @(' . Gedcom::REGEX_XREF . ')@/', $record->gedcom(), $matches);
1232
1233        foreach ($matches[1] as $xref) {
1234            $source = Registry::sourceFactory()->make($xref, $record->tree());
1235
1236            if ($source instanceof Source && $source->canShow()) {
1237                $this->addSourceToCart($source);
1238            }
1239        }
1240    }
1241
1242    /**
1243     * @param Repository $repository
1244     */
1245    protected function addRepositoryToCart(Repository $repository): void
1246    {
1247        $cart = Session::get('cart', []);
1248        $tree = $repository->tree()->name();
1249        $xref = $repository->xref();
1250
1251        if (($cart[$tree][$xref] ?? false) === false) {
1252            $cart[$tree][$xref] = true;
1253
1254            Session::put('cart', $cart);
1255
1256            $this->addNoteLinksToCart($repository);
1257        }
1258    }
1259
1260    /**
1261     * @param GedcomRecord $record
1262     */
1263    protected function addRepositoryLinksToCart(GedcomRecord $record): void
1264    {
1265        preg_match_all('/\n\d REPO @(' . Gedcom::REGEX_XREF . '@)/', $record->gedcom(), $matches);
1266
1267        foreach ($matches[1] as $xref) {
1268            $repository = Registry::repositoryFactory()->make($xref, $record->tree());
1269
1270            if ($repository instanceof Repository && $repository->canShow()) {
1271                $this->addRepositoryToCart($repository);
1272            }
1273        }
1274    }
1275
1276    /**
1277     * @param Submitter $submitter
1278     */
1279    protected function addSubmitterToCart(Submitter $submitter): void
1280    {
1281        $cart = Session::get('cart', []);
1282        $tree = $submitter->tree()->name();
1283        $xref = $submitter->xref();
1284
1285        if (($cart[$tree][$xref] ?? false) === false) {
1286            $cart[$tree][$xref] = true;
1287
1288            Session::put('cart', $cart);
1289
1290            $this->addNoteLinksToCart($submitter);
1291        }
1292    }
1293
1294    /**
1295     * @param GedcomRecord $record
1296     */
1297    protected function addSubmitterLinksToCart(GedcomRecord $record): void
1298    {
1299        preg_match_all('/\n\d SUBM @(' . Gedcom::REGEX_XREF . ')@/', $record->gedcom(), $matches);
1300
1301        foreach ($matches[1] as $xref) {
1302            $submitter = Registry::submitterFactory()->make($xref, $record->tree());
1303
1304            if ($submitter instanceof Submitter && $submitter->canShow()) {
1305                $this->addSubmitterToCart($submitter);
1306            }
1307        }
1308    }
1309}
1310