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