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