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