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