Passed
Push — develop ( b53f60...9961ae )
by Greg
14:15
created

IndividualListModule::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2022 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
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Module;
21
22
use Fisharebest\Localization\Locale\LocaleInterface;
23
use Fisharebest\Webtrees\Auth;
24
use Fisharebest\Webtrees\Contracts\UserInterface;
25
use Fisharebest\Webtrees\Family;
26
use Fisharebest\Webtrees\I18N;
27
use Fisharebest\Webtrees\Individual;
28
use Fisharebest\Webtrees\Registry;
29
use Fisharebest\Webtrees\Session;
30
use Fisharebest\Webtrees\Tree;
31
use Fisharebest\Webtrees\Validator;
32
use Illuminate\Database\Capsule\Manager as DB;
33
use Illuminate\Database\Query\Builder;
34
use Illuminate\Database\Query\Expression;
35
use Illuminate\Database\Query\JoinClause;
36
use Illuminate\Support\Collection;
37
use Psr\Http\Message\ResponseInterface;
38
use Psr\Http\Message\ServerRequestInterface;
39
use Psr\Http\Server\RequestHandlerInterface;
40
41
use function app;
42
use function array_keys;
43
use function assert;
44
use function e;
45
use function implode;
46
use function in_array;
47
use function ob_get_clean;
48
use function ob_start;
49
use function route;
50
use function view;
51
52
/**
53
 * Class IndividualListModule
54
 */
55
class IndividualListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface
56
{
57
    use ModuleListTrait;
58
59
    protected const ROUTE_URL = '/tree/{tree}/individual-list';
60
61
    /**
62
     * Initialization.
63
     *
64
     * @return void
65
     */
66
    public function boot(): void
67
    {
68
        Registry::routeFactory()->routeMap()
69
            ->get(static::class, static::ROUTE_URL, $this);
70
    }
71
72
    /**
73
     * How should this module be identified in the control panel, etc.?
74
     *
75
     * @return string
76
     */
77
    public function title(): string
78
    {
79
        /* I18N: Name of a module/list */
80
        return I18N::translate('Individuals');
81
    }
82
83
    /**
84
     * A sentence describing what this module does.
85
     *
86
     * @return string
87
     */
88
    public function description(): string
89
    {
90
        /* I18N: Description of the “Individuals” module */
91
        return I18N::translate('A list of individuals.');
92
    }
93
94
    /**
95
     * CSS class for the URL.
96
     *
97
     * @return string
98
     */
99
    public function listMenuClass(): string
100
    {
101
        return 'menu-list-indi';
102
    }
103
104
    /**
105
     * @param Tree                                      $tree
106
     * @param array<bool|int|string|array<string>|null> $parameters
107
     *
108
     * @return string
109
     */
110
    public function listUrl(Tree $tree, array $parameters = []): string
111
    {
112
        $request = app(ServerRequestInterface::class);
113
        assert($request instanceof ServerRequestInterface);
114
115
        $xref = Validator::attributes($request)->isXref()->string('xref', '');
116
117
        if ($xref !== '') {
118
            $individual = Registry::individualFactory()->make($xref, $tree);
119
120
            if ($individual instanceof Individual && $individual->canShow()) {
121
                $primary_name = $individual->getPrimaryName();
122
123
                $parameters['surname'] = $parameters['surname'] ?? $individual->getAllNames()[$primary_name]['surn'] ?? null;
124
            }
125
        }
126
127
        $parameters['tree'] = $tree->name();
128
129
        return route(static::class, $parameters);
130
    }
131
132
    /**
133
     * @return array<string>
134
     */
135
    public function listUrlAttributes(): array
136
    {
137
        return [];
138
    }
139
140
    /**
141
     * Handle URLs generated by older versions of webtrees
142
     *
143
     * @param ServerRequestInterface $request
144
     *
145
     * @return ResponseInterface
146
     */
147
    public function getListAction(ServerRequestInterface $request): ResponseInterface
148
    {
149
        $tree = Validator::attributes($request)->tree();
150
151
        return Registry::responseFactory()->redirectUrl($this->listUrl($tree, $request->getQueryParams()));
152
    }
153
154
    /**
155
     * @param ServerRequestInterface $request
156
     *
157
     * @return ResponseInterface
158
     */
159
    public function handle(ServerRequestInterface $request): ResponseInterface
160
    {
161
        $tree = Validator::attributes($request)->tree();
162
        $user = Validator::attributes($request)->user();
163
164
        Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user);
165
166
        return $this->createResponse($tree, $user, $request->getQueryParams(), false);
167
    }
168
169
    /**
170
     * @param Tree          $tree
171
     * @param UserInterface $user
172
     * @param array<string> $params
173
     * @param bool          $families
174
     *
175
     * @return ResponseInterface
176
     */
