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