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