177
    protected function createResponse(Tree $tree, UserInterface $user, array $params, bool $families): ResponseInterface
178
    {
179
        ob_start();
180
181
        // We show three different lists: initials, surnames and individuals
182
183
        // All surnames beginning with this letter where "@"=unknown and ","=none
184
        $alpha = $params['alpha'] ?? '';
185
186
        // All individuals with this surname
187
        $surname = $params['surname'] ?? '';
188
189
        // All individuals
190
        $show_all = $params['show_all'] ?? 'no';
191
192
        // Long lists can be broken down by given name
193
        $show_all_firstnames = $params['show_all_firstnames'] ?? 'no';
194
        if ($show_all_firstnames === 'yes') {
195
            $falpha = '';
196
        } else {
197
            // All first names beginning with this letter
198
            $falpha = $params['falpha'] ?? '';
199
        }
200
201
        $show_marnm = $params['show_marnm'] ?? '';
202
        switch ($show_marnm) {
203
            case 'no':
204
            case 'yes':
205
                $user->setPreference($families ? 'family-list-marnm' : 'individual-list-marnm', $show_marnm);
206
                break;
207
            default:
208
                $show_marnm = $user->getPreference($families ? 'family-list-marnm' : 'individual-list-marnm');
209
        }
210
211
        // Make sure selections are consistent.
212
        // i.e. can’t specify show_all and surname at the same time.
213
        if ($show_all === 'yes') {
214
            $alpha   = '';
215
            $surname = '';
216
217
            if ($show_all_firstnames === 'yes') {
218
                $legend  = I18N::translate('All');
219
                $params  = [
220
                    'tree'     => $tree->name(),
221
                    'show_all' => 'yes',
222
                ];
223
                $show    = 'indi';
224
            } elseif ($falpha !== '') {
225
                $legend  = I18N::translate('All') . ', ' . e($falpha) . '…';
226
                $params  = [
227
                    'tree'     => $tree->name(),
228
                    'show_all' => 'yes',
229
                ];
230
                $show    = 'indi';
231
            } else {
232
                $legend  = I18N::translate('All');
233
                $show    = $params['show'] ?? 'surn';
234
                $params  = [
235
                    'tree'     => $tree->name(),
236
                    'show_all' => 'yes',
237
                ];
238
            }
239
        } elseif ($surname !== '') {
240
            $alpha    = I18N::language()->initialLetter($surname); // so we can highlight the initial letter
241
            $show_all = 'no';
242
            if ($surname === Individual::NOMEN_NESCIO) {
243
                $legend = I18N::translateContext('Unknown surname', '…');
244
            } else {
245
                // The surname parameter is a root/canonical form.
246
                // Display it as the actual surname
247
                $legend = implode('/', array_keys($this->surnames($tree, $surname, $alpha, $show_marnm === 'yes', $families, I18N::locale())));
248
            }
249
            $params = [
250
                'tree'    => $tree->name(),
251
                'surname' => $surname,
252
                'falpha'  => $falpha,
253
            ];
254
            switch ($falpha) {
255
                case '':
256
                    break;
257
                case '@':
258
                    $legend .= ', ' . I18N::translateContext('Unknown given name', '…');
259
                    break;
260
                default:
261
                    $legend .= ', ' . e($falpha) . '…';
262
                    break;
263
            }
264
            $show = 'indi'; // SURN list makes no sense here
265
        } elseif ($alpha === '@') {
266
            $show_all = 'no';
267
            $legend   = I18N::translateContext('Unknown surname', '…');
268
            $params   = [
269
                'alpha' => $alpha,
270
                'tree'  => $tree->name(),
271
            ];
272
            $show     = 'indi'; // SURN list makes no sense here
273
        } elseif ($alpha === ',') {
274
            $show_all = 'no';
275
            $legend   = I18N::translate('No surname');
276
            $params   = [
277
                'alpha' => $alpha,
278
                'tree'  => $tree->name(),
279
            ];
280
            $show     = 'indi'; // SURN list makes no sense here
281
        } elseif ($alpha !== '') {
282
            $show_all = 'no';
283
            $legend   = e($alpha) . '…';
284
            $show     = $params['show'] ?? 'surn';
285
            $params   = [
286
                'alpha' => $alpha,
287
                'tree'  => $tree->name(),
288
            ];
289
        } else {
290
            $show_all = 'no';
291
            $legend   = '…';
292
            $params   = [
293
                'tree' => $tree->name(),
294
            ];
295
            $show     = 'none'; // Don't show lists until something is chosen
296
        }
297
        $legend = '<bdi>' . $legend . '</bdi>';
298
299
        if ($families) {
300
            $title = I18N::translate('Families') . ' — ' . $legend;
301
        } else {
302
            $title = I18N::translate('Individuals') . ' — ' . $legend;
303
        } ?>
304
        <div class="d-flex flex-column wt-page-options wt-page-options-individual-list d-print-none">
305
            <ul class="d-flex flex-wrap list-unstyled justify-content-center wt-initials-list wt-initials-list-surname">
306
307
                <?php foreach ($this->surnameAlpha($tree, $show_marnm === 'yes', $families, I18N::locale()) as $letter => $count) : ?>
308
                    <li class="wt-initials-list-item d-flex">
309
                        <?php if ($count > 0) : ?>
310
                            <a href="<?= e($this->listUrl($tree, ['alpha' => $letter, 'tree' => $tree->name()])) ?>" class="wt-initial px-1<?= $letter === $alpha ? ' active' : '' ?> '" title="<?= I18N::number($count) ?>"><?= $this->surnameInitial((string) $letter) ?></a>
311
                        <?php else : ?>
312
                            <span class="wt-initial px-1 text-muted"><?= $this->surnameInitial((string) $letter) ?></span>
313
314
                        <?php endif ?>
315
                    </li>
316
                <?php endforeach ?>
317
318
                <?php if (Session::has('initiated')) : ?>
319
                    <!-- Search spiders don't get the "show all" option as the other links give them everything. -->
320
                    <li class="wt-initials-list-item d-flex">
321
                        <a class="wt-initial px-1<?= $show_all === 'yes' ? ' active' : '' ?>" href="<?= e($this->listUrl($tree, ['show_all' => 'yes'] + $params)) ?>"><?= I18N::translate('All') ?></a>
322
                    </li>
323
                <?php endif ?>
324
            </ul>
325
326
            <!-- Search spiders don't get an option to show/hide the surname sublists, nor does it make sense on the all/unknown/surname views -->
327
            <?php if ($show !== 'none' && Session::has('initiated')) : ?>
328
                <?php if ($show_marnm === 'yes') : ?>
329
                    <p>
330
                        <a href="<?= e($this->listUrl($tree, ['show' => $show, 'show_marnm' => 'no'] + $params)) ?>">
331
                            <?= I18N::translate('Exclude individuals with “%s” as a married name', $legend) ?>
332
                        </a>
333
                    </p>
334
                <?php else : ?>
335
                    <p>
336
                        <a href="<?= e($this->listUrl($tree, ['show' => $show, 'show_marnm' => 'yes'] + $params)) ?>">
337
                            <?= I18N::translate('Include individuals with “%s” as a married name', $legend) ?>
338
                        </a>
339
                    </p>
340
                <?php endif ?>
341
342
                <?php if ($alpha !== '@' && $alpha !== ',' && $surname === '') : ?>
343
                    <?php if ($show === 'surn') : ?>
344
                        <p>
345
                            <a href="<?= e($this->listUrl($tree, ['show' => 'indi', 'show_marnm' => 'no'] + $params)) ?>">
346
                                <?= I18N::translate('Show the list of individuals') ?>
347
                            </a>
348
                        </p>
349
                    <?php else : ?>
350
                        <p>
351
                            <a href="<?= e($this->listUrl($tree, ['show' => 'surn', 'show_marnm' => 'no'] + $params)) ?>">
352
                                <?= I18N::translate('Show the list of surnames') ?>
353
                            </a>
354
                        </p>
355
                    <?php endif ?>
356
                <?php endif ?>
357
            <?php endif ?>
358
        </div>
359
360
        <div class="wt-page-content">
361
            <?php
362
363
            if ($show === 'indi' || $show === 'surn') {
364
                $surns = $this->surnames($tree, $surname, $alpha, $show_marnm === 'yes', $families, I18N::locale());
365
                if ($show === 'surn') {
366
                    // Show the surname list
367
                    switch ($tree->getPreference('SURNAME_LIST_STYLE')) {
368
                        case 'style1':
369
                            echo view('lists/surnames-column-list', [
370
                                'module'   => $this,
371
                                'surnames' => $surns,
372
                                'totals'   => true,
373
                                'tree'     => $tree,
374
                            ]);
375
                            break;
376
                        case 'style3':
377
                            echo view('lists/surnames-tag-cloud', [
378
                                'module'   => $this,
379
                                'surnames' => $surns,
380
                                'totals'   => true,
381
                                'tree'     => $tree,
382
                            ]);
383
                            break;
384
                        case 'style2':
385
                        default:
386
                            echo view('lists/surnames-table', [
387
                                'families' => $families,
388
                                'module'   => $this,
389
                                'order'    => [[0, 'asc']],
390
                                'surnames' => $surns,
391
                                'tree'     => $tree,
392
                            ]);
393
                            break;
394
                    }
395
                } else {
396
                    // Show the list
397
                    $count = 0;
398
                    foreach ($surns as $surnames) {
399
                        foreach ($surnames as $total) {
400
                            $count += $total;
401
                        }
402
                    }
403
                    // Don't sublist short lists.
404
                    if ($count < $tree->getPreference('SUBLIST_TRIGGER_I')) {
405
                        $falpha = '';
406
                    } else {
407
                        $givn_initials = $this->givenAlpha($tree, $surname, $alpha, $show_marnm === 'yes', $families, I18N::locale());
408
                        // Break long lists by initial letter of given name
409
                        if ($surname !== '' || $show_all === 'yes') {
410
                            if ($show_all === 'no') {
411
                                echo '<h2 class="wt-page-title">', I18N::translate('Individuals with surname %s', $legend), '</h2>';
412
                            }
413
                            // Don't show the list until we have some filter criteria
414
                            $show = $falpha !== '' || $show_all_firstnames === 'yes' ? 'indi' : 'none';
415
                            $list = [];
416
                            echo '<ul class="d-flex flex-wrap list-unstyled justify-content-center wt-initials-list wt-initials-list-given-names">';
417
                            foreach ($givn_initials as $givn_initial => $given_count) {
418
                                echo '<li class="wt-initials-list-item d-flex">';
419
                                if ($given_count > 0) {
420
                                    if ($show === 'indi' && $givn_initial === $falpha && $show_all_firstnames === 'no') {
421
                                        echo '<a class="wt-initial px-1 active" href="' . e($this->listUrl($tree, ['falpha' => $givn_initial] + $params)) . '" title="' . I18N::number($given_count) . '">' . $this->givenNameInitial((string) $givn_initial) . '</a>';
422
                                    } else {
423
                                        echo '<a class="wt-initial px-1" href="' . e($this->listUrl($tree, ['falpha' => $givn_initial] + $params)) . '" title="' . I18N::number($given_count) . '">' . $this->givenNameInitial((string) $givn_initial) . '</a>';
424
                                    }
425
                                } else {
426
                                    echo '<span class="wt-initial px-1 text-muted">' . $this->givenNameInitial((string) $givn_initial) . '</span>';
427
                                }
428
                                echo '</li>';
429
                            }
430
                            // Search spiders don't get the "show all" option as the other links give them everything.
431
                            if (Session::has('initiated')) {
432
                                echo '<li class="wt-initials-list-item d-flex">';
433
                                if ($show_all_firstnames === 'yes') {
434
                                    echo '<span class="wt-initial px-1 active">' . I18N::translate('All') . '</span>';
435
                                } else {
436
                                    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>';
437
                                }
438
                                echo '</li>';
439
                            }
440
                            echo '</ul>';
441
                            echo '<p class="text-center alpha_index">', implode(' | ', $list), '</p>';
442
                        }
443
                    }
444
                    if ($show === 'indi') {
445
                        if (!$families) {
446
                            echo view('lists/individuals-table', [
447
                                'individuals' => $this->individuals($tree, $surname, $alpha, $falpha, $show_marnm === 'yes', false, I18N::locale()),
448
                                'sosa'        => false,
449
                                'tree'        => $tree,
450
                            ]);
451
                        } else {
452
                            echo view('lists/families-table', [
453
                                'families' => $this->families($tree, $surname, $alpha, $falpha, $show_marnm === 'yes', I18N::locale()),
454
                                'tree'     => $tree,
455
                            ]);
456
                        }
457
                    }
458
                }
459
            } ?>
460
        </div>
461
        <?php
462
463
        $html = ob_get_clean();
464
465
        return $this->viewResponse('modules/individual-list/page', [
466
            'content' => $html,
467
            'title'   => $title,
468
            'tree'    => $tree,
469
        ]);
470
    }
