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