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