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