xref: /webtrees/app/Module/BranchesListModule.php (revision 090a06287954f43677f06ea778b3f67c029de8fe)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Module;
21
22use Aura\Router\RouterContainer;
23use Fig\Http\Message\RequestMethodInterface;
24use Fisharebest\Webtrees\Auth;
25use Fisharebest\Webtrees\Contracts\UserInterface;
26use Fisharebest\Webtrees\Registry;
27use Fisharebest\Webtrees\Family;
28use Fisharebest\Webtrees\GedcomRecord;
29use Fisharebest\Webtrees\I18N;
30use Fisharebest\Webtrees\Individual;
31use Fisharebest\Webtrees\Services\ModuleService;
32use Fisharebest\Webtrees\Soundex;
33use Fisharebest\Webtrees\Tree;
34use Illuminate\Database\Capsule\Manager as DB;
35use Illuminate\Database\Query\Builder;
36use Illuminate\Database\Query\JoinClause;
37use Psr\Http\Message\ResponseInterface;
38use Psr\Http\Message\ServerRequestInterface;
39use Psr\Http\Server\RequestHandlerInterface;
40
41use function app;
42use function array_search;
43use function assert;
44use function e;
45use function explode;
46use function in_array;
47use function is_int;
48use function key;
49use function log;
50use function next;
51use function redirect;
52use function route;
53use function strip_tags;
54use function stripos;
55use function strtolower;
56use function usort;
57use function view;
58
59/**
60 * Class BranchesListModule
61 */
62class BranchesListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface
63{
64    use ModuleListTrait;
65
66    protected const ROUTE_URL  = '/tree/{tree}/branches{/surname}';
67
68    /** @var ModuleService */
69    protected $module_service;
70
71    /**
72     * BranchesListModule constructor.
73     *
74     * @param ModuleService $module_service
75     */
76    public function __construct(ModuleService $module_service)
77    {
78        $this->module_service = $module_service;
79    }
80
81    /**
82     * Initialization.
83     *
84     * @return void
85     */
86    public function boot(): void
87    {
88        $router_container = app(RouterContainer::class);
89        assert($router_container instanceof RouterContainer);
90
91        $router_container->getMap()
92            ->get(static::class, static::ROUTE_URL, $this)
93            ->allows(RequestMethodInterface::METHOD_POST);
94    }
95
96    /**
97     * How should this module be identified in the control panel, etc.?
98     *
99     * @return string
100     */
101    public function title(): string
102    {
103        /* I18N: Name of a module/list */
104        return I18N::translate('Branches');
105    }
106
107    /**
108     * A sentence describing what this module does.
109     *
110     * @return string
111     */
112    public function description(): string
113    {
114        /* I18N: Description of the “Branches” module */
115        return I18N::translate('A list of branches of a family.');
116    }
117
118    /**
119     * CSS class for the URL.
120     *
121     * @return string
122     */
123    public function listMenuClass(): string
124    {
125        return 'menu-branches';
126    }
127
128    /**
129     * @param Tree    $tree
130     * @param mixed[] $parameters
131     *
132     * @return string
133     */
134    public function listUrl(Tree $tree, array $parameters = []): string
135    {
136        $xref = app(ServerRequestInterface::class)->getAttribute('xref', '');
137
138        if ($xref !== '') {
139            $individual = Registry::individualFactory()->make($xref, $tree);
140
141            if ($individual instanceof Individual && $individual->canShow()) {
142                $parameters['surname'] = $parameters['surname'] ?? $individual->getAllNames()[0]['surn'] ?? null;
143            }
144        }
145
146        $parameters['tree'] = $tree->name();
147
148        return route(static::class, $parameters);
149    }
150
151    /**
152     * @return array<string>
153     */
154    public function listUrlAttributes(): array
155    {
156        return [];
157    }
158
159    /**
160     * Handle URLs generated by older versions of webtrees
161     *
162     * @param ServerRequestInterface $request
163     *
164     * @return ResponseInterface
165     */
166    public function getPageAction(ServerRequestInterface $request): ResponseInterface
167    {
168        return redirect($this->listUrl($request->getAttribute('tree'), $request->getQueryParams()));
169    }
170
171    /**
172     * @param ServerRequestInterface $request
173     *
174     * @return ResponseInterface
175     */
176    public function handle(ServerRequestInterface $request): ResponseInterface
177    {
178        $tree = $request->getAttribute('tree');
179        assert($tree instanceof Tree);
180
181        $user = $request->getAttribute('user');
182        assert($user instanceof UserInterface);
183
184        Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user);
185
186        // Convert POST requests into GET requests for pretty URLs.
187        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
188            return redirect($this->listUrl($tree, (array) $request->getParsedBody()));
189        }
190
191        $surname = (string) $request->getAttribute('surname');
192
193        $params      = $request->getQueryParams();
194        $ajax        = $params['ajax'] ?? '';
195        $soundex_std = (bool) ($params['soundex_std'] ?? false);
196        $soundex_dm  = (bool) ($params['soundex_dm'] ?? false);
197
198        if ($ajax === '1') {
199            $this->layout = 'layouts/ajax';
200
201            // Highlight direct-line ancestors of this individual.
202            $xref = $tree->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF);
203            $self = Registry::individualFactory()->make($xref, $tree);
204
205            if ($surname !== '') {
206                $individuals = $this->loadIndividuals($tree, $surname, $soundex_dm, $soundex_std);
207            } else {
208                $individuals = [];
209            }
210
211            if ($self instanceof Individual) {
212                $ancestors = $this->allAncestors($self);
213            } else {
214                $ancestors = [];
215            }
216
217            return $this->viewResponse('modules/branches/list', [
218                'branches' => $this->getPatriarchsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std),
219            ]);
220        }
221
222        if ($surname !== '') {
223            /* I18N: %s is a surname */
224            $title = I18N::translate('Branches of the %s family', e($surname));
225
226            $ajax_url = $this->listUrl($tree, $params + ['ajax' => true, 'surname' => $surname]);
227        } else {
228            /* I18N: Branches of a family tree */
229            $title = I18N::translate('Branches');
230
231            $ajax_url = '';
232        }
233
234        return $this->viewResponse('branches-page', [
235            'ajax_url'    => $ajax_url,
236            'soundex_dm'  => $soundex_dm,
237            'soundex_std' => $soundex_std,
238            'surname'     => $surname,
239            'title'       => $title,
240            'tree'        => $tree,
241        ]);
242    }
243
244    /**
245     * Find all ancestors of an individual, indexed by the Sosa-Stradonitz number.
246     *
247     * @param Individual $individual
248     *
249     * @return Individual[]
250     */
251    private function allAncestors(Individual $individual): array
252    {
253        $ancestors = [
254            1 => $individual,
255        ];
256
257        do {
258            $sosa = key($ancestors);
259
260            $family = $ancestors[$sosa]->childFamilies()->first();
261
262            if ($family !== null) {
263                if ($family->husband() !== null) {
264                    $ancestors[$sosa * 2] = $family->husband();
265                }
266                if ($family->wife() !== null) {
267                    $ancestors[$sosa * 2 + 1] = $family->wife();
268                }
269            }
270        } while (next($ancestors));
271
272        return $ancestors;
273    }
274
275    /**
276     * Fetch all individuals with a matching surname
277     *
278     * @param Tree   $tree
279     * @param string $surname
280     * @param bool   $soundex_dm
281     * @param bool   $soundex_std
282     *
283     * @return Individual[]
284     */
285    private function loadIndividuals(Tree $tree, string $surname, bool $soundex_dm, bool $soundex_std): array
286    {
287        $individuals = DB::table('individuals')
288            ->join('name', static function (JoinClause $join): void {
289                $join
290                    ->on('name.n_file', '=', 'individuals.i_file')
291                    ->on('name.n_id', '=', 'individuals.i_id');
292            })
293            ->where('i_file', '=', $tree->id())
294            ->where('n_type', '<>', '_MARNM')
295            ->where(static function (Builder $query) use ($surname, $soundex_dm, $soundex_std): void {
296                $query
297                    ->where('n_surn', '=', $surname)
298                    ->orWhere('n_surname', '=', $surname);
299
300                if ($soundex_std) {
301                    $sdx = Soundex::russell($surname);
302                    if ($sdx !== '') {
303                        foreach (explode(':', $sdx) as $value) {
304                            $query->orWhere('n_soundex_surn_std', 'LIKE', '%' . $value . '%');
305                        }
306                    }
307                }
308
309                if ($soundex_dm) {
310                    $sdx = Soundex::daitchMokotoff($surname);
311                    if ($sdx !== '') {
312                        foreach (explode(':', $sdx) as $value) {
313                            $query->orWhere('n_soundex_surn_dm', 'LIKE', '%' . $value . '%');
314                        }
315                    }
316                }
317            })
318            ->select(['individuals.*'])
319            ->distinct()
320            ->get()
321            ->map(Registry::individualFactory()->mapper($tree))
322            ->filter(GedcomRecord::accessFilter())
323            ->all();
324
325        usort($individuals, Individual::birthDateComparator());
326
327        return $individuals;
328    }
329
330    /**
331     * For each individual with no ancestors, list their descendants.
332     *
333     * @param Tree         $tree
334     * @param Individual[] $individuals
335     * @param Individual[] $ancestors
336     * @param string       $surname
337     * @param bool         $soundex_dm
338     * @param bool         $soundex_std
339     *
340     * @return string
341     */
342    private function getPatriarchsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std): string
343    {
344        $html = '';
345        foreach ($individuals as $individual) {
346            foreach ($individual->childFamilies() as $family) {
347                foreach ($family->spouses() as $parent) {
348                    if (in_array($parent, $individuals, true)) {
349                        continue 3;
350                    }
351                }
352            }
353            $html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $individual, null);
354        }
355
356        return $html;
357    }
358
359    /**
360     * Generate a recursive list of descendants of an individual.
361     * If parents are specified, we can also show the pedigree (adopted, etc.).
362     *
363     * @param Tree         $tree
364     * @param Individual[] $individuals
365     * @param Individual[] $ancestors
366     * @param string       $surname
367     * @param bool         $soundex_dm
368     * @param bool         $soundex_std
369     * @param Individual   $individual
370     * @param Family|null  $parents
371     *
372     * @return string
373     */
374    private function getDescendantsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std, Individual $individual, Family $parents = null): string
375    {
376        $module = $this->module_service->findByComponent(ModuleChartInterface::class, $tree, Auth::user())->first(static function (ModuleInterface $module) {
377            return $module instanceof RelationshipsChartModule;
378        });
379
380        // A person has many names. Select the one that matches the searched surname
381        $person_name = '';
382        foreach ($individual->getAllNames() as $name) {
383            [$surn1] = explode(',', $name['sort']);
384            if ($this->surnamesMatch($surn1, $surname, $soundex_std, $soundex_dm)) {
385                $person_name = $name['full'];
386                break;
387            }
388        }
389
390        // No matching name? Typically children with a different surname. The branch stops here.
391        if ($person_name === '') {
392            return '<li title="' . strip_tags($individual->fullName()) . '"><small>' . view('icons/sex', ['sex' => $individual->sex()]) . '</small>…</li>';
393        }
394
395        // Is this individual one of our ancestors?
396        $sosa = array_search($individual, $ancestors, true);
397        if (is_int($sosa) && $module instanceof RelationshipsChartModule) {
398            $sosa_class = 'search_hit';
399            $sosa_html  = ' <a class="small wt-chart-box-' . strtolower($individual->sex()) . '" href="' . e($module->chartUrl($individual, ['xref2' => $ancestors[1]->xref()])) . '" rel="nofollow" title="' . I18N::translate('Relationship') . '">' . I18N::number($sosa) . '</a>' . self::sosaGeneration($sosa);
400        } else {
401            $sosa_class = '';
402            $sosa_html  = '';
403        }
404
405        // Generate HTML for this individual, and all their descendants
406        $indi_html = '<small>' . view('icons/sex', ['sex' => $individual->sex()]) . '</small><a class="' . $sosa_class . '" href="' . e($individual->url()) . '">' . $person_name . '</a> ' . $individual->lifespan() . $sosa_html;
407
408        // If this is not a birth pedigree (e.g. an adoption), highlight it
409        if ($parents) {
410            foreach ($individual->facts(['FAMC']) as $fact) {
411                if ($fact->target() === $parents) {
412                    $pedi = $fact->attribute('PEDI');
413
414                    if ($pedi !== '' && $pedi !== 'birth') {
415                        $pedigree  = Registry::elementFactory()->make('INDI:FAMC:PEDI')->value($pedi, $tree);
416                        $indi_html = '<span class="red">' . $pedigree . '</span> ' . $indi_html;
417                    }
418                    break;
419                }
420            }
421        }
422
423        // spouses and children
424        $spouse_families = $individual->spouseFamilies()
425            ->sort(Family::marriageDateComparator());
426
427        if ($spouse_families->isNotEmpty()) {
428            $fam_html = '';
429            foreach ($spouse_families as $family) {
430                $fam_html .= $indi_html; // Repeat the individual details for each spouse.
431
432                $spouse = $family->spouse($individual);
433                if ($spouse instanceof Individual) {
434                    $sosa = array_search($spouse, $ancestors, true);
435                    if (is_int($sosa) && $module instanceof RelationshipsChartModule) {
436                        $sosa_class = 'search_hit';
437                        $sosa_html  = ' <a class="small wt-chart-box-' . strtolower($spouse->sex()) . '" href="' . e($module->chartUrl($spouse, ['xref2' => $ancestors[1]->xref()])) . '" rel="nofollow" title="' . I18N::translate('Relationship') . '">' . I18N::number($sosa) . '</a>' . self::sosaGeneration($sosa);
438                    } else {
439                        $sosa_class = '';
440                        $sosa_html  = '';
441                    }
442                    $marriage_year = $family->getMarriageYear();
443                    if ($marriage_year) {
444                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . strip_tags($family->getMarriageDate()->display()) . '"><i class="icon-rings"></i>' . $marriage_year . '</a>';
445                    } elseif ($family->facts(['MARR'])->isNotEmpty()) {
446                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Marriage') . '"><i class="icon-rings"></i></a>';
447                    } else {
448                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Not married') . '"><i class="icon-rings"></i></a>';
449                    }
450                    $fam_html .= ' <small>' . view('icons/sex', ['sex' => $spouse->sex()]) . '</small><a class="' . $sosa_class . '" href="' . e($spouse->url()) . '">' . $spouse->fullName() . '</a> ' . $spouse->lifespan() . ' ' . $sosa_html;
451                }
452
453                $fam_html .= '<ol>';
454                foreach ($family->children() as $child) {
455                    $fam_html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $child, $family);
456                }
457                $fam_html .= '</ol>';
458            }
459
460            return '<li>' . $fam_html . '</li>';
461        }
462
463        // No spouses - just show the individual
464        return '<li>' . $indi_html . '</li>';
465    }
466
467    /**
468     * Do two surnames match?
469     *
470     * @param string $surname1
471     * @param string $surname2
472     * @param bool   $soundex_std
473     * @param bool   $soundex_dm
474     *
475     * @return bool
476     */
477    private function surnamesMatch(string $surname1, string $surname2, bool $soundex_std, bool $soundex_dm): bool
478    {
479        // One name sounds like another?
480        if ($soundex_std && Soundex::compare(Soundex::russell($surname1), Soundex::russell($surname2))) {
481            return true;
482        }
483        if ($soundex_dm && Soundex::compare(Soundex::daitchMokotoff($surname1), Soundex::daitchMokotoff($surname2))) {
484            return true;
485        }
486
487        // One is a substring of the other.  e.g. Halen / Van Halen
488        return stripos($surname1, $surname2) !== false || stripos($surname2, $surname1) !== false;
489    }
490
491    /**
492     * Convert a SOSA number into a generation number. e.g. 8 = great-grandfather = 3 generations
493     *
494     * @param int $sosa
495     *
496     * @return string
497     */
498    private static function sosaGeneration(int $sosa): string
499    {
500        $generation = (int) log($sosa, 2) + 1;
501
502        return '<sup title="' . I18N::translate('Generation') . '">' . $generation . '</sup>';
503    }
504}
505