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