471
472
    /**
473
     * Some initial letters have a special meaning
474
     *
475
     * @param string $initial
476
     *
477
     * @return string
478
     */
479
    protected function givenNameInitial(string $initial): string
480
    {
481
        if ($initial === '@') {
482
            return I18N::translateContext('Unknown given name', '…');
483
        }
484
485
        return e($initial);
486
    }
487
488
    /**
489
     * Some initial letters have a special meaning
490
     *
491
     * @param string $initial
492
     *
493
     * @return string
494
     */
495
    protected function surnameInitial(string $initial): string
496
    {
497
        if ($initial === '@') {
498
            return I18N::translateContext('Unknown surname', '…');
499
        }
500
501
        if ($initial === ',') {
502
            return I18N::translate('No surname');
503
        }
504
505
        return e($initial);
506
    }
507
508
    /**
509
     * Restrict a query to individuals that are a spouse in a family record.
510
     *
511
     * @param bool    $fams
512
     * @param Builder $query
513
     */
514
    protected function whereFamily(bool $fams, Builder $query): void
515
    {
516
        if ($fams) {
517
            $query->join('link', static function (JoinClause $join): void {
518
                $join
519
                    ->on('l_from', '=', 'n_id')
520
                    ->on('l_file', '=', 'n_file')
521
                    ->where('l_type', '=', 'FAMS');
522
            });
523
        }
524
    }
