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 if ($show === 'indi' || $show === 'surn') { 312 switch ($alpha) { 313 case '@': 314 $filter = static fn (string $x): bool => $x === Individual::NOMEN_NESCIO; 315 break; 316 case ',': 317 $filter = static fn (string $x): bool => $x === ''; 318 break; 319 case '': 320 if ($show_all === 'yes') { 321 $filter = static fn (string $x): bool => $x !== '' && $x !== Individual::NOMEN_NESCIO; 322 } else { 323 $filter = static fn (string $x): bool => $x === $surname; 324 } 325 break; 326 default: 327 if ($surname === '') { 328 $filter = static fn (string $x): bool => I18N::language()->initialLetter($x) === $alpha; 329 } else { 330 $filter = static fn (string $x): bool => $x === $surname; 331 } 332 break; 333 } 334 335 $all_surnames = array_filter($all_surnames, $filter, ARRAY_FILTER_USE_KEY); 336 337 if ($show === 'surn') { 338 // Show the surname list 339 switch ($tree->getPreference('SURNAME_LIST_STYLE')) { 340 case 'style1': 341 echo view('lists/surnames-column-list', [ 342 'module' => $this, 343 'params' => ['show' => 'indi', 'show_all' => null] + $params, 344 'surnames' => $all_surnames, 345 'totals' => true, 346 'tree' => $tree, 347 ]); 348 break; 349 case 'style3': 350 echo view('lists/surnames-tag-cloud', [ 351 'module' => $this, 352 'params' => ['show' => 'indi', 'show_all' => null] + $params, 353 'surnames' => $all_surnames, 354 'totals' => true, 355 'tree' => $tree, 356 ]); 357 break; 358 case 'style2': 359 default: 360 echo view('lists/surnames-table', [ 361 'families' => $this->showFamilies(), 362 'module' => $this, 363 'order' => [[0, 'asc']], 364 'params' => ['show' => 'indi', 'show_all' => null] + $params, 365 'surnames' => $all_surnames, 366 'tree' => $tree, 367 ]); 368 break; 369 } 370 } else { 371 // Show the list 372 $count = array_sum(array_map(static fn (array $x): int => array_sum($x), $all_surnames)); 373 374 // Don't sublist short lists. 375 $sublist_threshold = (int) $tree->getPreference('SUBLIST_TRIGGER_I'); 376 if ($sublist_threshold === 0 || $count < $sublist_threshold) { 377 $falpha = ''; 378 } else { 379 // Break long lists by initial letter of given name 380 $all_surnames = array_values(array_map(static fn ($x): array => array_keys($x), $all_surnames)); 381 $all_surnames = array_merge(...$all_surnames); 382 $givn_initials = $this->givenNameInitials($tree, $all_surnames, $show_marnm === 'yes', $this->showFamilies()); 383 384 if ($surname !== '' || $show_all === 'yes') { 385 if ($show_all !== 'yes') { 386 echo '<h2 class="wt-page-title">', I18N::translate('Individuals with surname %s', $legend), '</h2>'; 387 } 388 // Don't show the list until we have some filter criteria 389 $show = $falpha !== '' || $show_all_firstnames === 'yes' ? 'indi' : 'none'; 390 echo '<ul class="d-flex flex-wrap list-unstyled justify-content-center wt-initials-list wt-initials-list-given-names">'; 391 foreach ($givn_initials as $givn_initial => $given_count) { 392 echo '<li class="wt-initials-list-item d-flex">'; 393 if ($given_count > 0) { 394 if ($show === 'indi' && $givn_initial === $falpha && $show_all_firstnames !== 'yes') { 395 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>'; 396 } else { 397 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>'; 398 } 399 } else { 400 echo '<span class="wt-initial px-1 text-muted">' . $this->displayGivenNameInitial((string) $givn_initial) . '</span>'; 401 } 402 echo '</li>'; 403 } 404 // Search spiders don't get the "show all" option as the other links give them everything. 405 if (Session::has('initiated')) { 406 echo '<li class="wt-initials-list-item d-flex">'; 407 if ($show_all_firstnames === 'yes') { 408 echo '<span class="wt-initial px-1 active">' . I18N::translate('All') . '</span>'; 409 } else { 410 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>'; 411 } 412 echo '</li>'; 413 } 414 echo '</ul>'; 415 } 416 } 417 if ($show === 'indi') { 418 if ($alpha === '@') { 419 $surns_to_show = ['@N.N.']; 420 } elseif ($alpha === ',') { 421 $surns_to_show = ['']; 422 } elseif ($surname !== '') { 423 $surns_to_show = $all_surns[$surname]; 424 } elseif ($alpha !== '') { 425 $tmp = array_filter( 426 $all_surns, 427 static fn (string $x): bool => I18N::language()->initialLetter($x) === $alpha, 428 ARRAY_FILTER_USE_KEY 429 ); 430 431 $surns_to_show = array_merge(...array_values($tmp)); 432 } else { 433 $surns_to_show = []; 434 } 435 436 if ($this->showFamilies()) { 437 echo view('lists/families-table', [ 438 'families' => $this->families($tree, $surns_to_show, $falpha, $show_marnm === 'yes'), 439 'tree' => $tree, 440 ]); 441 } else { 442 echo view('lists/individuals-table', [ 443 'individuals' => $this->individuals($tree, $surns_to_show, $falpha, $show_marnm === 'yes', false), 444 'sosa' => false, 445 'tree' => $tree, 446 ]); 447 } 448 } 449 } 450 } ?> 451 </div> 452 <?php 453 454 $html = ob_get_clean(); 455 456 return $this->viewResponse('modules/individual-list/page', [ 457 'content' => $html, 458 'title' => $title, 459 'tree' => $tree, 460 ]); 461 } 462 463 /** 464 * Some initial letters have a special meaning 465 */ 466 protected function displayGivenNameInitial(string $initial): string 467 { 468 if ($initial === '@') { 469 return I18N::translateContext('Unknown given name', '…'); 470 } 471 472 return e($initial); 473 } 474 475 /** 476 * Some initial letters have a special meaning 477 */ 478 protected function displaySurnameInitial(string $initial): string 479 { 480 if ($initial === '@') { 481 return I18N::translateContext('Unknown surname', '…'); 482 } 483 484 if ($initial === ',') { 485 return I18N::translate('No surname'); 486 } 487 488 return e($initial); 489 } 490 491 /** 492 * Restrict a query to individuals that are a spouse in a family record. 493 */ 494 protected function whereFamily(bool $fams, Builder $query): void 495 { 496 if ($fams) { 497 $query->join('link', static function (JoinClause $join): void { 498 $join 499 ->on('l_from', '=', 'n_id') 500 ->on('l_file', '=', 'n_file') 501 ->where('l_type', '=', 'FAMS'); 502 }); 503 } 504 } 505 506 /** 507 * Restrict a query to include/exclude married names. 508 */ 509 protected function whereMarriedName(bool $marnm, Builder $query): void 510 { 511 if (!$marnm) { 512 $query->where('n_type', '<>', '_MARNM'); 513 } 514 } 515 516 /** 517 * Get a count of individuals with each initial letter 518 * 519 * @param array<string> $surns if set, only consider people with this surname 520 * @param bool $marnm if set, include married names 521 * @param bool $fams if set, only consider individuals with FAMS records 522 * 523 * @return array<int> 524 */ 525 public function givenNameInitials(Tree $tree, array $surns, bool $marnm, bool $fams): array 526 { 527 $initials = []; 528 529 // Ensure our own language comes before others. 530 foreach (I18N::language()->alphabet() as $initial) { 531 $initials[$initial] = 0; 532 } 533 534 $query = DB::table('name') 535 ->where('n_file', '=', $tree->id()); 536 537 $this->whereFamily($fams, $query); 538 $this->whereMarriedName($marnm, $query); 539 540 if ($surns !== []) { 541 $query->whereIn('n_surn', $surns); 542 } 543 544 $query 545 ->select([DB::binaryColumn('n_givn', 'n_givn'), new Expression('COUNT(*) AS count')]) 546 ->groupBy([DB::binaryColumn('n_givn')]); 547 548 foreach ($query->get() as $row) { 549 $initial = I18N::strtoupper(I18N::language()->initialLetter($row->n_givn)); 550 $initials[$initial] ??= 0; 551 $initials[$initial] += (int) $row->count; 552 } 553 554 $count_unknown = $initials['@'] ?? 0; 555 556 if ($count_unknown > 0) { 557 unset($initials['@']); 558 $initials['@'] = $count_unknown; 559 } 560 561 return $initials; 562 } 563 564 /** 565 * @return array<object{n_surn:string,n_surname:string,total:int}> 566 */ 567 private function surnameData(Tree $tree, bool $marnm, bool $fams): array 568 { 569 $query = DB::table('name') 570 ->where('n_file', '=', $tree->id()) 571 ->whereNotNull('n_surn') // Filters old records for sources, repositories, etc. 572 ->whereNotNull('n_surname') 573 ->select([ 574 DB::binaryColumn('n_surn', 'n_surn'), 575 DB::binaryColumn('n_surname', 'n_surname'), 576 new Expression('COUNT(*) AS total'), 577 ]); 578 579 $this->whereFamily($fams, $query); 580 $this->whereMarriedName($marnm, $query); 581 582 $query->groupBy([ 583 DB::binaryColumn('n_surn'), 584 DB::binaryColumn('n_surname'), 585 ]); 586 587 return $query 588 ->get() 589 ->map(static fn (object $x): object => (object) ['n_surn' => $x->n_surn, 'n_surname' => $x->n_surname, 'total' => (int) $x->total]) 590 ->all(); 591 } 592 593 /** 594 * Group n_surn values, based on collation rules for the current language. 595 * We need them to find the individuals with this n_surn. 596 * 597 * @param array<object{n_surn:string,n_surname:string,total:int}> $surname_data 598 * 599 * @return array<array<int,string>> 600 */ 601 protected function allSurns(array $surname_data): array 602 { 603 $list = []; 604 605 foreach ($surname_data as $row) { 606 $normalized = I18N::strtoupper(I18N::language()->normalize($row->n_surn)); 607 $list[$normalized][] = $row->n_surn; 608 } 609 610 uksort($list, I18N::comparator()); 611 612 return $list; 613 } 614 615 /** 616 * Group n_surname values, based on collation rules for each n_surn. 617 * We need them to show counts of individuals with each surname. 618 * 619 * @param array<object{n_surn:string,n_surname:string,total:int}> $surname_data 620 * 621 * @return array<array<int>> 622 */ 623 protected function allSurnames(array $surname_data): array 624 { 625 $list = []; 626 627 foreach ($surname_data as $row) { 628 $n_surn = $row->n_surn === '' ? $row->n_surname : $row->n_surn; 629 $n_surn = I18N::strtoupper(I18N::language()->normalize($n_surn)); 630 631 $list[$n_surn][$row->n_surname] ??= 0; 632 $list[$n_surn][$row->n_surname] += $row->total; 633 } 634 635 uksort($list, I18N::comparator()); 636 637 return $list; 638 } 639 640 /** 641 * Extract initial letters and counts for all surnames. 642 * 643 * @param array<object{n_surn:string,n_surname:string,total:int}> $surname_data 644 * 645 * @return array<int> 646 */ 647 protected function surnameInitials(array $surname_data): array 648 { 649 $initials = []; 650 651 // Ensure our own language comes before others. 652 foreach (I18N::language()->alphabet() as $initial) { 653 $initials[$initial] = 0; 654 } 655 656 foreach ($surname_data as $row) { 657 $initial = I18N::language()->initialLetter(I18N::language()->normalize(I18N::strtoupper($row->n_surn))); 658 659 $initials[$initial] ??= 0; 660 $initials[$initial] += $row->total; 661 } 662 663 // Move specials to the end 664 $count_none = $initials[''] ?? 0; 665 666 if ($count_none > 0) { 667 unset($initials['']); 668 $initials[','] = $count_none; 669 } 670 671 $count_unknown = $initials['@'] ?? 0; 672 673 if ($count_unknown > 0) { 674 unset($initials['@']); 675 $initials['@'] = $count_unknown; 676 } 677 678 return $initials; 679 } 680 681 /** 682 * Fetch a list of individuals with specified names 683 * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@" 684 * To search for names with no surnames, use $salpha="," 685 * 686 * @param array<string> $surns_to_show if set, only fetch people with this n_surn 687 * @param string $galpha if set, only fetch given names starting with this letter 688 * @param bool $marnm if set, include married names 689 * @param bool $fams if set, only fetch individuals with FAMS records 690 * 691 * @return Collection<int,Individual> 692 */ 693 protected function individuals(Tree $tree, array $surns_to_show, string $galpha, bool $marnm, bool $fams): Collection 694 { 695 $query = DB::table('individuals') 696 ->join('name', static function (JoinClause $join): void { 697 $join 698 ->on('n_id', '=', 'i_id') 699 ->on('n_file', '=', 'i_file'); 700 }) 701 ->where('i_file', '=', $tree->id()) 702 ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'n_givn', 'n_surn']); 703 704 $this->whereFamily($fams, $query); 705 $this->whereMarriedName($marnm, $query); 706 707 if ($surns_to_show === []) { 708 $query->whereNotIn('n_surn', ['', '@N.N.']); 709 } else { 710 $query->whereIn(DB::binaryColumn('n_surn'), $surns_to_show); 711 } 712 713 $individuals = new Collection(); 714 715 foreach ($query->get() as $row) { 716 $individual = Registry::individualFactory()->make($row->xref, $tree, $row->gedcom); 717 assert($individual instanceof Individual); 718 719 // The name from the database may be private - check the filtered list... 720 foreach ($individual->getAllNames() as $n => $name) { 721 if ($name['givn'] === $row->n_givn && $name['surn'] === $row->n_surn) { 722 if ($galpha === '' || I18N::strtoupper(I18N::language()->initialLetter($row->n_givn)) === $galpha) { 723 $individual->setPrimaryName($n); 724 // We need to clone $individual, as we may have multiple references to the 725 // same individual in this list, and the "primary name" would otherwise 726 // be shared amongst all of them. 727 $individuals->push(clone $individual); 728 break; 729 } 730 } 731 } 732 } 733 734 return $individuals; 735 } 736 737 /** 738 * Fetch a list of families with specified names 739 * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@" 740 * To search for names with no surnames, use $salpha="," 741 * 742 * @param array<string> $surnames if set, only fetch people with this n_surname 743 * @param string $galpha if set, only fetch given names starting with this letter 744 * @param bool $marnm if set, include married names 745 * 746 * @return Collection<int,Family> 747 */ 748 protected function families(Tree $tree, array $surnames, string $galpha, bool $marnm): Collection 749 { 750 $families = new Collection(); 751 752 foreach ($this->individuals($tree, $surnames, $galpha, $marnm, true) as $indi) { 753 foreach ($indi->spouseFamilies() as $family) { 754 $families->push($family); 755 } 756 } 757 758 return $families->unique(); 759 } 760} 761