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