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