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