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