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