525
526
    /**
527
     * Restrict a query to include/exclude married names.
528
     *
529
     * @param bool    $marnm
530
     * @param Builder $query
531
     */
532
    protected function whereMarriedName(bool $marnm, Builder $query): void
533
    {
534
        if (!$marnm) {
535
            $query->where('n_type', '<>', '_MARNM');
536
        }
537
    }
538
539
    /**
540
     * Get a list of initial surname letters.
541
     *
542
     * @param Tree            $tree
543
     * @param bool            $marnm if set, include married names
544
     * @param bool            $fams  if set, only consider individuals with FAMS records
545
     * @param LocaleInterface $locale
546
     *
547
     * @return array<int>
548
     */
549
    public function surnameAlpha(Tree $tree, bool $marnm, bool $fams, LocaleInterface $locale): array
550
    {
551
        $n_surn = $this->fieldWithCollation('n_surn');
552
        $alphas = [];
553
554
        $query = DB::table('name')->where('n_file', '=', $tree->id());
555
556
        $this->whereFamily($fams, $query);
557
        $this->whereMarriedName($marnm, $query);
558
559
        // Fetch all the letters in our alphabet, whether or not there
560
        // are any names beginning with that letter. It looks better to
561
        // show the full alphabet, rather than omitting rare letters such as X.
562
        foreach (I18N::language()->alphabet() as $letter) {
563
            $query2 = clone $query;
564
565
            $this->whereInitial($query2, 'n_surn', $letter, $locale);
566
567
            $alphas[$letter] = $query2->count();
568
        }
569
570
        // Now fetch initial letters that are not in our alphabet,
571
        // including "@" (for "@N.N.") and "" for no surname.
572
        foreach (I18N::language()->alphabet() as $letter) {
573
            $query->where($n_surn, 'NOT LIKE', $letter . '%');
574
        }
575
576
        $rows = $query
577
            ->groupBy(['initial'])
578
            ->orderBy('initial')
579
            ->pluck(new Expression('COUNT(*) AS aggregate'), new Expression('SUBSTR(n_surn, 1, 1) AS initial'));
580
581
        $specials = ['@', ''];
582
583
        foreach ($rows as $alpha => $count) {
584
            if (!in_array($alpha, $specials, true)) {
585
                $alphas[$alpha] = (int) $count;
586
            }
587
        }
588
589
        // Empty surnames have a special code ',' - as we search for SURN,GIVN
590
        foreach ($specials as $special) {
591
            if ($rows->has($special)) {
592
                $alphas[$special ?: ','] = (int) $rows[$special];
593
            }
594
        }
595
596
        return $alphas;
597
    }
