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