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