xref: /webtrees/app/Module/RelationshipsChartModule.php (revision 1792ff1cf1956b41f3e3c853cfb279a803a71ed2)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2019 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 Closure;
24use Fig\Http\Message\RequestMethodInterface;
25use Fisharebest\Algorithm\Dijkstra;
26use Fisharebest\Webtrees\Auth;
27use Fisharebest\Webtrees\Family;
28use Fisharebest\Webtrees\FlashMessages;
29use Fisharebest\Webtrees\Functions\Functions;
30use Fisharebest\Webtrees\I18N;
31use Fisharebest\Webtrees\Individual;
32use Fisharebest\Webtrees\Menu;
33use Fisharebest\Webtrees\Services\TreeService;
34use Fisharebest\Webtrees\Tree;
35use Fisharebest\Webtrees\User;
36use Illuminate\Database\Capsule\Manager as DB;
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 assert;
44use function is_string;
45use function redirect;
46use function route;
47use function view;
48
49/**
50 * Class RelationshipsChartModule
51 */
52class RelationshipsChartModule extends AbstractModule implements ModuleChartInterface, ModuleConfigInterface, RequestHandlerInterface
53{
54    use ModuleChartTrait;
55    use ModuleConfigTrait;
56
57    protected const ROUTE_URL  = '/tree/{tree}/relationships-{ancestors}-{recursion}/{xref}{/xref2}';
58
59    /** It would be more correct to use PHP_INT_MAX, but this isn't friendly in URLs */
60    public const UNLIMITED_RECURSION = 99;
61
62    /** By default new trees allow unlimited recursion */
63    public const DEFAULT_RECURSION = '99';
64
65    /** By default new trees search for all relationships (not via ancestors) */
66    public const DEFAULT_ANCESTORS  = '0';
67    public const DEFAULT_PARAMETERS = [
68        'ancestors' => self::DEFAULT_ANCESTORS,
69        'recursion' => self::DEFAULT_RECURSION,
70    ];
71
72    /** @var TreeService */
73    private $tree_service;
74
75    /**
76     * ModuleController constructor.
77     *
78     * @param TreeService   $tree_service
79     */
80    public function __construct(TreeService $tree_service)
81    {
82        $this->tree_service = $tree_service;
83    }
84
85    /**
86     * Initialization.
87     *
88     * @return void
89     */
90    public function boot(): void
91    {
92        $router_container = app(RouterContainer::class);
93        assert($router_container instanceof RouterContainer);
94
95        $router_container->getMap()
96            ->get(static::class, static::ROUTE_URL, $this)
97            ->allows(RequestMethodInterface::METHOD_POST)
98            ->tokens([
99                'ancestors' => '\d+',
100                'recursion' => '\d+',
101            ]);
102    }
103
104    /**
105     * A sentence describing what this module does.
106     *
107     * @return string
108     */
109    public function description(): string
110    {
111        /* I18N: Description of the “RelationshipsChart” module */
112        return I18N::translate('A chart displaying relationships between two individuals.');
113    }
114
115    /**
116     * Return a menu item for this chart - for use in individual boxes.
117     *
118     * @param Individual $individual
119     *
120     * @return Menu|null
121     */
122    public function chartBoxMenu(Individual $individual): ?Menu
123    {
124        return $this->chartMenu($individual);
125    }
126
127    /**
128     * A main menu item for this chart.
129     *
130     * @param Individual $individual
131     *
132     * @return Menu
133     */
134    public function chartMenu(Individual $individual): Menu
135    {
136        $gedcomid = $individual->tree()->getUserPreference(Auth::user(), User::PREF_TREE_ACCOUNT_XREF);
137
138        if ($gedcomid !== '' && $gedcomid !== $individual->xref()) {
139            return new Menu(
140                I18N::translate('Relationship to me'),
141                $this->chartUrl($individual, ['xref2' => $gedcomid]),
142                $this->chartMenuClass(),
143                $this->chartUrlAttributes()
144            );
145        }
146
147        return new Menu(
148            $this->title(),
149            $this->chartUrl($individual),
150            $this->chartMenuClass(),
151            $this->chartUrlAttributes()
152        );
153    }
154
155    /**
156     * CSS class for the URL.
157     *
158     * @return string
159     */
160    public function chartMenuClass(): string
161    {
162        return 'menu-chart-relationship';
163    }
164
165    /**
166     * How should this module be identified in the control panel, etc.?
167     *
168     * @return string
169     */
170    public function title(): string
171    {
172        /* I18N: Name of a module/chart */
173        return I18N::translate('Relationships');
174    }
175
176    /**
177     * The URL for a page showing chart options.
178     *
179     * @param Individual $individual
180     * @param mixed[]    $parameters
181     *
182     * @return string
183     */
184    public function chartUrl(Individual $individual, array $parameters = []): string
185    {
186        return route(static::class, [
187                'xref' => $individual->xref(),
188                'tree' => $individual->tree()->name(),
189            ] + $parameters + self::DEFAULT_PARAMETERS);
190    }
191
192    /**
193     * @param ServerRequestInterface $request
194     *
195     * @return ResponseInterface
196     */
197    public function handle(ServerRequestInterface $request): ResponseInterface
198    {
199        $tree = $request->getAttribute('tree');
200        assert($tree instanceof Tree);
201
202        $xref = $request->getAttribute('xref');
203        assert(is_string($xref));
204
205        $xref2 = $request->getAttribute('xref2') ?? '';
206
207        $ajax      = $request->getQueryParams()['ajax'] ?? '';
208        $ancestors = (int) $request->getAttribute('ancestors');
209        $recursion = (int) $request->getAttribute('recursion');
210        $user      = $request->getAttribute('user');
211
212        // Convert POST requests into GET requests for pretty URLs.
213        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
214            $params = (array) $request->getParsedBody();
215
216            return redirect(route(static::class, [
217                'ancestors' => $params['ancestors'],
218                'recursion' => $params['recursion'],
219                'tree'      => $tree->name(),
220                'xref'      => $params['xref'],
221                'xref2'     => $params['xref2'],
222            ]));
223        }
224
225        $individual1 = Individual::getInstance($xref, $tree);
226        $individual2 = Individual::getInstance($xref2, $tree);
227
228        $ancestors_only = (int) $tree->getPreference('RELATIONSHIP_ANCESTORS', static::DEFAULT_ANCESTORS);
229        $max_recursion  = (int) $tree->getPreference('RELATIONSHIP_RECURSION', static::DEFAULT_RECURSION);
230
231        $recursion = min($recursion, $max_recursion);
232
233        if ($individual1 instanceof Individual) {
234            $individual1 = Auth::checkIndividualAccess($individual1, false, true);
235        }
236
237        if ($individual2 instanceof Individual) {
238            $individual2 = Auth::checkIndividualAccess($individual2, false, true);
239        }
240
241        Auth::checkComponentAccess($this, 'chart', $tree, $user);
242
243        if ($individual1 instanceof Individual && $individual2 instanceof Individual) {
244            if ($ajax === '1') {
245                return $this->chart($individual1, $individual2, $recursion, $ancestors);
246            }
247
248            /* I18N: %s are individual’s names */
249            $title    = I18N::translate('Relationships between %1$s and %2$s', $individual1->fullName(), $individual2->fullName());
250            $ajax_url = $this->chartUrl($individual1, [
251                'ajax'      => true,
252                'ancestors' => $ancestors,
253                'recursion' => $recursion,
254                'xref2'     => $individual2->xref(),
255            ]);
256        } else {
257            $title    = I18N::translate('Relationships');
258            $ajax_url = '';
259        }
260
261        return $this->viewResponse('modules/relationships-chart/page', [
262            'ajax_url'          => $ajax_url,
263            'ancestors'         => $ancestors,
264            'ancestors_only'    => $ancestors_only,
265            'ancestors_options' => $this->ancestorsOptions(),
266            'individual1'       => $individual1,
267            'individual2'       => $individual2,
268            'max_recursion'     => $max_recursion,
269            'module'            => $this->name(),
270            'recursion'         => $recursion,
271            'recursion_options' => $this->recursionOptions($max_recursion),
272            'title'             => $title,
273            'tree'              => $tree,
274        ]);
275    }
276
277    /**
278     * @param Individual $individual1
279     * @param Individual $individual2
280     * @param int        $recursion
281     * @param int        $ancestors
282     *
283     * @return ResponseInterface
284     */
285    public function chart(Individual $individual1, Individual $individual2, int $recursion, int $ancestors): ResponseInterface
286    {
287        $tree = $individual1->tree();
288
289        $max_recursion = (int) $tree->getPreference('RELATIONSHIP_RECURSION', static::DEFAULT_RECURSION);
290
291        $recursion = min($recursion, $max_recursion);
292
293        $paths = $this->calculateRelationships($individual1, $individual2, $recursion, (bool) $ancestors);
294
295        ob_start();
296        if (I18N::direction() === 'ltr') {
297            $diagonal1 = asset('css/images/dline.png');
298            $diagonal2 = asset('css/images/dline2.png');
299        } else {
300            $diagonal1 = asset('css/images/dline2.png');
301            $diagonal2 = asset('css/images/dline.png');
302        }
303
304        $num_paths = 0;
305        foreach ($paths as $path) {
306            // Extract the relationship names between pairs of individuals
307            $relationships = $this->oldStyleRelationshipPath($tree, $path);
308            if ($relationships === []) {
309                // Cannot see one of the families/individuals, due to privacy;
310                continue;
311            }
312            echo '<h3>', I18N::translate('Relationship: %s', Functions::getRelationshipNameFromPath(implode('', $relationships), $individual1, $individual2)), '</h3>';
313            $num_paths++;
314
315            // Use a table/grid for layout.
316            $table = [];
317            // Current position in the grid.
318            $x = 0;
319            $y = 0;
320            // Extent of the grid.
321            $min_y = 0;
322            $max_y = 0;
323            $max_x = 0;
324            // For each node in the path.
325            foreach ($path as $n => $xref) {
326                if ($n % 2 === 1) {
327                    switch ($relationships[$n]) {
328                        case 'hus':
329                        case 'wif':
330                        case 'spo':
331                        case 'bro':
332                        case 'sis':
333                        case 'sib':
334                            $table[$x + 1][$y] = '<div style="background:url(' . e(asset('css/images/hline.png')) . ') repeat-x center;  width: 94px; text-align: center"><div class="hline-text" style="height: 32px;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="height: 32px;">' . view('icons/arrow-right') . '</div></div>';
335                            $x                 += 2;
336                            break;
337                        case 'son':
338                        case 'dau':
339                        case 'chi':
340                            if ($n > 2 && preg_match('/fat|mot|par/', $relationships[$n - 2])) {
341                                $table[$x + 1][$y - 1] = '<div style="background:url(' . $diagonal2 . '); width: 64px; height: 64px; text-align: center;"><div style="height: 32px; text-align: end;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="height: 32px; text-align: start;">' . view('icons/arrow-down') . '</div></div>';
342                                $x                     += 2;
343                            } else {
344                                $table[$x][$y - 1] = '<div style="background:url(' . e('"' . asset('css/images/vline.png') . '"') . ') repeat-y center; height: 64px; text-align: center;"><div class="vline-text" style="display: inline-block; width:50%; line-height: 64px;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="display: inline-block; width:50%; line-height: 64px;">' . view('icons/arrow-down') . '</div></div>';
345                            }
346                            $y -= 2;
347                            break;
348                        case 'fat':
349                        case 'mot':
350                        case 'par':
351                            if ($n > 2 && preg_match('/son|dau|chi/', $relationships[$n - 2])) {
352                                $table[$x + 1][$y + 1] = '<div style="background:url(' . $diagonal1 . '); background-position: top right; width: 64px; height: 64px; text-align: center;"><div style="height: 32px; text-align: start;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="height: 32px; text-align: end;">' . view('icons/arrow-down') . '</div></div>';
353                                $x                     += 2;
354                            } else {
355                                $table[$x][$y + 1] = '<div style="background:url(' . e('"' . asset('css/images/vline.png') . '"') . ') repeat-y center; height: 64px; text-align:center; "><div class="vline-text" style="display: inline-block; width: 50%; line-height: 64px;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="display: inline-block; width: 50%; line-height: 32px">' . view('icons/arrow-up') . '</div></div>';
356                            }
357                            $y += 2;
358                            break;
359                    }
360                    $max_x = max($max_x, $x);
361                    $min_y = min($min_y, $y);
362                    $max_y = max($max_y, $y);
363                } else {
364                    $individual    = Individual::getInstance($xref, $tree);
365                    $table[$x][$y] = view('chart-box', ['individual' => $individual]);
366                }
367            }
368            echo '<div class="wt-chart wt-chart-relationships">';
369            echo '<table style="border-collapse: collapse; margin: 20px 50px;">';
370            for ($y = $max_y; $y >= $min_y; --$y) {
371                echo '<tr>';
372                for ($x = 0; $x <= $max_x; ++$x) {
373                    echo '<td style="padding: 0;">';
374                    if (isset($table[$x][$y])) {
375                        echo $table[$x][$y];
376                    }
377                    echo '</td>';
378                }
379                echo '</tr>';
380            }
381            echo '</table>';
382            echo '</div>';
383        }
384
385        if (!$num_paths) {
386            echo '<p>', I18N::translate('No link between the two individuals could be found.'), '</p>';
387        }
388
389        $html = ob_get_clean();
390
391        return response($html);
392    }
393
394    /**
395     * @param ServerRequestInterface $request
396     *
397     * @return ResponseInterface
398     */
399    public function getAdminAction(ServerRequestInterface $request): ResponseInterface
400    {
401        $this->layout = 'layouts/administration';
402
403        return $this->viewResponse('modules/relationships-chart/config', [
404            'all_trees'         => $this->tree_service->all(),
405            'ancestors_options' => $this->ancestorsOptions(),
406            'default_ancestors' => self::DEFAULT_ANCESTORS,
407            'default_recursion' => self::DEFAULT_RECURSION,
408            'recursion_options' => $this->recursionConfigOptions(),
409            'title'             => I18N::translate('Chart preferences') . ' — ' . $this->title(),
410        ]);
411    }
412
413    /**
414     * @param ServerRequestInterface $request
415     *
416     * @return ResponseInterface
417     */
418    public function postAdminAction(ServerRequestInterface $request): ResponseInterface
419    {
420        $params = (array) $request->getParsedBody();
421
422        foreach ($this->tree_service->all() as $tree) {
423            $recursion = $params['relationship-recursion-' . $tree->id()] ?? '';
424            $ancestors = $params['relationship-ancestors-' . $tree->id()] ?? '';
425
426            $tree->setPreference('RELATIONSHIP_RECURSION', $recursion);
427            $tree->setPreference('RELATIONSHIP_ANCESTORS', $ancestors);
428        }
429
430        FlashMessages::addMessage(I18N::translate('The preferences for the module “%s” have been updated.', $this->title()), 'success');
431
432        return redirect($this->getConfigLink());
433    }
434
435    /**
436     * Possible options for the ancestors option
437     *
438     * @return string[]
439     */
440    private function ancestorsOptions(): array
441    {
442        return [
443            0 => I18N::translate('Find any relationship'),
444            1 => I18N::translate('Find relationships via ancestors'),
445        ];
446    }
447
448    /**
449     * Possible options for the recursion option
450     *
451     * @return string[]
452     */
453    private function recursionConfigOptions(): array
454    {
455        return [
456            0                         => I18N::translate('none'),
457            1                         => I18N::number(1),
458            2                         => I18N::number(2),
459            3                         => I18N::number(3),
460            self::UNLIMITED_RECURSION => I18N::translate('unlimited'),
461        ];
462    }
463
464    /**
465     * Calculate the shortest paths - or all paths - between two individuals.
466     *
467     * @param Individual $individual1
468     * @param Individual $individual2
469     * @param int        $recursion How many levels of recursion to use
470     * @param bool       $ancestor  Restrict to relationships via a common ancestor
471     *
472     * @return string[][]
473     */
474    private function calculateRelationships(Individual $individual1, Individual $individual2, $recursion, $ancestor = false): array
475    {
476        $tree = $individual1->tree();
477
478        $rows = DB::table('link')
479            ->where('l_file', '=', $tree->id())
480            ->whereIn('l_type', ['FAMS', 'FAMC'])
481            ->select(['l_from', 'l_to'])
482            ->get();
483
484        // Optionally restrict the graph to the ancestors of the individuals.
485        if ($ancestor) {
486            $ancestors = $this->allAncestors($individual1->xref(), $individual2->xref(), $tree->id());
487            $exclude   = $this->excludeFamilies($individual1->xref(), $individual2->xref(), $tree->id());
488        } else {
489            $ancestors = [];
490            $exclude   = [];
491        }
492
493        $graph = [];
494
495        foreach ($rows as $row) {
496            if ($ancestors === [] || in_array($row->l_from, $ancestors, true) && !in_array($row->l_to, $exclude, true)) {
497                $graph[$row->l_from][$row->l_to] = 1;
498                $graph[$row->l_to][$row->l_from] = 1;
499            }
500        }
501
502        $xref1    = $individual1->xref();
503        $xref2    = $individual2->xref();
504        $dijkstra = new Dijkstra($graph);
505        $paths    = $dijkstra->shortestPaths($xref1, $xref2);
506
507        // Only process each exclusion list once;
508        $excluded = [];
509
510        $queue = [];
511        foreach ($paths as $path) {
512            // Insert the paths into the queue, with an exclusion list.
513            $queue[] = [
514                'path'    => $path,
515                'exclude' => [],
516            ];
517            // While there are un-extended paths
518            for ($next = current($queue); $next !== false; $next = next($queue)) {
519                // For each family on the path
520                for ($n = count($next['path']) - 2; $n >= 1; $n -= 2) {
521                    $exclude = $next['exclude'];
522                    if (count($exclude) >= $recursion) {
523                        continue;
524                    }
525                    $exclude[] = $next['path'][$n];
526                    sort($exclude);
527                    $tmp = implode('-', $exclude);
528                    if (in_array($tmp, $excluded, true)) {
529                        continue;
530                    }
531
532                    $excluded[] = $tmp;
533                    // Add any new path to the queue
534                    foreach ($dijkstra->shortestPaths($xref1, $xref2, $exclude) as $new_path) {
535                        $queue[] = [
536                            'path'    => $new_path,
537                            'exclude' => $exclude,
538                        ];
539                    }
540                }
541            }
542        }
543        // Extract the paths from the queue.
544        $paths = [];
545        foreach ($queue as $next) {
546            // The Dijkstra library does not use strict types, and converts
547            // numeric array keys (XREFs) from strings to integers;
548            $path = array_map($this->stringMapper(), $next['path']);
549
550            // Remove duplicates
551            $paths[implode('-', $next['path'])] = $path;
552        }
553
554        return $paths;
555    }
556
557    /**
558     * Convert numeric values to strings
559     *
560     * @return Closure
561     */
562    private function stringMapper(): Closure
563    {
564        return static function ($xref) {
565            return (string) $xref;
566        };
567    }
568
569    /**
570     * Find all ancestors of a list of individuals
571     *
572     * @param string $xref1
573     * @param string $xref2
574     * @param int    $tree_id
575     *
576     * @return string[]
577     */
578    private function allAncestors($xref1, $xref2, $tree_id): array
579    {
580        $ancestors = [
581            $xref1,
582            $xref2,
583        ];
584
585        $queue = [
586            $xref1,
587            $xref2,
588        ];
589        while ($queue !== []) {
590            $parents = DB::table('link AS l1')
591                ->join('link AS l2', static function (JoinClause $join): void {
592                    $join
593                        ->on('l1.l_to', '=', 'l2.l_to')
594                        ->on('l1.l_file', '=', 'l2.l_file');
595                })
596                ->where('l1.l_file', '=', $tree_id)
597                ->where('l1.l_type', '=', 'FAMC')
598                ->where('l2.l_type', '=', 'FAMS')
599                ->whereIn('l1.l_from', $queue)
600                ->pluck('l2.l_from');
601
602            $queue = [];
603            foreach ($parents as $parent) {
604                if (!in_array($parent, $ancestors, true)) {
605                    $ancestors[] = $parent;
606                    $queue[]     = $parent;
607                }
608            }
609        }
610
611        return $ancestors;
612    }
613
614    /**
615     * Find all families of two individuals
616     *
617     * @param string $xref1
618     * @param string $xref2
619     * @param int    $tree_id
620     *
621     * @return string[]
622     */
623    private function excludeFamilies($xref1, $xref2, $tree_id): array
624    {
625        return DB::table('link AS l1')
626            ->join('link AS l2', static function (JoinClause $join): void {
627                $join
628                    ->on('l1.l_to', '=', 'l2.l_to')
629                    ->on('l1.l_type', '=', 'l2.l_type')
630                    ->on('l1.l_file', '=', 'l2.l_file');
631            })
632            ->where('l1.l_file', '=', $tree_id)
633            ->where('l1.l_type', '=', 'FAMS')
634            ->where('l1.l_from', '=', $xref1)
635            ->where('l2.l_from', '=', $xref2)
636            ->pluck('l1.l_to')
637            ->all();
638    }
639
640    /**
641     * Convert a path (list of XREFs) to an "old-style" string of relationships.
642     * Return an empty array, if privacy rules prevent us viewing any node.
643     *
644     * @param Tree     $tree
645     * @param string[] $path Alternately Individual / Family
646     *
647     * @return string[]
648     */
649    private function oldStyleRelationshipPath(Tree $tree, array $path): array
650    {
651        $spouse_codes  = [
652            'M' => 'hus',
653            'F' => 'wif',
654            'U' => 'spo',
655        ];
656        $parent_codes  = [
657            'M' => 'fat',
658            'F' => 'mot',
659            'U' => 'par',
660        ];
661        $child_codes   = [
662            'M' => 'son',
663            'F' => 'dau',
664            'U' => 'chi',
665        ];
666        $sibling_codes = [
667            'M' => 'bro',
668            'F' => 'sis',
669            'U' => 'sib',
670        ];
671        $relationships = [];
672
673        for ($i = 1, $count = count($path); $i < $count; $i += 2) {
674            $family = Family::getInstance($path[$i], $tree);
675            $prev   = Individual::getInstance($path[$i - 1], $tree);
676            $next   = Individual::getInstance($path[$i + 1], $tree);
677            if (preg_match('/\n\d (HUSB|WIFE|CHIL) @' . $prev->xref() . '@/', $family->gedcom(), $match)) {
678                $rel1 = $match[1];
679            } else {
680                return [];
681            }
682            if (preg_match('/\n\d (HUSB|WIFE|CHIL) @' . $next->xref() . '@/', $family->gedcom(), $match)) {
683                $rel2 = $match[1];
684            } else {
685                return [];
686            }
687            if (($rel1 === 'HUSB' || $rel1 === 'WIFE') && ($rel2 === 'HUSB' || $rel2 === 'WIFE')) {
688                $relationships[$i] = $spouse_codes[$next->sex()];
689            } elseif (($rel1 === 'HUSB' || $rel1 === 'WIFE') && $rel2 === 'CHIL') {
690                $relationships[$i] = $child_codes[$next->sex()];
691            } elseif ($rel1 === 'CHIL' && ($rel2 === 'HUSB' || $rel2 === 'WIFE')) {
692                $relationships[$i] = $parent_codes[$next->sex()];
693            } elseif ($rel1 === 'CHIL' && $rel2 === 'CHIL') {
694                $relationships[$i] = $sibling_codes[$next->sex()];
695            }
696        }
697
698        return $relationships;
699    }
700
701    /**
702     * Possible options for the recursion option
703     *
704     * @param int $max_recursion
705     *
706     * @return string[]
707     */
708    private function recursionOptions(int $max_recursion): array
709    {
710        if ($max_recursion === static::UNLIMITED_RECURSION) {
711            $text = I18N::translate('Find all possible relationships');
712        } else {
713            $text = I18N::translate('Find other relationships');
714        }
715
716        return [
717            '0'            => I18N::translate('Find the closest relationships'),
718            $max_recursion => $text,
719        ];
720    }
721}
722