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