598
599
    /**
600
     * Get a list of initial given name letters for indilist.php and famlist.php
601
     *
602
     * @param Tree            $tree
603
     * @param string          $surn   if set, only consider people with this surname
604
     * @param string          $salpha if set, only consider surnames starting with this letter
605
     * @param bool            $marnm  if set, include married names
606
     * @param bool            $fams   if set, only consider individuals with FAMS records
607
     * @param LocaleInterface $locale
608
     *
609
     * @return array<int>
610
     */
611
    public function givenAlpha(Tree $tree, string $surn, string $salpha, bool $marnm, bool $fams, LocaleInterface $locale): array
612
    {
613
        $alphas = [];
614
615
        $query = DB::table('name')
616
            ->where('n_file', '=', $tree->id());
617
618
        $this->whereFamily($fams, $query);
619
        $this->whereMarriedName($marnm, $query);
620
621
        if ($surn !== '') {
622
            $n_surn = $this->fieldWithCollation('n_surn');
623
            $query->where($n_surn, '=', $surn);
624
        } elseif ($salpha === ',') {
625
            $query->where('n_surn', '=', '');
626
        } elseif ($salpha === '@') {
627
            $query->where('n_surn', '=', Individual::NOMEN_NESCIO);
628
        } elseif ($salpha !== '') {
629
            $this->whereInitial($query, 'n_surn', $salpha, $locale);
630
        } else {
631
            // All surnames
632
            $query->whereNotIn('n_surn', ['', Individual::NOMEN_NESCIO]);
633
        }
634
635
        // Fetch all the letters in our alphabet, whether or not there
636
        // are any names beginning with that letter. It looks better to
637
        // show the full alphabet, rather than omitting rare letters such as X
638
        foreach (I18N::language()->alphabet() as $letter) {
639
            $query2 = clone $query;
640
641
            $this->whereInitial($query2, 'n_givn', $letter, $locale);
642
643
            $alphas[$letter] = $query2->distinct()->count('n_id');
644
        }
645
646
        $rows = $query
647
            ->groupBy(['initial'])
648
            ->orderBy('initial')
649
            ->pluck(new Expression('COUNT(*) AS aggregate'), new Expression('UPPER(SUBSTR(n_givn, 1, 1)) AS initial'));
650
651
        foreach ($rows as $alpha => $count) {
652
            if ($alpha !== '@') {
653
                $alphas[$alpha] = (int) $count;
654
            }
655
        }
656
657
        if ($rows->has('@')) {
658
            $alphas['@'] = (int) $rows['@'];
659
        }
660
661
        return $alphas;
662
    }
