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