xref: /webtrees/app/Http/RequestHandlers/IndividualPage.php (revision 9e3c7a99c9105176a1bb6f94a576c597b9754970)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2019 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Http\RequestHandlers;
21
22use Fig\Http\Message\StatusCodeInterface;
23use Fisharebest\Webtrees\Auth;
24use Fisharebest\Webtrees\Date;
25use Fisharebest\Webtrees\Fact;
26use Fisharebest\Webtrees\Functions\FunctionsDate;
27use Fisharebest\Webtrees\Functions\FunctionsPrint;
28use Fisharebest\Webtrees\Functions\FunctionsPrintFacts;
29use Fisharebest\Webtrees\GedcomCode\GedcomCodeName;
30use Fisharebest\Webtrees\GedcomTag;
31use Fisharebest\Webtrees\Http\ViewResponseTrait;
32use Fisharebest\Webtrees\I18N;
33use Fisharebest\Webtrees\Individual;
34use Fisharebest\Webtrees\Media;
35use Fisharebest\Webtrees\MediaFile;
36use Fisharebest\Webtrees\Module\ModuleSidebarInterface;
37use Fisharebest\Webtrees\Module\ModuleTabInterface;
38use Fisharebest\Webtrees\Services\ClipboardService;
39use Fisharebest\Webtrees\Services\ModuleService;
40use Fisharebest\Webtrees\Services\UserService;
41use Fisharebest\Webtrees\Tree;
42use Illuminate\Support\Collection;
43use Psr\Http\Message\ResponseInterface;
44use Psr\Http\Message\ServerRequestInterface;
45use Psr\Http\Server\RequestHandlerInterface;
46use stdClass;
47
48use function assert;
49use function e;
50use function explode;
51use function is_string;
52use function ob_get_clean;
53use function ob_start;
54use function preg_match_all;
55use function preg_replace;
56use function redirect;
57use function route;
58use function str_replace;
59use function strpos;
60
61/**
62 * Show an individual's page.
63 */
64class IndividualPage implements RequestHandlerInterface
65{
66    use ViewResponseTrait;
67
68    /** @var ClipboardService */
69    private $clipboard_service;
70
71    /** @var ModuleService */
72    private $module_service;
73
74    /** @var UserService */
75    private $user_service;
76
77    /**
78     * IndividualPage constructor.
79     *
80     * @param ClipboardService $clipboard_service
81     * @param ModuleService    $module_service
82     * @param UserService      $user_service
83     */
84    public function __construct(ClipboardService $clipboard_service, ModuleService $module_service, UserService $user_service)
85    {
86        $this->clipboard_service = $clipboard_service;
87        $this->module_service    = $module_service;
88        $this->user_service      = $user_service;
89    }
90
91    /**
92     * @param ServerRequestInterface $request
93     *
94     * @return ResponseInterface
95     */
96    public function handle(ServerRequestInterface $request): ResponseInterface
97    {
98        $tree = $request->getAttribute('tree');
99        assert($tree instanceof Tree);
100
101        $xref = $request->getAttribute('xref');
102        assert(is_string($xref));
103
104        $individual = Individual::getInstance($xref, $tree);
105        $individual = Auth::checkIndividualAccess($individual);
106
107        // Redirect to correct xref/slug
108        if ($individual->xref() !== $xref || $request->getAttribute('slug') !== $individual->slug()) {
109            return redirect($individual->url(), StatusCodeInterface::STATUS_MOVED_PERMANENTLY);
110        }
111
112        // What is (was) the age of the individual
113        $bdate = $individual->getBirthDate();
114        $ddate = $individual->getDeathDate();
115        if ($bdate->isOK() && !$individual->isDead()) {
116            // If living display age
117            $age = ' (' . I18N::translate('age') . ' ' . FunctionsDate::getAgeAtEvent(Date::getAgeGedcom($bdate, new Date(strtoupper(date('d M Y'))))) . ')';
118        } elseif ($bdate->isOK() && $ddate->isOK()) {
119            // If dead, show age at death
120            $age = ' (' . I18N::translate('age') . ' ' . FunctionsDate::getAgeAtEvent(Date::getAgeGedcom($bdate, $ddate)) . ')';
121        } else {
122            $age = '';
123        }
124
125        // What images are linked to this individual
126        $individual_media = new Collection();
127        foreach ($individual->facts(['OBJE']) as $fact) {
128            $media_object = $fact->target();
129            if ($media_object instanceof Media) {
130                $media_file = $media_object->firstImageFile();
131                if ($media_file instanceof MediaFile) {
132                    $individual_media->add($media_file);
133                }
134            }
135        }
136
137        $name_records = new Collection();
138        foreach ($individual->facts(['NAME']) as $n => $name_fact) {
139            $name_records->add($this->formatNameRecord($tree, $n, $name_fact));
140        }
141
142        $sex_records = new Collection();
143        foreach ($individual->facts(['SEX']) as $n => $sex_fact) {
144            $sex_records->add($this->formatSexRecord($sex_fact));
145        }
146
147        // If this individual is linked to a user account, show the link
148        $user_link = '';
149        if (Auth::isAdmin()) {
150            $users = $this->user_service->findByIndividual($individual);
151            foreach ($users as $user) {
152                $user_link = ' —  <a href="' . e(route('admin-users', ['filter' => $user->email()])) . '">' . e($user->userName()) . '</a>';
153            }
154        }
155
156        return $this->viewResponse('individual-page', [
157            'age'              => $age,
158            'clipboard_facts'  => $this->clipboard_service->pastableFacts($individual, new Collection()),
159            'individual'       => $individual,
160            'individual_media' => $individual_media,
161            'meta_robots'      => 'index,follow',
162            'name_records'     => $name_records,
163            'sex_records'      => $sex_records,
164            'sidebars'         => $this->getSidebars($individual),
165            'tabs'             => $this->getTabs($individual),
166            'significant'      => $this->significant($individual),
167            'title'            => $individual->fullName() . ' ' . $individual->lifespan(),
168            'tree'             => $tree,
169            'user_link'        => $user_link,
170        ]);
171    }
172
173    /**
174     * Format a name record
175     *
176     * @param Tree $tree
177     * @param int  $n
178     * @param Fact $fact
179     *
180     * @return string
181     */
182    private function formatNameRecord(Tree $tree, $n, Fact $fact): string
183    {
184        $individual = $fact->record();
185
186        // Create a dummy record, so we can extract the formatted NAME value from it.
187        $dummy = new Individual(
188            'xref',
189            "0 @xref@ INDI\n1 DEAT Y\n" . $fact->gedcom(),
190            null,
191            $individual->tree()
192        );
193        $dummy->setPrimaryName(0); // Make sure we use the name from "1 NAME"
194
195        $container_class = 'card';
196        $content_class   = 'collapse';
197        $aria            = 'false';
198
199        if ($n === 0) {
200            $content_class = 'collapse show';
201            $aria          = 'true';
202        }
203        if ($fact->isPendingDeletion()) {
204            $container_class .= ' wt-old';
205        } elseif ($fact->isPendingAddition()) {
206            $container_class .= ' wt-new';
207        }
208
209        ob_start();
210        echo '<dl class="row mb-0"><dt class="col-md-4 col-lg-3">', I18N::translate('Name'), '</dt>';
211        echo '<dd class="col-md-8 col-lg-9">', $dummy->fullName(), '</dd>';
212        $ct = preg_match_all('/\n2 (\w+) (.*)/', $fact->gedcom(), $nmatch, PREG_SET_ORDER);
213        for ($i = 0; $i < $ct; $i++) {
214            $tag = $nmatch[$i][1];
215            if ($tag !== 'SOUR' && $tag !== 'NOTE' && $tag !== 'SPFX') {
216                echo '<dt class="col-md-4 col-lg-3">', GedcomTag::getLabel($tag, $individual), '</dt>';
217                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
218                if (isset($nmatch[$i][2])) {
219                    $name = e($nmatch[$i][2]);
220                    $name = str_replace('/', '', $name);
221                    $name = preg_replace('/(\S*)\*/', '<span class="starredname">\\1</span>', $name);
222                    switch ($tag) {
223                        case 'TYPE':
224                            echo GedcomCodeName::getValue($name, $individual);
225                            break;
226                        case 'SURN':
227                            // The SURN field is not necessarily the surname.
228                            // Where it is not a substring of the real surname, show it after the real surname.
229                            $surname = e($dummy->getAllNames()[0]['surname']);
230                            $surns   = preg_replace('/, */', ' ', $nmatch[$i][2]);
231                            if (strpos($dummy->getAllNames()[0]['surname'], $surns) !== false) {
232                                echo '<span dir="auto">' . $surname . '</span>';
233                            } else {
234                                echo I18N::translate('%1$s (%2$s)', '<span dir="auto">' . $surname . '</span>', '<span dir="auto">' . $name . '</span>');
235                            }
236                            break;
237                        default:
238                            echo '<span dir="auto">' . $name . '</span>';
239                            break;
240                    }
241                }
242                echo '</dd>';
243            }
244        }
245        echo '</dl>';
246        if (strpos($fact->gedcom(), "\n2 SOUR") !== false) {
247            echo '<div id="indi_sour" class="clearfix">', FunctionsPrintFacts::printFactSources($tree, $fact->gedcom(), 2), '</div>';
248        }
249        if (strpos($fact->gedcom(), "\n2 NOTE") !== false) {
250            echo '<div id="indi_note" class="clearfix">', FunctionsPrint::printFactNotes($tree, $fact->gedcom(), 2), '</div>';
251        }
252        $content = ob_get_clean();
253
254        if ($fact->canEdit()) {
255            $edit_links =
256                '<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>' .
257                '<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>';
258        } else {
259            $edit_links = '';
260        }
261
262        return '
263			<div class="' . $container_class . '">
264        <div class="card-header" role="tab" id="name-header-' . $n . '">
265		        <a data-toggle="collapse" data-parent="#individual-names" href="#name-content-' . $n . '" aria-expanded="' . $aria . '" aria-controls="name-content-' . $n . '">' . $dummy->fullName() . '</a>
266		      ' . $edit_links . '
267        </div>
268		    <div id="name-content-' . $n . '" class="' . $content_class . '" role="tabpanel" aria-labelledby="name-header-' . $n . '">
269		      <div class="card-body">' . $content . '</div>
270        </div>
271      </div>';
272    }
273
274    /**
275     * print information for a sex record
276     *
277     * @param Fact $fact
278     *
279     * @return string
280     */
281    private function formatSexRecord(Fact $fact): string
282    {
283        $individual = $fact->record();
284
285        switch ($fact->value()) {
286            case 'M':
287                $sex = I18N::translate('Male');
288                break;
289            case 'F':
290                $sex = I18N::translate('Female');
291                break;
292            default:
293                $sex = I18N::translateContext('unknown gender', 'Unknown');
294                break;
295        }
296
297        $container_class = 'card';
298        if ($fact->isPendingDeletion()) {
299            $container_class .= ' wt-old';
300        } elseif ($fact->isPendingAddition()) {
301            $container_class .= ' wt-new';
302        }
303
304        if ($individual->canEdit()) {
305            $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>';
306        } else {
307            $edit_links = '';
308        }
309
310        return '
311		<div class="' . $container_class . '">
312			<div class="card-header" role="tab" id="name-header-add">
313				<div class="card-title mb-0">
314					<b>' . I18N::translate('Gender') . '</b> ' . $sex . $edit_links . '
315				</div>
316			</div>
317		</div>';
318    }
319
320    /**
321     * Which tabs should we show on this individual's page.
322     * We don't show empty tabs.
323     *
324     * @param Individual $individual
325     *
326     * @return Collection<ModuleSidebarInterface>
327     */
328    public function getSidebars(Individual $individual): Collection
329    {
330        return $this->module_service->findByComponent(ModuleSidebarInterface::class, $individual->tree(), Auth::user())
331            ->filter(static function (ModuleSidebarInterface $sidebar) use ($individual): bool {
332                return $sidebar->hasSidebarContent($individual);
333            });
334    }
335
336    /**
337     * Which tabs should we show on this individual's page.
338     * We don't show empty tabs.
339     *
340     * @param Individual $individual
341     *
342     * @return Collection<ModuleTabInterface>
343     */
344    public function getTabs(Individual $individual): Collection
345    {
346        return $this->module_service->findByComponent(ModuleTabInterface::class, $individual->tree(), Auth::user())
347            ->filter(static function (ModuleTabInterface $tab) use ($individual): bool {
348                return $tab->hasTabContent($individual);
349            });
350    }
351
352    /**
353     * What are the significant elements of this page?
354     * The layout will need them to generate URLs for charts and reports.
355     *
356     * @param Individual $individual
357     *
358     * @return stdClass
359     */
360    private function significant(Individual $individual): stdClass
361    {
362        [$surname] = explode(',', $individual->sortName());
363
364        $family = $individual->childFamilies()->merge($individual->spouseFamilies())->first();
365
366        return (object) [
367            'family'     => $family,
368            'individual' => $individual,
369            'surname'    => $surname,
370        ];
371    }
372}
373