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