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