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