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