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