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