663
664
    /**
665
     * Get a count of actual surnames and variants, based on a "root" surname.
666
     *
667
     * @param Tree            $tree
668
     * @param string          $surn   if set, only count people with this surname
669
     * @param string          $salpha if set, only consider surnames starting with this letter
670
     * @param bool            $marnm  if set, include married names
671
     * @param bool            $fams   if set, only consider individuals with FAMS records
672
     * @param LocaleInterface $locale
673
     *
674
     * @return array<array<int>>
675
     */
676
    protected function surnames(
677
        Tree $tree,
678
        string $surn,
679
        string $salpha,
680
        bool $marnm,
681
        bool $fams,
682
        LocaleInterface $locale
683
    ): array {
684
        $query = DB::table('name')
685
            ->where('n_file', '=', $tree->id())
686
            ->select([
687
                new Expression('n_surn /*! COLLATE utf8_bin */ AS n_surn'),
688
                new Expression('n_surname /*! COLLATE utf8_bin */ AS n_surname'),
689
                new Expression('COUNT(*) AS total'),
690
            ]);
691
692
        $this->whereFamily($fams, $query);
693
        $this->whereMarriedName($marnm, $query);
694
695
        if ($surn !== '') {
696
            $query->where('n_surn', '=', $surn);
697
        } elseif ($salpha === ',') {
698
            $query->where('n_surn', '=', '');
699
        } elseif ($salpha === '@') {
700
            $query->where('n_surn', '=', Individual::NOMEN_NESCIO);
701
        } elseif ($salpha !== '') {
702
            $this->whereInitial($query, 'n_surn', $salpha, $locale);
703
        } else {
704
            // All surnames
705
            $query->whereNotIn('n_surn', ['', Individual::NOMEN_NESCIO]);
706
        }
707
        $query->groupBy([
708
            new Expression('n_surn /*! COLLATE utf8_bin */'),
709
            new Expression('n_surname /*! COLLATE utf8_bin */'),
710
        ]);
711
712
        $list = [];
713
714
        foreach ($query->get() as $row) {
715
            $row->n_surn = strtr(I18N::strtoupper($row->n_surn), I18N::language()->equivalentLetters());
716
            $row->total += $list[$row->n_surn][$row->n_surname] ?? 0;
717
718
            $list[$row->n_surn][$row->n_surname] = (int) $row->total;
719
        }
720
721
        uksort($list, I18N::comparator());
722
723
        return $list;
724
    }
725
726
    /**
727
     * Fetch a list of individuals with specified names
728
     * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@"
729
     * To search for names with no surnames, use $salpha=","
730
     *
731
     * @param Tree            $tree
732
     * @param string          $surn   if set, only fetch people with this surname
733
     * @param string          $salpha if set, only fetch surnames starting with this letter
734
     * @param string          $galpha if set, only fetch given names starting with this letter
735
     * @param bool            $marnm  if set, include married names
736
     * @param bool            $fams   if set, only fetch individuals with FAMS records
737
     * @param LocaleInterface $locale
738
     *
739
     * @return Collection<Individual>
740
     */
741
    protected function individuals(
742
        Tree $tree,
743
        string $surn,
744
        string $salpha,
745
        string $galpha,
746
        bool $marnm,
747
        bool $fams,
748
        LocaleInterface $locale
749
    ): Collection {
750
        // Use specific collation for name fields.
751
        $n_givn = $this->fieldWithCollation('n_givn');
752
        $n_surn = $this->fieldWithCollation('n_surn');
753
754
        $query = DB::table('individuals')
755
            ->join('name', static function (JoinClause $join): void {
756
                $join
757
                    ->on('n_id', '=', 'i_id')
758
                    ->on('n_file', '=', 'i_file');
759
            })
760
            ->where('i_file', '=', $tree->id())
761
            ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'n_givn', 'n_surn']);
