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