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('Given names'), '</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::language()->initialLetter(I18N::language()->normalize(I18N::strtoupper($row->n_givn))); 550 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::language()->normalize(I18N::strtoupper($row->n_surn))); 659 660 $initials[$initial] ??= 0; 661 $initials[$initial] += $row->total; 662 } 663 664 // Move specials to the end 665 $count_none = $initials[''] ?? 0; 666 667 if ($count_none > 0) { 668 unset($initials['']); 669 $initials[','] = $count_none; 670 } 671 672 $count_unknown = $initials['@'] ?? 0; 673 674 if ($count_unknown > 0) { 675 unset($initials['@']); 676 $initials['@'] = $count_unknown; 677 } 678 679 return $initials; 680 } 681 682 /** 683 * Fetch a list of individuals with specified names 684 * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@" 685 * To search for names with no surnames, use $salpha="," 686 * 687 * @param array<string> $surns_to_show if set, only fetch people with this n_surn 688 * @param string $galpha if set, only fetch given names starting with this letter 689 * @param bool $marnm if set, include married names 690 * @param bool $fams if set, only fetch individuals with FAMS records 691 * 692 * @return Collection<int,Individual> 693 */ 694 protected function individuals(Tree $tree, array $surns_to_show, string $galpha, bool $marnm, bool $fams): Collection 695 { 696 $query = DB::table('individuals') 697 ->join('name', static function (JoinClause $join): void { 698 $join 699 ->on('n_id', '=', 'i_id') 700 ->on('n_file', '=', 'i_file'); 701 }) 702 ->where('i_file', '=', $tree->id()) 703 ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'n_givn', 'n_surn']); 704 705 $this->whereFamily($fams, $query); 706 $this->whereMarriedName($marnm, $query); 707 708 if ($surns_to_show === []) { 709 $query->whereNotIn('n_surn', ['', '@N.N.']); 710 } else { 711 $query->whereIn(DB::binaryColumn('n_surn'), $surns_to_show); 712 } 713 714 $individuals = new Collection(); 715 716 foreach ($query->get() as $row) { 717 $individual = Registry::individualFactory()->make($row->xref, $tree, $row->gedcom); 718 assert($individual instanceof Individual); 719 720 // The name from the database may be private - check the filtered list... 721 foreach ($individual->getAllNames() as $n => $name) { 722 if ($name['givn'] === $row->n_givn && $name['surn'] === $row->n_surn) { 723 if ($galpha === '' || I18N::language()->initialLetter(I18N::language()->normalize(I18N::strtoupper($row->n_givn))) === $galpha) { 724 $individual->setPrimaryName($n); 725 // We need to clone $individual, as we may have multiple references to the 726 // same individual in this list, and the "primary name" would otherwise 727 // be shared amongst all of them. 728 $individuals->push(clone $individual); 729 break; 730 } 731 } 732 } 733 } 734 735 return $individuals; 736 } 737 738 /** 739 * Fetch a list of families with specified names 740 * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@" 741 * To search for names with no surnames, use $salpha="," 742 * 743 * @param array<string> $surnames if set, only fetch people with this n_surname 744 * @param string $galpha if set, only fetch given names starting with this letter 745 * @param bool $marnm if set, include married names 746 * 747 * @return Collection<int,Family> 748 */ 749 protected function families(Tree $tree, array $surnames, string $galpha, bool $marnm): Collection 750 { 751 $families = new Collection(); 752 753 foreach ($this->individuals($tree, $surnames, $galpha, $marnm, true) as $indi) { 754 foreach ($indi->spouseFamilies() as $family) { 755 $families->push($family); 756 } 757 } 758 759 return $families->unique(); 760 } 761} 762