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