762
763
        $this->whereFamily($fams, $query);
764
        $this->whereMarriedName($marnm, $query);
765
766
        if ($surn) {
767
            $query->where($n_surn, '=', $surn);
768
        } elseif ($salpha === ',') {
769
            $query->where($n_surn, '=', '');
770
        } elseif ($salpha === '@') {
771
            $query->where($n_surn, '=', Individual::NOMEN_NESCIO);
772
        } elseif ($salpha) {
773
            $this->whereInitial($query, 'n_surn', $salpha, $locale);
774
        } else {
775
            // All surnames
776
            $query->whereNotIn($n_surn, ['', Individual::NOMEN_NESCIO]);
777
        }
778
        if ($galpha) {
779
            $this->whereInitial($query, 'n_givn', $galpha, $locale);
780
        }
781
782
        $query
783
            ->orderBy(new Expression("CASE n_surn WHEN '" . Individual::NOMEN_NESCIO . "' THEN 1 ELSE 0 END"))
784
            ->orderBy($n_surn)
785
            ->orderBy(new Expression("CASE n_givn WHEN '" . Individual::NOMEN_NESCIO . "' THEN 1 ELSE 0 END"))
786
            ->orderBy($n_givn);
787
788
        $individuals = new Collection();
789
        $rows = $query->get();
790
791
        foreach ($rows as $row) {
792
            $individual = Registry::individualFactory()->make($row->xref, $tree, $row->gedcom);
793
            assert($individual instanceof Individual);
794
795
            // The name from the database may be private - check the filtered list...
796
            foreach ($individual->getAllNames() as $n => $name) {
797
                if ($name['givn'] === $row->n_givn && $name['surn'] === $row->n_surn) {
798
                    $individual->setPrimaryName($n);
799
                    // We need to clone $individual, as we may have multiple references to the
800
                    // same individual in this list, and the "primary name" would otherwise
801
                    // be shared amongst all of them.
802
                    $individuals->push(clone $individual);
803
                    break;
804
                }
805
            }
806
        }
807
808
        return $individuals;
809
    }
810
811
    /**
812
     * Fetch a list of families with specified names
813
     * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@"
814
     * To search for names with no surnames, use $salpha=","
815
     *
816
     * @param Tree            $tree
817
     * @param string          $surn   if set, only fetch people with this surname
818
     * @param string          $salpha if set, only fetch surnames starting with this letter
819
     * @param string          $galpha if set, only fetch given names starting with this letter
820
     * @param bool            $marnm  if set, include married names
821
     * @param LocaleInterface $locale
822
     *
823
     * @return Collection<Family>
824
     */
825
    protected function families(Tree $tree, string $surn, string $salpha, string $galpha, bool $marnm, LocaleInterface $locale): Collection
826
    {
827
        $families = new Collection();
828
829
        foreach ($this->individuals($tree, $surn, $salpha, $galpha, $marnm, true, $locale) as $indi) {
830
            foreach ($indi->spouseFamilies() as $family) {
831
                $families->push($family);
832
            }
833
        }
834
835
        return $families->unique();
836
    }
837
838
    /**
839
     * Use MySQL-specific comments so we can run these queries on other RDBMS.
840
     *
841
     * @param string $field
842
     *
843
     * @return Expression
844
     */
845
    protected function fieldWithCollation(string $field): Expression
846
    {
847
        return new Expression($field . ' /*! COLLATE ' . I18N::collation() . ' */');
848
    }
849
850
    /**
851
     * Modify a query to restrict a field to a given initial letter.
852
     * Take account of digraphs, equialent letters, etc.
853
     *
854
     * @param Builder         $query
855
     * @param string          $field
856
     * @param string          $letter
857
     * @param LocaleInterface $locale
858
     *
859
     * @return void
860
     */
861
    protected function whereInitial(
862
        Builder $query,
863
        string $field,
864
        string $letter,
865
        LocaleInterface $locale
866
    ): void {
867
        // Use MySQL-specific comments so we can run these queries on other RDBMS.
868
        $field_with_collation = $this->fieldWithCollation($field);
869
870
        switch ($locale->languageTag()) {
871
            case 'cs':
872
                $this->whereInitialCzech($query, $field_with_collation, $letter);
873
                break;
874
875
            case 'da':
876
            case 'nb':
877
            case 'nn':
878
                $this->whereInitialNorwegian($query, $field_with_collation, $letter);
879
                break;
880
881
            case 'sv':
882
            case 'fi':
883
                $this->whereInitialSwedish($query, $field_with_collation, $letter);
884
                break;
885
886
            case 'hu':
887
                $this->whereInitialHungarian($query, $field_with_collation, $letter);
888
                break;
889
890
            case 'nl':
891
                $this->whereInitialDutch($query, $field_with_collation, $letter);
892
                break;
893
894
            default:
895
                $query->where($field_with_collation, 'LIKE', '\\' . $letter . '%');
896
        }
897
    }
