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