xref: /webtrees/app/Http/RequestHandlers/IndividualPage.php (revision a091ac74647eab281b25090b737835eeea14ae10)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2020 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\Http\RequestHandlers;
21
22use Fig\Http\Message\StatusCodeInterface;
23use Fisharebest\Webtrees\Age;
24use Fisharebest\Webtrees\Auth;
25use Fisharebest\Webtrees\Date;
26use Fisharebest\Webtrees\Fact;
27use Fisharebest\Webtrees\Factory;
28use Fisharebest\Webtrees\Functions\FunctionsPrint;
29use Fisharebest\Webtrees\Functions\FunctionsPrintFacts;
30use Fisharebest\Webtrees\GedcomCode\GedcomCodeName;
31use Fisharebest\Webtrees\GedcomTag;
32use Fisharebest\Webtrees\Http\ViewResponseTrait;
33use Fisharebest\Webtrees\I18N;
34use Fisharebest\Webtrees\Individual;
35use Fisharebest\Webtrees\Media;
36use Fisharebest\Webtrees\MediaFile;
37use Fisharebest\Webtrees\Module\ModuleSidebarInterface;
38use Fisharebest\Webtrees\Module\ModuleTabInterface;
39use Fisharebest\Webtrees\Services\ClipboardService;
40use Fisharebest\Webtrees\Services\ModuleService;
41use Fisharebest\Webtrees\Services\UserService;
42use Fisharebest\Webtrees\Tree;
43use Illuminate\Support\Collection;
44use Psr\Http\Message\ResponseInterface;
45use Psr\Http\Message\ServerRequestInterface;
46use Psr\Http\Server\RequestHandlerInterface;
47use stdClass;
48
49use function array_map;
50use function assert;
51use function date;
52use function e;
53use function explode;
54use function implode;
55use function is_string;
56use function ob_get_clean;
57use function ob_start;
58use function preg_match_all;
59use function preg_replace;
60use function redirect;
61use function route;
62use function str_replace;
63use function strpos;
64use function strtoupper;
65use function view;
66
67use const PREG_SET_ORDER;
68
69/**
70 * Show an individual's page.
71 */
72class IndividualPage implements RequestHandlerInterface
73{
74    use ViewResponseTrait;
75
76    /** @var ClipboardService */
77    private $clipboard_service;
78
79    /** @var ModuleService */
80    private $module_service;
81
82    /** @var UserService */
83    private $user_service;
84
85    /**
86     * IndividualPage constructor.
87     *
88     * @param ClipboardService $clipboard_service
89     * @param ModuleService    $module_service
90     * @param UserService      $user_service
91     */
92    public function __construct(ClipboardService $clipboard_service, ModuleService $module_service, UserService $user_service)
93    {
94        $this->clipboard_service = $clipboard_service;
95        $this->module_service    = $module_service;
96        $this->user_service      = $user_service;
97    }
98
99    /**
100     * @param ServerRequestInterface $request
101     *
102     * @return ResponseInterface
103     */
104    public function handle(ServerRequestInterface $request): ResponseInterface
105    {
106        $tree = $request->getAttribute('tree');
107        assert($tree instanceof Tree);
108
109        $xref = $request->getAttribute('xref');
110        assert(is_string($xref));
111
112        $individual = Factory::individual()->make($xref, $tree);
113        $individual = Auth::checkIndividualAccess($individual);
114
115        // Redirect to correct xref/slug
116        if ($individual->xref() !== $xref || $request->getAttribute('slug') !== $individual->slug()) {
117            return redirect($individual->url(), StatusCodeInterface::STATUS_MOVED_PERMANENTLY);
118        }
119
120        // What is (was) the age of the individual
121        $bdate = $individual->getBirthDate();
122        $ddate = $individual->getDeathDate();
123
124        if ($individual->isDead()) {
125            // If dead, show age at death
126            $age = (new Age($bdate, $ddate))->ageAtEvent(false);
127        } else {
128            // If living, show age today
129            $today = strtoupper(date('d M Y'));
130            $age   = (new Age($bdate, new Date($today)))->ageAtEvent(true);
131        }
132
133        // What images are linked to this individual
134        $individual_media = new Collection();
135        foreach ($individual->facts(['OBJE']) as $fact) {
136            $media_object = $fact->target();
137            if ($media_object instanceof Media) {
138                $media_file = $media_object->firstImageFile();
139                if ($media_file instanceof MediaFile) {
140                    $individual_media->add($media_file);
141                }
142            }
143        }
144
145        $name_records = new Collection();
146        foreach ($individual->facts(['NAME']) as $n => $name_fact) {
147            $name_records->add($this->formatNameRecord($tree, $n, $name_fact));
148        }
149
150        $sex_records = new Collection();
151        foreach ($individual->facts(['SEX']) as $n => $sex_fact) {
152            $sex_records->add($this->formatSexRecord($sex_fact));
153        }
154
155        // If this individual is linked to a user account, show the link
156        $user_link = '';
157        if (Auth::isAdmin()) {
158            $users = $this->user_service->findByIndividual($individual);
159            foreach ($users as $user) {
160                $user_link = ' —  <a href="' . e(route('admin-users', ['filter' => $user->email()])) . '">' . e($user->userName()) . '</a>';
161            }
162        }
163
164        return $this->viewResponse('individual-page', [
165            'age'              => $age,
166            'clipboard_facts'  => $this->clipboard_service->pastableFacts($individual, new Collection()),
167            'individual'       => $individual,
168            'individual_media' => $individual_media,
169            'meta_description' => $this->metaDescription($individual),
170            'meta_robots'      => 'index,follow',
171            'name_records'     => $name_records,
172            'sex_records'      => $sex_records,
173            'sidebars'         => $this->getSidebars($individual),
174            'tabs'             => $this->getTabs($individual),
175            'significant'      => $this->significant($individual),
176            'title'            => $individual->fullName() . ' ' . $individual->lifespan(),
177            'tree'             => $tree,
178            'user_link'        => $user_link,
179        ]);
180    }
181
182    /**
183     * @param Individual $individual
184     *
185     * @return string
186     */
187    private function metaDescription(Individual $individual): string
188    {
189        $meta_facts = [];
190
191        $birth_date  = $individual->getBirthDate();
192        $birth_place = $individual->getBirthPlace();
193
194        if ($birth_date->isOK() || $birth_place->id() !== 0) {
195            $meta_facts[] = I18N::translate('Birth') . ' ' .
196                $birth_date->display(false, null, false) . ' ' .
197                $birth_place->placeName();
198        }
199
200        $death_date  = $individual->getDeathDate();
201        $death_place = $individual->getDeathPlace();
202
203        if ($death_date->isOK() || $death_place->id() !== 0) {
204            $meta_facts[] = I18N::translate('Death') . ' ' .
205                $death_date->display(false, null, false) . ' ' .
206                $death_place->placeName();
207        }
208
209        foreach ($individual->childFamilies() as $family) {
210            $meta_facts[] = I18N::translate('Parents') . ' ' . $family->fullName();
211        }
212
213        foreach ($individual->spouseFamilies() as $family) {
214            $spouse = $family->spouse($individual);
215            if ($spouse instanceof Individual) {
216                $meta_facts[] = I18N::translate('Spouse') . ' ' . $spouse->fullName();
217            }
218
219            $child_names = $family->children()->map(static function (Individual $individual): string {
220                return e($individual->getAllNames()[0]['givn']);
221            })->implode(', ');
222
223
224            if ($child_names !== '') {
225                $meta_facts[] = I18N::translate('Children') . ' ' . $child_names;
226            }
227        }
228
229        $meta_facts = array_map('strip_tags', $meta_facts);
230        $meta_facts = array_map('trim', $meta_facts);
231
232        return implode(', ', $meta_facts);
233    }
234
235    /**
236     * Format a name record
237     *
238     * @param Tree $tree
239     * @param int  $n
240     * @param Fact $fact
241     *
242     * @return string
243     */
244    private function formatNameRecord(Tree $tree, $n, Fact $fact): string
245    {
246        $individual = $fact->record();
247
248        // Create a dummy record, so we can extract the formatted NAME value from it.
249        $dummy = Factory::individual()->new(
250            'xref',
251            "0 @xref@ INDI\n1 DEAT Y\n" . $fact->gedcom(),
252            null,
253            $individual->tree()
254        );
255        $dummy->setPrimaryName(0); // Make sure we use the name from "1 NAME"
256
257        $container_class = 'card';
258        $content_class   = 'collapse';
259        $aria            = 'false';
260
261        if ($n === 0) {
262            $content_class = 'collapse show';
263            $aria          = 'true';
264        }
265        if ($fact->isPendingDeletion()) {
266            $container_class .= ' wt-old';
267        } elseif ($fact->isPendingAddition()) {
268            $container_class .= ' wt-new';
269        }
270
271        ob_start();
272        echo '<dl class="row mb-0"><dt class="col-md-4 col-lg-3">', I18N::translate('Name'), '</dt>';
273        echo '<dd class="col-md-8 col-lg-9">', $dummy->fullName(), '</dd>';
274        $ct = preg_match_all('/\n2 (\w+) (.*)/', $fact->gedcom(), $nmatch, PREG_SET_ORDER);
275        for ($i = 0; $i < $ct; $i++) {
276            $tag = $nmatch[$i][1];
277            if ($tag !== 'SOUR' && $tag !== 'NOTE' && $tag !== 'SPFX') {
278                echo '<dt class="col-md-4 col-lg-3">', GedcomTag::getLabel($tag, $individual), '</dt>';
279                echo '<dd class="col-md-8 col-lg-9">'; // Before using dir="auto" on this field, note that Gecko treats this as an inline element but WebKit treats it as a block element
280                if (isset($nmatch[$i][2])) {
281                    $name = e($nmatch[$i][2]);
282                    $name = str_replace('/', '', $name);
283                    $name = preg_replace('/(\S*)\*/', '<span class="starredname">\\1</span>', $name);
284                    switch ($tag) {
285                        case 'TYPE':
286                            echo GedcomCodeName::getValue($name, $individual);
287                            break;
288                        case 'SURN':
289                            // The SURN field is not necessarily the surname.
290                            // Where it is not a substring of the real surname, show it after the real surname.
291                            $surname = e($dummy->getAllNames()[0]['surname']);
292                            $surns   = preg_replace('/, */', ' ', $nmatch[$i][2]);
293                            if (strpos($dummy->getAllNames()[0]['surname'], $surns) !== false) {
294                                echo '<span dir="auto">' . $surname . '</span>';
295                            } else {
296                                echo I18N::translate('%1$s (%2$s)', '<span dir="auto">' . $surname . '</span>', '<span dir="auto">' . $name . '</span>');
297                            }
298                            break;
299                        default:
300                            echo '<span dir="auto">' . $name . '</span>';
301                            break;
302                    }
303                }
304                echo '</dd>';
305            }
306        }
307        echo '</dl>';
308        if (strpos($fact->gedcom(), "\n2 SOUR") !== false) {
309            echo '<div id="indi_sour" class="clearfix">', FunctionsPrintFacts::printFactSources($tree, $fact->gedcom(), 2), '</div>';
310        }
311        if (strpos($fact->gedcom(), "\n2 NOTE") !== false) {
312            echo '<div id="indi_note" class="clearfix">', FunctionsPrint::printFactNotes($tree, $fact->gedcom(), 2), '</div>';
313        }
314        $content = ob_get_clean();
315
316        if ($fact->canEdit()) {
317            $edit_links =
318                '<a class="btn btn-link" href="#" data-confirm="' . I18N::translate('Are you sure you want to delete this fact?') . '" data-post-url="' . e(route(DeleteFact::class, ['tree' => $individual->tree()->name(), 'xref' => $individual->xref(), 'fact_id' => $fact->id()])) . '" title="' . I18N::translate('Delete this name') . '">' . view('icons/delete') . '<span class="sr-only">' . I18N::translate('Delete this name') . '</span></a>' .
319                '<a class="btn btn-link" href="' . e(route('edit-name', ['xref' => $individual->xref(), 'fact_id' => $fact->id(), 'tree' => $individual->tree()->name()])) . '" title="' . I18N::translate('Edit the name') . '">' . view('icons/edit') . '<span class="sr-only">' . I18N::translate('Edit the name') . '</span></a>';
320        } else {
321            $edit_links = '';
322        }
323
324        return '
325			<div class="' . $container_class . '">
326        <div class="card-header" role="tab" id="name-header-' . $n . '">
327		        <a data-toggle="collapse" data-parent="#individual-names" href="#name-content-' . $n . '" aria-expanded="' . $aria . '" aria-controls="name-content-' . $n . '">' . $dummy->fullName() . '</a>
328		      ' . $edit_links . '
329        </div>
330		    <div id="name-content-' . $n . '" class="' . $content_class . '" role="tabpanel" aria-labelledby="name-header-' . $n . '">
331		      <div class="card-body">' . $content . '</div>
332        </div>
333      </div>';
334    }
335
336    /**
337     * print information for a sex record
338     *
339     * @param Fact $fact
340     *
341     * @return string
342     */
343    private function formatSexRecord(Fact $fact): string
344    {
345        $individual = $fact->record();
346
347        switch ($fact->value()) {
348            case 'M':
349                $sex = I18N::translate('Male');
350                break;
351            case 'F':
352                $sex = I18N::translate('Female');
353                break;
354            default:
355                $sex = I18N::translateContext('unknown gender', 'Unknown');
356                break;
357        }
358
359        $container_class = 'card';
360        if ($fact->isPendingDeletion()) {
361            $container_class .= ' wt-old';
362        } elseif ($fact->isPendingAddition()) {
363            $container_class .= ' wt-new';
364        }
365
366        if ($individual->canEdit()) {
367            $edit_links = '<a class="btn btn-link" href="' . e(route(EditFact::class, ['xref' => $individual->xref(), 'fact_id' => $fact->id(), 'tree' => $individual->tree()->name()])) . '" title="' . I18N::translate('Edit the gender') . '">' . view('icons/edit') . '<span class="sr-only">' . I18N::translate('Edit the gender') . '</span></a>';
368        } else {
369            $edit_links = '';
370        }
371
372        return '
373		<div class="' . $container_class . '">
374			<div class="card-header" role="tab" id="name-header-add">
375				<div class="card-title mb-0">
376					<b>' . I18N::translate('Gender') . '</b> ' . $sex . $edit_links . '
377				</div>
378			</div>
379		</div>';
380    }
381
382    /**
383     * Which tabs should we show on this individual's page.
384     * We don't show empty tabs.
385     *
386     * @param Individual $individual
387     *
388     * @return Collection<ModuleSidebarInterface>
389     */
390    public function getSidebars(Individual $individual): Collection
391    {
392        return $this->module_service->findByComponent(ModuleSidebarInterface::class, $individual->tree(), Auth::user())
393            ->filter(static function (ModuleSidebarInterface $sidebar) use ($individual): bool {
394                return $sidebar->hasSidebarContent($individual);
395            });
396    }
397
398    /**
399     * Which tabs should we show on this individual's page.
400     * We don't show empty tabs.
401     *
402     * @param Individual $individual
403     *
404     * @return Collection<ModuleTabInterface>
405     */
406    public function getTabs(Individual $individual): Collection
407    {
408        return $this->module_service->findByComponent(ModuleTabInterface::class, $individual->tree(), Auth::user())
409            ->filter(static function (ModuleTabInterface $tab) use ($individual): bool {
410                return $tab->hasTabContent($individual);
411            });
412    }
413
414    /**
415     * What are the significant elements of this page?
416     * The layout will need them to generate URLs for charts and reports.
417     *
418     * @param Individual $individual
419     *
420     * @return stdClass
421     */
422    private function significant(Individual $individual): stdClass
423    {
424        [$surname] = explode(',', $individual->sortName());
425
426        $family = $individual->childFamilies()->merge($individual->spouseFamilies())->first();
427
428        return (object) [
429            'family'     => $family,
430            'individual' => $individual,
431            'surname'    => $surname,
432        ];
433    }
434}
435