898
899
    /**
900
     * @param Builder    $query
901
     * @param Expression $field
902
     * @param string     $letter
903
     */
904
    protected function whereInitialCzech(Builder $query, Expression $field, string $letter): void
905
    {
906
        if ($letter === 'C') {
907
            $query->where($field, 'LIKE', 'C%')->where($field, 'NOT LIKE', 'CH%');
908
        } else {
909
            $query->where($field, 'LIKE', '\\' . $letter . '%');
910
        }
911
    }
912
913
    /**
914
     * @param Builder    $query
915
     * @param Expression $field
916
     * @param string     $letter
917
     */
918
    protected function whereInitialDutch(Builder $query, Expression $field, string $letter): void
919
    {
920
        if ($letter === 'I') {
921
            $query->where($field, 'LIKE', 'I%')->where($field, 'NOT LIKE', 'IJ%');
922
        } else {
923
            $query->where($field, 'LIKE', '\\' . $letter . '%');
924
        }
925
    }
926
927
    /**
928
     * Hungarian has many digraphs and trigraphs, so exclude these from prefixes.
929
     *
930
     * @param Builder    $query
931
     * @param Expression $field
932
     * @param string     $letter
933
     */
934
    protected function whereInitialHungarian(Builder $query, Expression $field, string $letter): void
935
    {
936
        switch ($letter) {
937
            case 'C':
938
                $query->where($field, 'LIKE', 'C%')->where($field, 'NOT LIKE', 'CS%');
939
                break;
940
941
            case 'D':
942
                $query->where($field, 'LIKE', 'D%')->where($field, 'NOT LIKE', 'DZ%');
943
                break;
944
945
            case 'DZ':
946
                $query->where($field, 'LIKE', 'DZ%')->where($field, 'NOT LIKE', 'DZS%');
947
                break;
948
949
            case 'G':
950
                $query->where($field, 'LIKE', 'G%')->where($field, 'NOT LIKE', 'GY%');
951
                break;
952
953
            case 'L':
954
                $query->where($field, 'LIKE', 'L%')->where($field, 'NOT LIKE', 'LY%');
955
                break;
956
957
            case 'N':
958
                $query->where($field, 'LIKE', 'N%')->where($field, 'NOT LIKE', 'NY%');
959
                break;
960
961
            case 'S':
962
                $query->where($field, 'LIKE', 'S%')->where($field, 'NOT LIKE', 'SZ%');
963
                break;
964
965
            case 'T':
966
                $query->where($field, 'LIKE', 'T%')->where($field, 'NOT LIKE', 'TY%');
967
                break;
968
969
            case 'Z':
970
                $query->where($field, 'LIKE', 'Z%')->where($field, 'NOT LIKE', 'ZS%');
971
                break;
972
973
            default:
974
                $query->where($field, 'LIKE', '\\' . $letter . '%');
975
                break;
976
        }
977
    }
978
979
    /**
980
     * In Norwegian and Danish, AA gets listed under Å, NOT A
981
     *
982
     * @param Builder    $query
983
     * @param Expression $field
984
     * @param string     $letter
985
     */
986
    protected function whereInitialNorwegian(Builder $query, Expression $field, string $letter): void
987
    {
988
        switch ($letter) {
989
            case 'A':
990
                $query->where($field, 'LIKE', 'A%')->where($field, 'NOT LIKE', 'AA%');
991
                break;
992
993
            case 'Å':
994
                $query->where(static function (Builder $query) use ($field): void {
995
                    $query
996
                        ->where($field, 'LIKE', 'Å%')
997
                        ->orWhere($field, 'LIKE', 'AA%');
998
                });
999
                break;
1000
1001
            default:
1002
                $query->where($field, 'LIKE', '\\' . $letter . '%');
1003
                break;
1004
        }
1005
    }
1006
1007
    /**
1008
     * In Swedish and Finnish, AA gets listed under A, NOT Å (even though Swedish collation says they should).
1009
     *
1010
     * @param Builder    $query
1011
     * @param Expression $field
1012
     * @param string     $letter
1013
     */
1014
    protected function whereInitialSwedish(Builder $query, Expression $field, string $letter): void
1015
    {
1016
        if ($letter === 'Å') {
1017
            $query->where($field, 'LIKE', 'Å%')->where($field, 'NOT LIKE', 'AA%');
1018
        } else {
1019
            $query->where($field, 'LIKE', '\\' . $letter . '%');
1020
        }
1021
    }
1022
}
1023