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