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