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