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