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