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