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