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