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\StatusCodeInterface; 23use Fisharebest\Webtrees\Auth; 24use Fisharebest\Webtrees\DB; 25use Fisharebest\Webtrees\Family; 26use Fisharebest\Webtrees\FlashMessages; 27use Fisharebest\Webtrees\I18N; 28use Fisharebest\Webtrees\Individual; 29use Fisharebest\Webtrees\Registry; 30use Fisharebest\Webtrees\Session; 31use Fisharebest\Webtrees\Tree; 32use Fisharebest\Webtrees\Validator; 33use Illuminate\Database\Query\Builder; 34use Illuminate\Database\Query\Expression; 35use Illuminate\Database\Query\JoinClause; 36use Illuminate\Support\Collection; 37use Psr\Http\Message\ResponseInterface; 38use Psr\Http\Message\ServerRequestInterface; 39use Psr\Http\Server\RequestHandlerInterface; 40 41use function array_filter; 42use function array_key_exists; 43use function array_keys; 44use function array_map; 45use function array_merge; 46use function array_sum; 47use function array_values; 48use function assert; 49use function e; 50use function implode; 51use function ob_get_clean; 52use function ob_start; 53use function route; 54use function uksort; 55use function usort; 56use function view; 57 58use const ARRAY_FILTER_USE_KEY; 59 60/** 61 * Common logic for individual and family lists. 62 */ 63abstract class AbstractIndividualListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface 64{ 65 use ModuleListTrait; 66 67 // The individual list and family list use the same code/logic. 68 // They just display different lists. 69 abstract protected function showFamilies(): bool; 70 71 abstract protected function routeUrl(): string; 72 73 /** 74 * Initialization. 75 */ 76 public function boot(): void 77 { 78 Registry::routeFactory()->routeMap()->get(static::class, $this->routeUrl(), $this); 79 } 80 81 /** 82 * @param array<bool|int|string|array<string>|null> $parameters 83 */ 84 public function listUrl(Tree $tree, array $parameters = []): string 85 { 86 $request = Registry::container()->get(ServerRequestInterface::class); 87 $xref = Validator::attributes($request)->isXref()->string('xref', ''); 88 89 if ($xref !== '') { 90 $individual = Registry::individualFactory()->make($xref, $tree); 91 92 if ($individual instanceof Individual && $individual->canShow()) { 93 $primary_name = $individual->getPrimaryName(); 94 95 $parameters['surname'] ??= $individual->getAllNames()[$primary_name]['surn'] ?? null; 96 } 97 } 98 99 $parameters['tree'] = $tree->name(); 100 101 return route(static::class, $parameters); 102 } 103 104 /** 105 * @return array<string> 106 */ 107 public function listUrlAttributes(): array 108 { 109 return []; 110 } 111 112 public function handle(ServerRequestInterface $request): ResponseInterface 113 { 114 $tree = Validator::attributes($request)->tree(); 115 $user = Validator::attributes($request)->user(); 116 117 Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user); 118 119 // All individuals with this surname 120 $surname_param = Validator::queryParams($request)->string('surname', ''); 121 $surname = I18N::strtoupper(I18N::language()->normalize($surname_param)); 122 123 // All surnames beginning with this letter, where "@" is unknown and "," is none 124 $alpha = Validator::queryParams($request)->string('alpha', ''); 125 126 // All first names beginning with this letter where "@" is unknown 127 $falpha = Validator::queryParams($request)->string('falpha', ''); 128 129 // What type of list to display, if any 130 $show = Validator::queryParams($request)->string('show', 'surn'); 131 132 // All individuals 133 $show_all = Validator::queryParams($request)->string('show_all', ''); 134 135 // Include/exclude married names 136 $show_marnm = Validator::queryParams($request)->string('show_marnm', ''); 137 138 // Break long lists down by given name 139 $show_all_firstnames = Validator::queryParams($request)->string('show_all_firstnames', ''); 140 141 $params = [ 142 'alpha' => $alpha, 143 'falpha' => $falpha, 144 'show' => $show, 145 'show_all' => $show_all, 146 'show_all_firstnames' => $show_all_firstnames, 147 'show_marnm' => $show_marnm, 148 'surname' => $surname, 149 ]; 150 151 if ($surname_param !== $surname) { 152 return Registry::responseFactory() 153 ->redirectUrl($this->listUrl($tree, $params), StatusCodeInterface::STATUS_MOVED_PERMANENTLY); 154 } 155 156 // Make sure parameters are consistent with each other. 157 if ($show_all_firstnames === 'yes') { 158 $falpha = ''; 159 } 160 161 if ($show_all === 'yes') { 162 $alpha = ''; 163 $surname = ''; 164 } 165 166 if ($surname !== '') { 167 $alpha = I18N::language()->initialLetter($surname); 168 } 169 170 $surname_data = $this->surnameData($tree, $show_marnm === 'yes', $this->showFamilies()); 171 $all_surns = $this->allSurns($surname_data); 172 $all_surnames = $this->allSurnames($surname_data); 173 $surname_initials = $this->surnameInitials($surname_data); 174 175 // We've requested a surname that doesn't currently exist. 176 if ($surname !== '' && !array_key_exists($surname, $all_surns)) { 177 $message = I18N::translate('There are no individuals with the surname “%s”', e($surname)); 178 FlashMessages::addMessage($message); 179 180 return Registry::responseFactory() 181 ->redirectUrl($this->listUrl($tree)); 182 } 183 184 // Make sure selections are consistent. 185 // i.e. can’t specify show_all and surname at the same time. 186 if ($show_all === 'yes') { 187 if ($show_all_firstnames === 'yes') { 188 $legend = I18N::translate('All'); 189 $params = ['tree' => $tree->name(), 'show_all' => 'yes', 'show_marnm' => $show_marnm]; 190 $show = 'indi'; 191 } elseif ($falpha !== '') { 192 $legend = I18N::translate('All') . ', ' . e($falpha) . '…'; 193 $params = ['tree' => $tree->name(), 'show_all' => 'yes', 'show_marnm' => $show_marnm]; 194 $show = 'indi'; 195 } else { 196 $legend = I18N::translate('All'); 197 $params = ['tree' => $tree->name(), 'show_all' => 'yes', 'show_marnm' => $show_marnm]; 198 } 199 } elseif ($surname !== '') { 200 $show_all = 'no'; 201 $show = 'indi'; // The surname list makes no sense with only one surname. 202 $params = ['tree' => $tree->name(), 'surname' => $surname, 'falpha' => $falpha, 'show_marnm' => $show_marnm]; 203 204 if ($surname === Individual::NOMEN_NESCIO) { 205 $legend = I18N::translateContext('Unknown surname', '…'); 206 } else { 207 // The surname parameter is a root/canonical form. Display the actual surnames found. 208 $variants = array_keys($all_surnames[$surname] ?? [$surname => $surname]); 209 usort($variants, I18N::comparator()); 210 $variants = array_map(static fn (string $x): string => $x === '' ? I18N::translate('No surname') : $x, $variants); 211 $legend = implode('/', $variants); 212 } 213 214 switch ($falpha) { 215 case '': 216 break; 217 case '@': 218 $legend .= ', ' . I18N::translateContext('Unknown given name', '…'); 219 break; 220 default: 221 $legend .= ', ' . e($falpha) . '…'; 222 break; 223 } 224 } elseif ($alpha === '@') { 225 $show_all = 'no'; 226 $legend = I18N::translateContext('Unknown surname', '…'); 227 $params = ['alpha' => $alpha, 'tree' => $tree->name(), 'show_marnm' => $show_marnm]; 228 $surname = Individual::NOMEN_NESCIO; 229 $show = 'indi'; // SURN list makes no sense here 230 } elseif ($alpha === ',') { 231 $show_all = 'no'; 232 $legend = I18N::translate('No surname'); 233 $params = ['alpha' => $alpha, 'tree' => $tree->name(), 'show_marnm' => $show_marnm]; 234 $show = 'indi'; // SURN list makes no sense here 235 } elseif ($alpha !== '') { 236 $show_all = 'no'; 237 $legend = e($alpha) . '…'; 238 $params = ['alpha' => $alpha, 'tree' => $tree->name(), 'show_marnm' => $show_marnm]; 239 } else { 240 $show_all = 'no'; 241 $legend = '…'; 242 $params = ['tree' => $tree->name(), 'show_marnm' => $show_marnm]; 243 $show = 'none'; // Don't show lists until something is chosen 244 } 245 $legend = '<bdi>' . $legend . '</bdi>'; 246 247 if ($this->showFamilies()) { 248 $title = I18N::translate('Families') . ' — ' . $legend; 249 } else { 250 $title = I18N::translate('Individuals') . ' — ' . $legend; 251 } 252 253 ob_start(); ?> 254 <div class="d-flex flex-column wt-page-options wt-page-options-individual-list d-print-none"> 255 <ul class="d-flex flex-wrap list-unstyled justify-content-center wt-initials-list wt-initials-list-surname"> 256 257 <?php foreach ($surname_initials as $letter => $count) : ?> 258 <li class="wt-initials-list-item d-flex"> 259 <?php if ($count > 0) : ?> 260 <a href="<?= e($this->listUrl($tree, ['alpha' => $letter, 'show_marnm' => $show_marnm, 'tree' => $tree->name()])) ?>" class="wt-initial px-1<?= $letter === $alpha ? ' active' : '' ?> '" title="<?= I18N::number($count) ?>"><?= $this->displaySurnameInitial((string) $letter) ?></a> 261 <?php else : ?> 262 <span class="wt-initial px-1 text-muted"><?= $this->displaySurnameInitial((string) $letter) ?></span> 263 264 <?php endif ?> 265 </li> 266 <?php endforeach ?> 267 268 <?php if (Session::has('initiated')) : ?> 269 <!-- Search spiders don't get the "show all" option as the other links give them everything. --> 270 <li class="wt-initials-list-item d-flex"> 271 <a class="wt-initial px-1<?= $show_all === 'yes' ? ' active' : '' ?>" href="<?= e($this->listUrl($tree, ['show_all' => 'yes'] + $params)) ?>"><?= I18N::translate('All') ?></a> 272 </li> 273 <?php endif ?> 274 </ul> 275 276 <!-- Search spiders don't get an option to show/hide the surname sublists, nor does it make sense on the all/unknown/surname views --> 277 <?php if ($show !== 'none' && Session::has('initiated')) : ?> 278 <?php if ($show_marnm === 'yes') : ?> 279 <p> 280 <a href="<?= e($this->listUrl($tree, ['show' => $show, 'show_marnm' => 'no'] + $params)) ?>"> 281 <?= I18N::translate('Exclude individuals with “%s” as a married name', $legend) ?> 282 </a> 283 </p> 284 <?php else : ?> 285 <p> 286 <a href="<?= e($this->listUrl($tree, ['show' => $show, 'show_marnm' => 'yes'] + $params)) ?>"> 287 <?= I18N::translate('Include individuals with “%s” as a married name', $legend) ?> 288 </a> 289 </p> 290 <?php endif ?> 291 292 <?php if ($alpha !== '@' && $alpha !== ',' && $surname === '') : ?> 293 <?php if ($show === 'surn') : ?> 294 <p> 295 <a href="<?= e($this->listUrl($tree, ['show' => 'indi'] + $params)) ?>"> 296 <?= I18N::translate('Show the list of individuals') ?> 297 </a> 298 </p> 299 <?php else : ?> 300 <p> 301 <a href="<?= e($this->listUrl($tree, ['show' => 'surn'] + $params)) ?>"> 302 <?= I18N::translate('Show the list of surnames') ?> 303 </a> 304 </p> 305 <?php endif ?> 306 <?php endif ?> 307 <?php endif ?> 308 </div> 309 310 <div class="wt-page-content"> 311 <?php 312 if ($show === 'indi' || $show === 'surn') { 313 switch ($alpha) { 314 case '@': 315 $filter = static fn (string $x): bool => $x === Individual::NOMEN_NESCIO; 316 break; 317 case ',': 318 $filter = static fn (string $x): bool => $x === ''; 319 break; 320 case '': 321 if ($show_all === 'yes') { 322 $filter = static fn (string $x): bool => $x !== '' && $x !== Individual::NOMEN_NESCIO; 323 } else { 324 $filter = static fn (string $x): bool => $x === $surname; 325 } 326 break; 327 default: 328 if ($surname === '') { 329 $filter = static fn (string $x): bool => I18N::language()->initialLetter($x) === $alpha; 330 } else { 331 $filter = static fn (string $x): bool => $x === $surname; 332 } 333 break; 334 } 335 336 $all_surnames = array_filter($all_surnames, $filter, ARRAY_FILTER_USE_KEY); 337 338 if ($show === 'surn') { 339 // Show the surname list 340 switch ($tree->getPreference('SURNAME_LIST_STYLE')) { 341 case 'style1': 342 echo view('lists/surnames-column-list', [ 343 'module' => $this, 344 'params' => ['show' => 'indi', 'show_all' => null] + $params, 345 'surnames' => $all_surnames, 346 'totals' => true, 347 'tree' => $tree, 348 ]); 349 break; 350 case 'style3': 351 echo view('lists/surnames-tag-cloud', [ 352 'module' => $this, 353 'params' => ['show' => 'indi', 'show_all' => null] + $params, 354 'surnames' => $all_surnames, 355 'totals' => true, 356 'tree' => $tree, 357 ]); 358 break; 359 case 'style2': 360 default: 361 echo view('lists/surnames-table', [ 362 'families' => $this->showFamilies(), 363 'module' => $this, 364 'order' => [[0, 'asc']], 365 'params' => ['show' => 'indi', 'show_all' => null] + $params, 366 'surnames' => $all_surnames, 367 'tree' => $tree, 368 ]); 369 break; 370 } 371 } else { 372 // Show the list 373 $count = array_sum(array_map(static fn (array $x): int => array_sum($x), $all_surnames)); 374 375 // Don't sublist short lists. 376 $sublist_threshold = (int) $tree->getPreference('SUBLIST_TRIGGER_I'); 377 if ($sublist_threshold === 0 || $count < $sublist_threshold) { 378 $falpha = ''; 379 } else { 380 // Break long lists by initial letter of given name 381 $all_surnames = array_values(array_map(static fn ($x): array => array_keys($x), $all_surnames)); 382 $all_surnames = array_merge(...$all_surnames); 383 $givn_initials = $this->givenNameInitials($tree, $all_surnames, $show_marnm === 'yes', $this->showFamilies()); 384 385 if ($surname !== '' || $show_all === 'yes') { 386 if ($show_all !== 'yes') { 387 echo '<h2 class="wt-page-title">', I18N::translate('Individuals with surname %s', $legend), '</h2>'; 388 } 389 // Don't show the list until we have some filter criteria 390 $show = $falpha !== '' || $show_all_firstnames === 'yes' ? 'indi' : 'none'; 391 echo '<ul class="d-flex flex-wrap list-unstyled justify-content-center wt-initials-list wt-initials-list-given-names">'; 392 foreach ($givn_initials as $givn_initial => $given_count) { 393 echo '<li class="wt-initials-list-item d-flex">'; 394 if ($given_count > 0) { 395 if ($show === 'indi' && $givn_initial === $falpha && $show_all_firstnames !== 'yes') { 396 echo '<a class="wt-initial px-1 active" href="' . e($this->listUrl($tree, ['falpha' => $givn_initial] + $params)) . '" title="' . I18N::number($given_count) . '">' . $this->displayGivenNameInitial((string) $givn_initial) . '</a>'; 397 } else { 398 echo '<a class="wt-initial px-1" href="' . e($this->listUrl($tree, ['falpha' => $givn_initial] + $params)) . '" title="' . I18N::number($given_count) . '">' . $this->displayGivenNameInitial((string) $givn_initial) . '</a>'; 399 } 400 } else { 401 echo '<span class="wt-initial px-1 text-muted">' . $this->displayGivenNameInitial((string) $givn_initial) . '</span>'; 402 } 403 echo '</li>'; 404 } 405 // Search spiders don't get the "show all" option as the other links give them everything. 406 if (Session::has('initiated')) { 407 echo '<li class="wt-initials-list-item d-flex">'; 408 if ($show_all_firstnames === 'yes') { 409 echo '<span class="wt-initial px-1 active">' . I18N::translate('All') . '</span>'; 410 } else { 411 echo '<a class="wt-initial px-1" href="' . e($this->listUrl($tree, ['show_all_firstnames' => 'yes'] + $params)) . '" title="' . I18N::number($count) . '">' . I18N::translate('All') . '</a>'; 412 } 413 echo '</li>'; 414 } 415 echo '</ul>'; 416 } 417 } 418 if ($show === 'indi') { 419 if ($alpha === '@') { 420 $surns_to_show = ['@N.N.']; 421 } elseif ($alpha === ',') { 422 $surns_to_show = ['']; 423 } elseif ($surname !== '') { 424 $surns_to_show = $all_surns[$surname]; 425 } elseif ($alpha !== '') { 426 $tmp = array_filter( 427 $all_surns, 428 static fn (string $x): bool => I18N::language()->initialLetter($x) === $alpha, 429 ARRAY_FILTER_USE_KEY 430 ); 431 432 $surns_to_show = array_merge(...array_values($tmp)); 433 } else { 434 $surns_to_show = []; 435 } 436 437 if ($this->showFamilies()) { 438 echo view('lists/families-table', [ 439 'families' => $this->families($tree, $surns_to_show, $falpha, $show_marnm === 'yes'), 440 'tree' => $tree, 441 ]); 442 } else { 443 echo view('lists/individuals-table', [ 444 'individuals' => $this->individuals($tree, $surns_to_show, $falpha, $show_marnm === 'yes', false), 445 'sosa' => false, 446 'tree' => $tree, 447 ]); 448 } 449 } 450 } 451 } ?> 452 </div> 453 <?php 454 455 $html = ob_get_clean(); 456 457 return $this->viewResponse('modules/individual-list/page', [ 458 'content' => $html, 459 'title' => $title, 460 'tree' => $tree, 461 ]); 462 } 463 464 /** 465 * Some initial letters have a special meaning 466 */ 467 protected function displayGivenNameInitial(string $initial): string 468 { 469 if ($initial === '@') { 470 return I18N::translateContext('Unknown given name', '…'); 471 } 472 473 return e($initial); 474 } 475 476 /** 477 * Some initial letters have a special meaning 478 */ 479 protected function displaySurnameInitial(string $initial): string 480 { 481 if ($initial === '@') { 482 return I18N::translateContext('Unknown surname', '…'); 483 } 484 485 if ($initial === ',') { 486 return I18N::translate('No surname'); 487 } 488 489 return e($initial); 490 } 491 492 /** 493 * Restrict a query to individuals that are a spouse in a family record. 494 */ 495 protected function whereFamily(bool $fams, Builder $query): void 496 { 497 if ($fams) { 498 $query->join('link', static function (JoinClause $join): void { 499 $join 500 ->on('l_from', '=', 'n_id') 501 ->on('l_file', '=', 'n_file') 502 ->where('l_type', '=', 'FAMS'); 503 }); 504 } 505 } 506 507 /** 508 * Restrict a query to include/exclude married names. 509 */ 510 protected function whereMarriedName(bool $marnm, Builder $query): void 511 { 512 if (!$marnm) { 513 $query->where('n_type', '<>', '_MARNM'); 514 } 515 } 516 517 /** 518 * Get a count of individuals with each initial letter 519 * 520 * @param array<string> $surns if set, only consider people with this surname 521 * @param bool $marnm if set, include married names 522 * @param bool $fams if set, only consider individuals with FAMS records 523 * 524 * @return array<int> 525 */ 526 public function givenNameInitials(Tree $tree, array $surns, bool $marnm, bool $fams): array 527 { 528 $initials = []; 529 530 // Ensure our own language comes before others. 531 foreach (I18N::language()->alphabet() as $initial) { 532 $initials[$initial] = 0; 533 } 534 535 $query = DB::table('name') 536 ->where('n_file', '=', $tree->id()); 537 538 $this->whereFamily($fams, $query); 539 $this->whereMarriedName($marnm, $query); 540 541 if ($surns !== []) { 542 $query->whereIn('n_surn', $surns); 543 } 544 545 $query 546 ->select([DB::binaryColumn('n_givn', 'n_givn'), new Expression('COUNT(*) AS count')]) 547 ->groupBy([DB::binaryColumn('n_givn')]); 548 549 foreach ($query->get() as $row) { 550 $initial = I18N::strtoupper(I18N::language()->initialLetter($row->n_givn)); 551 $initials[$initial] ??= 0; 552 $initials[$initial] += (int) $row->count; 553 } 554 555 $count_unknown = $initials['@'] ?? 0; 556 557 if ($count_unknown > 0) { 558 unset($initials['@']); 559 $initials['@'] = $count_unknown; 560 } 561 562 return $initials; 563 } 564 565 /** 566 * @return array<object{n_surn:string,n_surname:string,total:int}> 567 */ 568 private function surnameData(Tree $tree, bool $marnm, bool $fams): array 569 { 570 $query = DB::table('name') 571 ->where('n_file', '=', $tree->id()) 572 ->whereNotNull('n_surn') // Filters old records for sources, repositories, etc. 573 ->whereNotNull('n_surname') 574 ->select([ 575 DB::binaryColumn('n_surn', 'n_surn'), 576 DB::binaryColumn('n_surname', 'n_surname'), 577 new Expression('COUNT(*) AS total'), 578 ]); 579 580 $this->whereFamily($fams, $query); 581 $this->whereMarriedName($marnm, $query); 582 583 $query->groupBy([ 584 DB::binaryColumn('n_surn'), 585 DB::binaryColumn('n_surname'), 586 ]); 587 588 return $query 589 ->get() 590 ->map(static fn (object $x): object => (object) ['n_surn' => $x->n_surn, 'n_surname' => $x->n_surname, 'total' => (int) $x->total]) 591 ->all(); 592 } 593 594 /** 595 * Group n_surn values, based on collation rules for the current language. 596 * We need them to find the individuals with this n_surn. 597 * 598 * @param array<object{n_surn:string,n_surname:string,total:int}> $surname_data 599 * 600 * @return array<array<int,string>> 601 */ 602 protected function allSurns(array $surname_data): array 603 { 604 $list = []; 605 606 foreach ($surname_data as $row) { 607 $normalized = I18N::strtoupper(I18N::language()->normalize($row->n_surn)); 608 $list[$normalized][] = $row->n_surn; 609 } 610 611 uksort($list, I18N::comparator()); 612 613 return $list; 614 } 615 616 /** 617 * Group n_surname values, based on collation rules for each n_surn. 618 * We need them to show counts of individuals with each surname. 619 * 620 * @param array<object{n_surn:string,n_surname:string,total:int}> $surname_data 621 * 622 * @return array<array<int>> 623 */ 624 protected function allSurnames(array $surname_data): array 625 { 626 $list = []; 627 628 foreach ($surname_data as $row) { 629 $n_surn = $row->n_surn === '' ? $row->n_surname : $row->n_surn; 630 $n_surn = I18N::strtoupper(I18N::language()->normalize($n_surn)); 631 632 $list[$n_surn][$row->n_surname] ??= 0; 633 $list[$n_surn][$row->n_surname] += $row->total; 634 } 635 636 uksort($list, I18N::comparator()); 637 638 return $list; 639 } 640 641 /** 642 * Extract initial letters and counts for all surnames. 643 * 644 * @param array<object{n_surn:string,n_surname:string,total:int}> $surname_data 645 * 646 * @return array<int> 647 */ 648 protected function surnameInitials(array $surname_data): array 649 { 650 $initials = []; 651 652 // Ensure our own language comes before others. 653 foreach (I18N::language()->alphabet() as $initial) { 654 $initials[$initial] = 0; 655 } 656 657 foreach ($surname_data as $row) { 658 $initial = I18N::language()->initialLetter(I18N::strtoupper($row->n_surn)); 659 $initial = I18N::language()->normalize($initial); 660 661 $initials[$initial] ??= 0; 662 $initials[$initial] += $row->total; 663 } 664 665 // Move specials to the end 666 $count_none = $initials[''] ?? 0; 667 668 if ($count_none > 0) { 669 unset($initials['']); 670 $initials[','] = $count_none; 671 } 672 673 $count_unknown = $initials['@'] ?? 0; 674 675 if ($count_unknown > 0) { 676 unset($initials['@']); 677 $initials['@'] = $count_unknown; 678 } 679 680 return $initials; 681 } 682 683 /** 684 * Fetch a list of individuals with specified names 685 * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@" 686 * To search for names with no surnames, use $salpha="," 687 * 688 * @param array<string> $surns_to_show if set, only fetch people with this n_surn 689 * @param string $galpha if set, only fetch given names starting with this letter 690 * @param bool $marnm if set, include married names 691 * @param bool $fams if set, only fetch individuals with FAMS records 692 * 693 * @return Collection<int,Individual> 694 */ 695 protected function individuals(Tree $tree, array $surns_to_show, string $galpha, bool $marnm, bool $fams): Collection 696 { 697 $query = DB::table('individuals') 698 ->join('name', static function (JoinClause $join): void { 699 $join 700 ->on('n_id', '=', 'i_id') 701 ->on('n_file', '=', 'i_file'); 702 }) 703 ->where('i_file', '=', $tree->id()) 704 ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'n_givn', 'n_surn']); 705 706 $this->whereFamily($fams, $query); 707 $this->whereMarriedName($marnm, $query); 708 709 if ($surns_to_show === []) { 710 $query->whereNotIn('n_surn', ['', '@N.N.']); 711 } else { 712 $query->whereIn(DB::binaryColumn('n_surn'), $surns_to_show); 713 } 714 715 $individuals = new Collection(); 716 717 foreach ($query->get() as $row) { 718 $individual = Registry::individualFactory()->make($row->xref, $tree, $row->gedcom); 719 assert($individual instanceof Individual); 720 721 // The name from the database may be private - check the filtered list... 722 foreach ($individual->getAllNames() as $n => $name) { 723 if ($name['givn'] === $row->n_givn && $name['surn'] === $row->n_surn) { 724 if ($galpha === '' || I18N::strtoupper(I18N::language()->initialLetter($row->n_givn)) === $galpha) { 725 $individual->setPrimaryName($n); 726 // We need to clone $individual, as we may have multiple references to the 727 // same individual in this list, and the "primary name" would otherwise 728 // be shared amongst all of them. 729 $individuals->push(clone $individual); 730 break; 731 } 732 } 733 } 734 } 735 736 return $individuals; 737 } 738 739 /** 740 * Fetch a list of families with specified names 741 * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@" 742 * To search for names with no surnames, use $salpha="," 743 * 744 * @param array<string> $surnames if set, only fetch people with this n_surname 745 * @param string $galpha if set, only fetch given names starting with this letter 746 * @param bool $marnm if set, include married names 747 * 748 * @return Collection<int,Family> 749 */ 750 protected function families(Tree $tree, array $surnames, string $galpha, bool $marnm): Collection 751 { 752 $families = new Collection(); 753 754 foreach ($this->individuals($tree, $surnames, $galpha, $marnm, true) as $indi) { 755 foreach ($indi->spouseFamilies() as $family) { 756 $families->push($family); 757 } 758 } 759 760 return $families->unique(); 761 } 762} 763