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