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 Fig\Http\Message\RequestMethodInterface; 23use Fisharebest\Webtrees\Auth; 24use Fisharebest\Webtrees\Contracts\UserInterface; 25use Fisharebest\Webtrees\Elements\PedigreeLinkageType; 26use Fisharebest\Webtrees\Family; 27use Fisharebest\Webtrees\GedcomRecord; 28use Fisharebest\Webtrees\I18N; 29use Fisharebest\Webtrees\Individual; 30use Fisharebest\Webtrees\Registry; 31use Fisharebest\Webtrees\Services\ModuleService; 32use Fisharebest\Webtrees\Soundex; 33use Fisharebest\Webtrees\Tree; 34use Fisharebest\Webtrees\Validator; 35use Illuminate\Database\Capsule\Manager as DB; 36use Illuminate\Database\Query\Builder; 37use Illuminate\Database\Query\JoinClause; 38use Psr\Http\Message\ResponseInterface; 39use Psr\Http\Message\ServerRequestInterface; 40use Psr\Http\Server\RequestHandlerInterface; 41 42use function app; 43use function array_search; 44use function assert; 45use function e; 46use function explode; 47use function in_array; 48use function is_int; 49use function key; 50use function log; 51use function next; 52use function redirect; 53use function route; 54use function strip_tags; 55use function stripos; 56use function strtolower; 57use function usort; 58use function view; 59 60/** 61 * Class BranchesListModule 62 */ 63class BranchesListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface 64{ 65 use ModuleListTrait; 66 67 protected const ROUTE_URL = '/tree/{tree}/branches{/surname}'; 68 69 private ModuleService $module_service; 70 71 /** 72 * BranchesListModule constructor. 73 * 74 * @param ModuleService $module_service 75 */ 76 public function __construct(ModuleService $module_service) 77 { 78 $this->module_service = $module_service; 79 } 80 81 /** 82 * Initialization. 83 * 84 * @return void 85 */ 86 public function boot(): void 87 { 88 Registry::routeFactory()->routeMap() 89 ->get(static::class, static::ROUTE_URL, $this) 90 ->allows(RequestMethodInterface::METHOD_POST); 91 } 92 93 /** 94 * How should this module be identified in the control panel, etc.? 95 * 96 * @return string 97 */ 98 public function title(): string 99 { 100 /* I18N: Name of a module/list */ 101 return I18N::translate('Branches'); 102 } 103 104 /** 105 * A sentence describing what this module does. 106 * 107 * @return string 108 */ 109 public function description(): string 110 { 111 /* I18N: Description of the “Branches” module */ 112 return I18N::translate('A list of branches of a family.'); 113 } 114 115 /** 116 * CSS class for the URL. 117 * 118 * @return string 119 */ 120 public function listMenuClass(): string 121 { 122 return 'menu-branches'; 123 } 124 125 /** 126 * @param Tree $tree 127 * @param array<bool|int|string|array<string>|null> $parameters 128 * 129 * @return string 130 */ 131 public function listUrl(Tree $tree, array $parameters = []): string 132 { 133 $request = app(ServerRequestInterface::class); 134 assert($request instanceof ServerRequestInterface); 135 136 $xref = Validator::attributes($request)->isXref()->string('xref', ''); 137 138 if ($xref !== '') { 139 $individual = Registry::individualFactory()->make($xref, $tree); 140 141 if ($individual instanceof Individual && $individual->canShow()) { 142 $parameters['surname'] = $parameters['surname'] ?? $individual->getAllNames()[0]['surn'] ?? null; 143 } 144 } 145 146 $parameters['tree'] = $tree->name(); 147 148 return route(static::class, $parameters); 149 } 150 151 /** 152 * @return array<string> 153 */ 154 public function listUrlAttributes(): array 155 { 156 return []; 157 } 158 159 /** 160 * Handle URLs generated by older versions of webtrees 161 * 162 * @param ServerRequestInterface $request 163 * 164 * @return ResponseInterface 165 */ 166 public function getPageAction(ServerRequestInterface $request): ResponseInterface 167 { 168 $tree = Validator::attributes($request)->tree(); 169 $user = Validator::attributes($request)->user(); 170 171 Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user); 172 173 return redirect($this->listUrl($tree, [ 174 'soundex_dm' => Validator::queryParams($request)->boolean('soundex_dm'), 175 'soundex_std' => Validator::queryParams($request)->boolean('soundex_std'), 176 'surname' => 'x' . Validator::queryParams($request)->string('surname'), 177 ])); 178 } 179 180 /** 181 * @param ServerRequestInterface $request 182 * 183 * @return ResponseInterface 184 */ 185 public function handle(ServerRequestInterface $request): ResponseInterface 186 { 187 $tree = Validator::attributes($request)->tree(); 188 $user = Validator::attributes($request)->user(); 189 190 Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user); 191 192 // Convert POST requests into GET requests for pretty URLs. 193 if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 194 return redirect($this->listUrl($tree, [ 195 'soundex_dm' => Validator::parsedBody($request)->boolean('soundex_dm', false), 196 'soundex_std' => Validator::parsedBody($request)->boolean('soundex_std', false), 197 'surname' => Validator::parsedBody($request)->string('surname'), 198 ])); 199 } 200 201 $surname = Validator::attributes($request)->string('surname', ''); 202 $soundex_std = Validator::queryParams($request)->boolean('soundex_std', false); 203 $soundex_dm = Validator::queryParams($request)->boolean('soundex_dm', false); 204 $ajax = Validator::queryParams($request)->boolean('ajax', false); 205 206 if ($ajax) { 207 $this->layout = 'layouts/ajax'; 208 209 // Highlight direct-line ancestors of this individual. 210 $xref = $tree->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF); 211 $self = Registry::individualFactory()->make($xref, $tree); 212 213 if ($surname !== '') { 214 $individuals = $this->loadIndividuals($tree, $surname, $soundex_dm, $soundex_std); 215 } else { 216 $individuals = []; 217 } 218 219 if ($self instanceof Individual) { 220 $ancestors = $this->allAncestors($self); 221 } else { 222 $ancestors = []; 223 } 224 225 return $this->viewResponse('modules/branches/list', [ 226 'branches' => $this->getPatriarchsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std), 227 ]); 228 } 229 230 if ($surname !== '') { 231 /* I18N: %s is a surname */ 232 $title = I18N::translate('Branches of the %s family', e($surname)); 233 234 $ajax_url = $this->listUrl($tree, [ 235 'ajax' => true, 236 'soundex_dm' => $soundex_dm, 237 'soundex_std' => $soundex_std, 238 'surname' => $surname, 239 ]); 240 } else { 241 /* I18N: Branches of a family tree */ 242 $title = I18N::translate('Branches'); 243 244 $ajax_url = ''; 245 } 246 247 return $this->viewResponse('branches-page', [ 248 'ajax_url' => $ajax_url, 249 'soundex_dm' => $soundex_dm, 250 'soundex_std' => $soundex_std, 251 'surname' => $surname, 252 'title' => $title, 253 'tree' => $tree, 254 ]); 255 } 256 257 /** 258 * Find all ancestors of an individual, indexed by the Sosa-Stradonitz number. 259 * 260 * @param Individual $individual 261 * 262 * @return array<Individual> 263 */ 264 private function allAncestors(Individual $individual): array 265 { 266 $ancestors = [ 267 1 => $individual, 268 ]; 269 270 do { 271 $sosa = key($ancestors); 272 273 $family = $ancestors[$sosa]->childFamilies()->first(); 274 275 if ($family !== null) { 276 if ($family->husband() !== null) { 277 $ancestors[$sosa * 2] = $family->husband(); 278 } 279 if ($family->wife() !== null) { 280 $ancestors[$sosa * 2 + 1] = $family->wife(); 281 } 282 } 283 } while (next($ancestors)); 284 285 return $ancestors; 286 } 287 288 /** 289 * Fetch all individuals with a matching surname 290 * 291 * @param Tree $tree 292 * @param string $surname 293 * @param bool $soundex_dm 294 * @param bool $soundex_std 295 * 296 * @return array<Individual> 297 */ 298 private function loadIndividuals(Tree $tree, string $surname, bool $soundex_dm, bool $soundex_std): array 299 { 300 $individuals = DB::table('individuals') 301 ->join('name', static function (JoinClause $join): void { 302 $join 303 ->on('name.n_file', '=', 'individuals.i_file') 304 ->on('name.n_id', '=', 'individuals.i_id'); 305 }) 306 ->where('i_file', '=', $tree->id()) 307 ->where('n_type', '<>', '_MARNM') 308 ->where(static function (Builder $query) use ($surname, $soundex_dm, $soundex_std): void { 309 $query 310 ->where('n_surn', '=', $surname) 311 ->orWhere('n_surname', '=', $surname); 312 313 if ($soundex_std) { 314 $sdx = Soundex::russell($surname); 315 if ($sdx !== '') { 316 foreach (explode(':', $sdx) as $value) { 317 $query->orWhere('n_soundex_surn_std', 'LIKE', '%' . $value . '%'); 318 } 319 } 320 } 321 322 if ($soundex_dm) { 323 $sdx = Soundex::daitchMokotoff($surname); 324 if ($sdx !== '') { 325 foreach (explode(':', $sdx) as $value) { 326 $query->orWhere('n_soundex_surn_dm', 'LIKE', '%' . $value . '%'); 327 } 328 } 329 } 330 }) 331 ->distinct() 332 ->select(['individuals.*']) 333 ->get() 334 ->map(Registry::individualFactory()->mapper($tree)) 335 ->filter(GedcomRecord::accessFilter()) 336 ->all(); 337 338 usort($individuals, Individual::birthDateComparator()); 339 340 return $individuals; 341 } 342 343 /** 344 * For each individual with no ancestors, list their descendants. 345 * 346 * @param Tree $tree 347 * @param array<Individual> $individuals 348 * @param array<Individual> $ancestors 349 * @param string $surname 350 * @param bool $soundex_dm 351 * @param bool $soundex_std 352 * 353 * @return string 354 */ 355 private function getPatriarchsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std): string 356 { 357 $html = ''; 358 foreach ($individuals as $individual) { 359 foreach ($individual->childFamilies() as $family) { 360 foreach ($family->spouses() as $parent) { 361 if (in_array($parent, $individuals, true)) { 362 continue 3; 363 } 364 } 365 } 366 $html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $individual, null); 367 } 368 369 return $html; 370 } 371 372 /** 373 * Generate a recursive list of descendants of an individual. 374 * If parents are specified, we can also show the pedigree (adopted, etc.). 375 * 376 * @param Tree $tree 377 * @param array<Individual> $individuals 378 * @param array<Individual> $ancestors 379 * @param string $surname 380 * @param bool $soundex_dm 381 * @param bool $soundex_std 382 * @param Individual $individual 383 * @param Family|null $parents 384 * 385 * @return string 386 */ 387 private function getDescendantsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std, Individual $individual, Family $parents = null): string 388 { 389 $module = $this->module_service->findByComponent(ModuleChartInterface::class, $tree, Auth::user())->first(static function (ModuleInterface $module) { 390 return $module instanceof RelationshipsChartModule; 391 }); 392 393 // A person has many names. Select the one that matches the searched surname 394 $person_name = ''; 395 foreach ($individual->getAllNames() as $name) { 396 [$surn1] = explode(',', $name['sort']); 397 if ($this->surnamesMatch($surn1, $surname, $soundex_std, $soundex_dm)) { 398 $person_name = $name['full']; 399 break; 400 } 401 } 402 403 // No matching name? Typically children with a different surname. The branch stops here. 404 if ($person_name === '') { 405 return '<li title="' . strip_tags($individual->fullName()) . '" class="wt-branch-split"><small>' . view('icons/sex', ['sex' => $individual->sex()]) . '</small>…</li>'; 406 } 407 408 // Is this individual one of our ancestors? 409 $sosa = array_search($individual, $ancestors, true); 410 if (is_int($sosa) && $module instanceof RelationshipsChartModule) { 411 $sosa_class = 'search_hit'; 412 $sosa_html = ' <a class="small wt-chart-box-' . strtolower($individual->sex()) . '" href="' . e($module->chartUrl($individual, ['xref2' => $ancestors[1]->xref()])) . '" rel="nofollow" title="' . I18N::translate('Relationship') . '">' . I18N::number($sosa) . '</a>' . self::sosaGeneration($sosa); 413 } else { 414 $sosa_class = ''; 415 $sosa_html = ''; 416 } 417 418 // Generate HTML for this individual, and all their descendants 419 $indi_html = '<small>' . view('icons/sex', ['sex' => $individual->sex()]) . '</small><a class="' . $sosa_class . '" href="' . e($individual->url()) . '">' . $person_name . '</a> ' . $individual->lifespan() . $sosa_html; 420 421 // If this is not a birth pedigree (e.g. an adoption), highlight it 422 if ($parents) { 423 foreach ($individual->facts(['FAMC']) as $fact) { 424 if ($fact->target() === $parents) { 425 $pedi = $fact->attribute('PEDI'); 426 427 if ($pedi !== '' && $pedi !== PedigreeLinkageType::VALUE_BIRTH) { 428 $pedigree = Registry::elementFactory()->make('INDI:FAMC:PEDI')->value($pedi, $tree); 429 $indi_html = '<span class="red">' . $pedigree . '</span> ' . $indi_html; 430 } 431 break; 432 } 433 } 434 } 435 436 // spouses and children 437 $spouse_families = $individual->spouseFamilies() 438 ->sort(Family::marriageDateComparator()); 439 440 if ($spouse_families->isNotEmpty()) { 441 $fam_html = ''; 442 foreach ($spouse_families as $family) { 443 $fam_html .= $indi_html; // Repeat the individual details for each spouse. 444 445 $spouse = $family->spouse($individual); 446 if ($spouse instanceof Individual) { 447 $sosa = array_search($spouse, $ancestors, true); 448 if (is_int($sosa) && $module instanceof RelationshipsChartModule) { 449 $sosa_class = 'search_hit'; 450 $sosa_html = ' <a class="small wt-chart-box-' . strtolower($spouse->sex()) . '" href="' . e($module->chartUrl($spouse, ['xref2' => $ancestors[1]->xref()])) . '" rel="nofollow" title="' . I18N::translate('Relationship') . '">' . I18N::number($sosa) . '</a>' . self::sosaGeneration($sosa); 451 } else { 452 $sosa_class = ''; 453 $sosa_html = ''; 454 } 455 $marriage_year = $family->getMarriageYear(); 456 if ($marriage_year) { 457 $fam_html .= ' <a href="' . e($family->url()) . '" title="' . strip_tags($family->getMarriageDate()->display()) . '"><i class="icon-rings"></i>' . $marriage_year . '</a>'; 458 } elseif ($family->facts(['MARR'])->isNotEmpty()) { 459 $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Marriage') . '"><i class="icon-rings"></i></a>'; 460 } else { 461 $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Not married') . '"><i class="icon-rings"></i></a>'; 462 } 463 $fam_html .= ' <small>' . view('icons/sex', ['sex' => $spouse->sex()]) . '</small><a class="' . $sosa_class . '" href="' . e($spouse->url()) . '">' . $spouse->fullName() . '</a> ' . $spouse->lifespan() . ' ' . $sosa_html; 464 } 465 466 $fam_html .= '<ol>'; 467 foreach ($family->children() as $child) { 468 $fam_html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $child, $family); 469 } 470 $fam_html .= '</ol>'; 471 } 472 473 return '<li>' . $fam_html . '</li>'; 474 } 475 476 // No spouses - just show the individual 477 return '<li>' . $indi_html . '</li>'; 478 } 479 480 /** 481 * Do two surnames match? 482 * 483 * @param string $surname1 484 * @param string $surname2 485 * @param bool $soundex_std 486 * @param bool $soundex_dm 487 * 488 * @return bool 489 */ 490 private function surnamesMatch(string $surname1, string $surname2, bool $soundex_std, bool $soundex_dm): bool 491 { 492 // One name sounds like another? 493 if ($soundex_std && Soundex::compare(Soundex::russell($surname1), Soundex::russell($surname2))) { 494 return true; 495 } 496 if ($soundex_dm && Soundex::compare(Soundex::daitchMokotoff($surname1), Soundex::daitchMokotoff($surname2))) { 497 return true; 498 } 499 500 // One is a substring of the other. e.g. Halen / Van Halen 501 return stripos($surname1, $surname2) !== false || stripos($surname2, $surname1) !== false; 502 } 503 504 /** 505 * Convert a SOSA number into a generation number. e.g. 8 = great-grandfather = 3 generations 506 * 507 * @param int $sosa 508 * 509 * @return string 510 */ 511 private static function sosaGeneration(int $sosa): string 512 { 513 $generation = (int) log($sosa, 2) + 1; 514 515 return '<sup title="' . I18N::translate('Generation') . '">' . $generation . '</sup>'; 516 } 517} 518