Issues (2500)

app/StatisticsData.php (8 issues)

1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2025 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;
21
22
use Fisharebest\Webtrees\Contracts\UserInterface;
23
use Fisharebest\Webtrees\Elements\UnknownElement;
24
use Fisharebest\Webtrees\Http\RequestHandlers\MessagePage;
25
use Fisharebest\Webtrees\Module\IndividualListModule;
26
use Fisharebest\Webtrees\Module\ModuleInterface;
27
use Fisharebest\Webtrees\Module\ModuleListInterface;
28
use Fisharebest\Webtrees\Services\MessageService;
29
use Fisharebest\Webtrees\Services\ModuleService;
30
use Fisharebest\Webtrees\Services\UserService;
31
use Illuminate\Database\Query\Builder;
32
use Illuminate\Database\Query\Expression;
33
use Illuminate\Database\Query\JoinClause;
34
use Illuminate\Support\Collection;
35
36
use function abs;
37
use function array_keys;
38
use function array_reverse;
39
use function array_search;
40
use function array_shift;
41
use function array_slice;
42
use function arsort;
43
use function asort;
44
use function count;
45
use function e;
46
use function explode;
47
use function floor;
48
use function implode;
49
use function in_array;
50
use function preg_match;
51
use function preg_quote;
52
use function route;
53
use function str_replace;
54
use function strip_tags;
55
use function uksort;
56
use function view;
57
58
readonly class StatisticsData
59
{
60
    public function __construct(
61
        private Tree $tree,
62
        private UserService $user_service,
63
    ) {
64
    }
65
66
    public function averageChildrenPerFamily(): float
67
    {
68
        return (float) DB::table('families')
69
            ->where('f_file', '=', $this->tree->id())
70
            ->avg('f_numchil');
71
    }
72
73
    public function averageLifespanDays(string $sex): int
74
    {
75
        return (int) $this->birthAndDeathQuery($sex)
76
            ->select([new Expression('AVG(' . DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ') AS days')])
77
            ->value('days');
78
    }
79
80
    /**
81
     * @return Collection<string,int>
82
     */
83
    public function commonGivenNames(string $sex, int $threshold, int $limit): Collection
84
    {
85
        $query = DB::table('name')
86
            ->where('n_file', '=', $this->tree->id())
87
            ->where('n_type', '<>', '_MARNM')
88
            ->where('n_givn', '<>', Individual::PRAENOMEN_NESCIO)
89
            ->where(new Expression('LENGTH(n_givn)'), '>', 1);
90
91
        if ($sex !== 'ALL') {
92
            $query
93
                ->join('individuals', static function (JoinClause $join): void {
94
                    $join
95
                        ->on('i_file', '=', 'n_file')
96
                        ->on('i_id', '=', 'n_id');
97
                })
98
                ->where('i_sex', '=', $sex);
99
        }
100
101
        $rows = $query
102
            ->groupBy(['n_givn'])
103
            ->pluck(new Expression('COUNT(DISTINCT n_id) AS total'), 'n_givn')
104
            ->map(static fn (int|string $count): int => (int) $count);
105
106
107
        $given_names = [];
108
109
        foreach ($rows as $n_givn => $count) {
110
            // Split “John Thomas” into “John” and “Thomas” and count against both totals
111
            foreach (explode(' ', (string) $n_givn) as $given) {
112
                // Exclude initials and particles.
113
                if (preg_match('/^([A-Z]|[a-z]{1,3})$/', $given) !== 1) {
114
                    $given_names[$given] ??= 0;
115
                    $given_names[$given] += (int) $count;
116
                }
117
            }
118
        }
119
120
        return (new Collection($given_names))
121
            ->sortDesc()
122
            ->slice(0, $limit)
123
            ->filter(static fn (int $count): bool => $count >= $threshold);
124
    }
125
126
    /**
127
     * @return array<array<int>>
128
     */
129
    public function commonSurnames(int $limit, int $threshold, string $sort): array
130
    {
131
        // Use the count of base surnames.
132
        $top_surnames = DB::table('name')
133
            ->where('n_file', '=', $this->tree->id())
134
            ->where('n_type', '<>', '_MARNM')
135
            ->whereNotIn('n_surn', ['', Individual::NOMEN_NESCIO])
136
            ->select(['n_surn'])
137
            ->groupBy(['n_surn'])
138
            ->orderByRaw('COUNT(n_surn) DESC')
139
            ->orderBy(new Expression('COUNT(n_surn)'), 'DESC')
140
            ->having(new Expression('COUNT(n_surn)'), '>=', $threshold)
141
            ->take($limit)
142
            ->pluck('n_surn')
143
            ->all();
144
145
        $surnames = [];
146
147
        foreach ($top_surnames as $top_surname) {
148
            $surnames[$top_surname] = DB::table('name')
149
                ->where('n_file', '=', $this->tree->id())
150
                ->where('n_type', '<>', '_MARNM')
151
                ->where('n_surn', '=', $top_surname)
152
                ->select(['n_surn', new Expression('COUNT(n_surn) AS count')])
153
                ->groupBy(['n_surn'])
154
                ->orderBy('n_surn')
155
                ->pluck('count', 'n_surn')
156
                ->map(static fn (string $count): int => (int) $count)
157
                ->all();
158
        }
159
160
        switch ($sort) {
161
            default:
162
            case 'alpha':
163
                uksort($surnames, I18N::comparator());
164
                break;
165
            case 'count':
166
                break;
167
            case 'rcount':
168
                $surnames = array_reverse($surnames, true);
169
                break;
170
        }
171
172
        return $surnames;
173
    }
174
175
    /**
176
     * @param array<string> $events
177
     */
178
    public function countAllEvents(array $events): int
179
    {
180
        return DB::table('dates')
181
            ->where('d_file', '=', $this->tree->id())
182
            ->whereIn('d_fact', $events)
183
            ->count();
184
    }
185
186
    public function countAllPlaces(): int
187
    {
188
        return DB::table('places')
189
            ->where('p_file', '=', $this->tree->id())
190
            ->count();
191
    }
192
193
    public function countAllRecords(): int
194
    {
195
        return
196
            $this->countIndividuals() +
197
            $this->countFamilies() +
198
            $this->countMedia() +
199
            $this->countNotes() +
200
            $this->countRepositories() +
201
            $this->countSources();
202
    }
203
204
    public function countChildren(): int
205
    {
206
        return (int) DB::table('families')
207
            ->where('f_file', '=', $this->tree->id())
208
            ->sum('f_numchil');
209
    }
210
211
    /**
212
     * @return array<array{place:Place,count:int}>
213
     */
214
    public function countCountries(int $limit): array
215
    {
216
        return DB::table('places')
217
            ->join('placelinks', static function (JoinClause $join): void {
218
                $join
219
                    ->on('pl_file', '=', 'p_file')
220
                    ->on('pl_p_id', '=', 'p_id');
221
            })
222
            ->where('p_file', '=', $this->tree->id())
223
            ->where('p_parent_id', '=', 0)
224
            ->groupBy(['p_place'])
225
            ->orderByDesc(new Expression('COUNT(*)'))
226
            ->orderBy('p_place')
227
            ->take($limit)
228
            ->select([new Expression('COUNT(*) AS total'), 'p_place AS place'])
229
            ->get()
230
            ->map(fn (object $row): array => [
231
                'place' => new Place($row->place, $this->tree),
232
                'count' => (int) $row->total,
233
            ])
234
            ->all();
235
    }
236
237
    private function countEventQuery(string $event, int $year1 = 0, int $year2 = 0): Builder
238
    {
239
        $query = DB::table('dates')
240
            ->where('d_file', '=', $this->tree->id())
241
            ->where('d_fact', '=', $event)
242
            ->whereIn('d_type', ['@#DGREGORIAN@', '@#DJULIAN@']);
243
244
        if ($year1 !== 0 && $year2 !== 0) {
245
            $query->whereBetween('d_year', [$year1, $year2]);
246
        } else {
247
            $query->where('d_year', '<>', 0);
248
        }
249
250
        return $query;
251
    }
252
253
    /**
254
     * @return array<string,int>
255
     */
256
    public function countEventsByMonth(string $event, int $year1, int $year2): array
257
    {
258
        return $this->countEventQuery($event, $year1, $year2)
259
            ->groupBy(['d_month'])
260
            ->pluck(new Expression('COUNT(*) AS total'), 'd_month')
261
            ->map(static fn (string $total): int => (int) $total)
262
            ->all();
263
    }
264
265
    /**
266
     * @return array<object{month:string,sex:string,total:int}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<object{month:string,sex:string,total:int}> at position 2 could not be parsed: Expected '>' at position 2, but found 'object'.
Loading history...
267
     */
268
    public function countEventsByMonthAndSex(string $event, int $year1, int $year2): array
269
    {
270
        return $this->countEventQuery($event, $year1, $year2)
271
            ->join('individuals', static function (JoinClause $join): void {
272
                $join
273
                    ->on('i_id', '=', 'd_gid')
274
                    ->on('i_file', '=', 'd_file');
275
            })
276
            ->groupBy(['i_sex', 'd_month'])
277
            ->select(['d_month', 'i_sex', new Expression('COUNT(*) AS total')])
278
            ->get()
279
            ->map(static fn (object $row): object => (object) [
280
                'month' => $row->d_month,
281
                'sex'   => $row->i_sex,
282
                'total' => (int) $row->total,
283
            ])
284
            ->all();
285
    }
286
287
    /**
288
     * @return array<int,array{0:string,1:int}>
289
     */
290
    public function countEventsByCentury(string $event): array
291
    {
292
        return $this->countEventQuery($event, 0, 0)
293
            ->select([new Expression('ROUND((d_year + 49) / 100, 0) AS century'), new Expression('COUNT(*) AS total')])
294
            ->groupBy(['century'])
295
            ->orderBy('century')
296
            ->get()
297
            ->map(fn (object $row): array => [$this->centuryName((int) $row->century), (int) $row->total])
298
            ->all();
299
    }
300
301
    public function countFamilies(): int
302
    {
303
        return DB::table('families')
304
            ->where('f_file', '=', $this->tree->id())
305
            ->count();
306
    }
307
308
    /**
309
     * @param array<string> $events
310
     */
311
    public function countFamiliesWithEvents(array $events): int
312
    {
313
        return DB::table('dates')
314
            ->join('families', static function (JoinClause $join): void {
315
                $join
316
                    ->on('f_id', '=', 'd_gid')
317
                    ->on('f_file', '=', 'd_file');
318
            })
319
            ->where('d_file', '=', $this->tree->id())
320
            ->whereIn('d_fact', $events)
321
            ->count();
322
    }
323
324
    public function countFamiliesWithNoChildren(): int
325
    {
326
        return DB::table('families')
327
            ->where('f_file', '=', $this->tree->id())
328
            ->where('f_numchil', '=', 0)
329
            ->count();
330
    }
331
332
    public function countFamiliesWithSources(): int
333
    {
334
        return DB::table('families')
335
            ->select(['f_id'])
336
            ->distinct()
337
            ->join('link', static function (JoinClause $join): void {
338
                $join->on('f_id', '=', 'l_from')
339
                    ->on('f_file', '=', 'l_file');
340
            })
341
            ->where('l_file', '=', $this->tree->id())
342
            ->where('l_type', '=', 'SOUR')
343
            ->count('f_id');
344
    }
345
346
    /**
347
     * @return array<string,int>
348
     */
349
    public function countFirstChildrenByMonth(int $year1, int $year2): array
350
    {
351
        return $this->countFirstChildrenQuery($year1, $year2)
352
            ->groupBy(['d_month'])
353
            ->pluck(new Expression('COUNT(*) AS total'), 'd_month')
354
            ->map(static fn (string $total): int => (int) $total)
355
            ->all();
356
    }
357
358
    /**
359
     * @return array<object{month:string,sex:string,total:int}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<object{month:string,sex:string,total:int}> at position 2 could not be parsed: Expected '>' at position 2, but found 'object'.
Loading history...
360
     */
361
    public function countFirstChildrenByMonthAndSex(int $year1, int $year2): array
362
    {
363
        return $this->countFirstChildrenQuery($year1, $year2)
364
            ->join('individuals', static function (JoinClause $join): void {
365
                $join
366
                    ->on('i_file', '=', 'l_file')
367
                    ->on('i_id', '=', 'l_to');
368
            })
369
            ->groupBy(['d_month', 'i_sex'])
370
            ->select(['d_month', 'i_sex', new Expression('COUNT(*) AS total')])
371
            ->get()
372
            ->map(static fn (object $row): object => (object) [
373
                'month' => $row->d_month,
374
                'sex'   => $row->i_sex,
375
                'total' => (int) $row->total,
376
            ])
377
            ->all();
378
    }
379
380
    private function countFirstChildrenQuery(int $year1, int $year2): Builder
381
    {
382
        $first_child_subquery = DB::table('link')
383
            ->join('dates', static function (JoinClause $join): void {
384
                $join
385
                    ->on('d_gid', '=', 'l_to')
386
                    ->on('d_file', '=', 'l_file')
387
                    ->where('d_julianday1', '<>', 0)
388
                    ->whereIn('d_month', ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']);
389
            })
390
            ->where('l_file', '=', $this->tree->id())
391
            ->where('l_type', '=', 'CHIL')
392
            ->select(['l_from AS family_id', new Expression('MIN(d_julianday1) AS min_birth_jd')])
393
            ->groupBy(['family_id']);
394
395
        $query = DB::table('link')
396
            ->join('dates', static function (JoinClause $join): void {
397
                $join
398
                    ->on('d_gid', '=', 'l_to')
399
                    ->on('d_file', '=', 'l_file');
400
            })
401
            ->joinSub($first_child_subquery, 'subquery', static function (JoinClause $join): void {
402
                $join
403
                    ->on('family_id', '=', 'l_from')
404
                    ->on('min_birth_jd', '=', 'd_julianday1');
405
            })
406
            ->where('link.l_file', '=', $this->tree->id())
407
            ->where('link.l_type', '=', 'CHIL');
408
409
        if ($year1 !== 0 && $year2 !== 0) {
410
            $query->whereBetween('d_year', [$year1, $year2]);
411
        }
412
413
        return $query;
414
    }
415
416
    /**
417
     * @return array<string,int>
418
     */
419
    public function countFirstMarriagesByMonth(Tree $tree, int $year1, int $year2): array
420
    {
421
        $query = DB::table('families')
422
            ->join('dates', static function (JoinClause $join): void {
423
                $join
424
                    ->on('d_gid', '=', 'f_id')
425
                    ->on('d_file', '=', 'f_file')
426
                    ->where('d_fact', '=', 'MARR')
427
                    ->whereIn('d_month', ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'])
428
                    ->where('d_julianday2', '<>', 0);
429
            })
430
            ->where('f_file', '=', $tree->id());
431
432
        if ($year1 !== 0 && $year2 !== 0) {
433
            $query->whereBetween('d_year', [$year1, $year2]);
434
        }
435
436
        $rows = $query
437
            ->orderBy('d_julianday2')
438
            ->select(['f_husb AS husb', 'f_wife AS wife', 'd_month AS month'])
439
            ->get();
440
441
        $months = [
442
            'JAN' => 0,
443
            'FEB' => 0,
444
            'MAR' => 0,
445
            'APR' => 0,
446
            'MAY' => 0,
447
            'JUN' => 0,
448
            'JUL' => 0,
449
            'AUG' => 0,
450
            'SEP' => 0,
451
            'OCT' => 0,
452
            'NOV' => 0,
453
            'DEC' => 0,
454
        ];
455
        $seen = [];
456
457
        foreach ($rows as $row) {
458
            if (!in_array($row->husb, $seen, true) && !in_array($row->wife, $seen, true)) {
459
                $months[$row->month]++;
460
                $seen[] = $row->husb;
461
                $seen[] = $row->wife;
462
            }
463
        }
464
465
        return $months;
466
    }
467
468
    /**
469
     * @param array<string> $names
470
     */
471
    public function countGivenNames(array $names): int
472
    {
473
        if ($names === []) {
474
            // Count number of distinct given names.
475
            return DB::table('name')
476
                ->where('n_file', '=', $this->tree->id())
477
                ->distinct()
478
                ->where('n_givn', '<>', Individual::PRAENOMEN_NESCIO)
479
                ->whereNotNull('n_givn')
480
                ->count('n_givn');
481
        }
482
483
        // Count number of occurrences of specific given names.
484
        return DB::table('name')
485
            ->where('n_file', '=', $this->tree->id())
486
            ->whereIn('n_givn', $names)
487
            ->count('n_givn');
488
    }
489
490
    public function countHits(string $page_name, string $page_parameter): int
491
    {
492
        return (int) DB::table('hit_counter')
493
            ->where('gedcom_id', '=', $this->tree->id())
494
            ->where('page_name', '=', $page_name)
495
            ->where('page_parameter', '=', $page_parameter)
496
            ->sum('page_count');
497
    }
498
499
    public function countIndividuals(): int
500
    {
501
        return DB::table('individuals')
502
            ->where('i_file', '=', $this->tree->id())
503
            ->count();
504
    }
505
506
    public function countIndividualsBySex(string $sex): int
507
    {
508
        return DB::table('individuals')
509
            ->where('i_file', '=', $this->tree->id())
510
            ->where('i_sex', '=', $sex)
511
            ->count();
512
    }
513
514
    public function countIndividualsDeceased(): int
515
    {
516
        return DB::table('individuals')
517
            ->where('i_file', '=', $this->tree->id())
518
            ->where(static function (Builder $query): void {
519
                foreach (Gedcom::DEATH_EVENTS as $death_event) {
520
                    $query->orWhere('i_gedcom', 'LIKE', "%\n1 " . $death_event . '%');
521
                }
522
            })
523
            ->count();
524
    }
525
526
    public function countIndividualsLiving(): int
527
    {
528
        $query = DB::table('individuals')
529
            ->where('i_file', '=', $this->tree->id());
530
531
        foreach (Gedcom::DEATH_EVENTS as $death_event) {
532
            $query->where('i_gedcom', 'NOT LIKE', "%\n1 " . $death_event . '%');
533
        }
534
535
        return $query->count();
536
    }
537
538
    /**
539
     * @param array<string> $events
540
     */
541
    public function countIndividualsWithEvents(array $events): int
542
    {
543
        return DB::table('dates')
544
            ->join('individuals', static function (JoinClause $join): void {
545
                $join
546
                    ->on('i_id', '=', 'd_gid')
547
                    ->on('i_file', '=', 'd_file');
548
            })
549
            ->where('d_file', '=', $this->tree->id())
550
            ->whereIn('d_fact', $events)
551
            ->distinct()
552
            ->count(['i_id']);
553
    }
554
555
    public function countIndividualsWithSources(): int
556
    {
557
        return DB::table('individuals')
558
            ->select(['i_id'])
559
            ->distinct()
560
            ->join('link', static function (JoinClause $join): void {
561
                $join->on('i_id', '=', 'l_from')
562
                    ->on('i_file', '=', 'l_file');
563
            })
564
            ->where('l_file', '=', $this->tree->id())
565
            ->where('l_type', '=', 'SOUR')
566
            ->count('i_id');
567
    }
568
569
    public function countMarriedFemales(): int
570
    {
571
        return DB::table('families')
572
            ->where('f_file', '=', $this->tree->id())
573
            ->where('f_gedcom', 'LIKE', "%\n1 MARR%")
574
            ->distinct()
575
            ->count('f_wife');
576
    }
577
578
    public function countMarriedMales(): int
579
    {
580
        return DB::table('families')
581
            ->where('f_file', '=', $this->tree->id())
582
            ->where('f_gedcom', 'LIKE', "%\n1 MARR%")
583
            ->distinct()
584
            ->count('f_husb');
585
    }
586
587
    public function countMedia(string $type = 'all'): int
588
    {
589
        $query = DB::table('media_file')
590
            ->where('m_file', '=', $this->tree->id());
591
592
        if ($type !== 'all') {
593
            $query->where('source_media_type', '=', $type);
594
        }
595
596
        return $query->count();
597
    }
598
599
    /**
600
     * @return array<array{0:string,1:int}>
601
     */
602
    public function countMediaByType(): array
603
    {
604
        $element = Registry::elementFactory()->make('OBJE:FILE:FORM:TYPE');
605
        $values  = $element->values();
606
607
        return DB::table('media_file')
608
            ->where('m_file', '=', $this->tree->id())
609
            ->groupBy('source_media_type')
610
            ->select([new Expression('COUNT(*) AS total'), 'source_media_type'])
611
            ->get()
612
            ->map(static fn (object $row): array => [
613
                $values[$element->canonical($row->source_media_type)] ?? I18N::translate('Other'),
614
                (int) $row->total,
615
            ])
616
            ->all();
617
    }
618
619
    public function countNotes(): int
620
    {
621
        return DB::table('other')
622
            ->where('o_file', '=', $this->tree->id())
623
            ->where('o_type', '=', 'NOTE')
624
            ->count();
625
    }
626
627
    /**
628
     * @param array<string> $events
629
     */
630
    public function countOtherEvents(array $events): int
631
    {
632
        return DB::table('dates')
633
            ->where('d_file', '=', $this->tree->id())
634
            ->whereNotIn('d_fact', $events)
635
            ->count();
636
    }
637
638
    /**
639
     * @param array<string> $rows
640
     *
641
     * @return array<array{place:Place,count:int}>
642
     */
643
    private function countPlaces(array $rows, string $event, int $limit): array
644
    {
645
        $places = [];
646
647
        foreach ($rows as $gedcom) {
648
            if (preg_match('/\n1 ' . $event . '(?:\n[2-9].*)*\n2 PLAC (.+)/', $gedcom, $match) === 1) {
649
                $places[$match[1]] ??= 0;
650
                $places[$match[1]]++;
651
            }
652
        }
653
654
        arsort($places);
655
656
        $records = [];
657
658
        foreach (array_slice($places, 0, $limit) as $place => $count) {
659
            $records[] = [
660
                'place' => new Place((string) $place, $this->tree),
661
                'count' => $count,
662
            ];
663
        }
664
665
        return $records;
666
    }
667
668
    /**
669
     * @return array<array{place:Place,count:int}>
670
     */
671
    public function countPlacesForFamilies(string $event, int $limit): array
672
    {
673
        $rows = DB::table('families')
674
            ->where('f_file', '=', $this->tree->id())
675
            ->where('f_gedcom', 'LIKE', "%\n2 PLAC %")
676
            ->pluck('f_gedcom')
677
            ->all();
678
679
        return $this->countPlaces($rows, $event, $limit);
680
    }
681
682
    /**
683
     * @return array<array{place:Place,count:int}>
684
     */
685
    public function countPlacesForIndividuals(string $event, int $limit): array
686
    {
687
        $rows = DB::table('individuals')
688
            ->where('i_file', '=', $this->tree->id())
689
            ->where('i_gedcom', 'LIKE', "%\n2 PLAC %")
690
            ->pluck('i_gedcom')
691
            ->all();
692
693
        return $this->countPlaces($rows, $event, $limit);
694
    }
695
696
    public function countRepositories(): int
697
    {
698
        return DB::table('other')
699
            ->where('o_file', '=', $this->tree->id())
700
            ->where('o_type', '=', 'REPO')
701
            ->count();
702
    }
703
704
    public function countSources(): int
705
    {
706
        return DB::table('sources')
707
            ->where('s_file', '=', $this->tree->id())
708
            ->count();
709
    }
710
711
    /**
712
     * @param array<string> $names
713
     */
714
    public function countSurnames(array $names): int
715
    {
716
        if ($names === []) {
717
            // Count number of distinct surnames
718
            return DB::table('name')
719
                ->where('n_file', '=', $this->tree->id())->distinct()
720
                ->whereNotNull('n_surn')
721
                ->count('n_surn');
722
        }
723
724
        // Count number of occurrences of specific surnames.
725
        return DB::table('name')
726
            ->where('n_file', '=', $this->tree->id())
727
            ->whereIn('n_surn', $names)
728
            ->count('n_surn');
729
    }
730
731
    public function countTreeFavorites(): int
732
    {
733
        return DB::table('favorite')
734
            ->where('gedcom_id', '=', $this->tree->id())
735
            ->count();
736
    }
737
738
    public function countTreeNews(): int
739
    {
740
        return DB::table('news')
741
            ->where('gedcom_id', '=', $this->tree->id())
742
            ->count();
743
    }
744
745
    public function countUserfavorites(): int
746
    {
747
        return DB::table('favorite')
748
            ->where('user_id', '=', Auth::id())
749
            ->count();
750
    }
751
752
    public function countUserJournal(): int
753
    {
754
        return DB::table('news')
755
            ->where('user_id', '=', Auth::id())
756
            ->count();
757
    }
758
759
    public function countUserMessages(): int
760
    {
761
        return DB::table('message')
762
            ->where('user_id', '=', Auth::id())
763
            ->count();
764
    }
765
766
    /**
767
     * @return array<int,object{family:Family,children:int}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int,object{family:Family,children:int}> at position 4 could not be parsed: Expected '>' at position 4, but found 'object'.
Loading history...
768
     */
769
    public function familiesWithTheMostChildren(int $limit): array
770
    {
771
        return DB::table('families')
772
            ->where('f_file', '=', $this->tree->id())
773
            ->orderByDesc('f_numchil')
774
            ->limit($limit)
775
            ->get()
776
            ->map(fn (object $row): object => (object) [
777
                'family'   => Registry::familyFactory()->make($row->f_id, $this->tree, $row->f_gedcom),
778
                'children' => (int) $row->f_numchil,
779
            ])
780
            ->all();
781
    }
782
783
    /**
784
     * @param array<string> $events
785
     *
786
     * @return object{id:string,year:int,fact:string,type:string}|null
787
     */
788
    private function firstEvent(array $events, bool $ascending): object|null
789
    {
790
        if ($events === []) {
791
            $events = [
792
                ...Gedcom::BIRTH_EVENTS,
793
                ...Gedcom::DEATH_EVENTS,
794
                ...Gedcom::MARRIAGE_EVENTS,
795
                ...Gedcom::DIVORCE_EVENTS,
796
            ];
797
        }
798
799
        return DB::table('dates')
800
            ->select(['d_gid as id', 'd_year as year', 'd_fact AS fact', 'd_type AS type'])
801
            ->where('d_file', '=', $this->tree->id())
802
            ->whereIn('d_fact', $events)
803
            ->where('d_julianday1', '<>', 0)
804
            ->orderBy('d_julianday1', $ascending ? 'ASC' : 'DESC')
805
            ->limit(1)
806
            ->get()
807
            ->map(static fn (object $row): object => (object) [
808
                'id'   => $row->id,
809
                'year' => (int) $row->year,
810
                'fact' => $row->fact,
811
                'type' => $row->type,
812
            ])
813
            ->first();
814
    }
815
816
    /**
817
     * @param array<string> $events
818
     */
819
    public function firstEventName(array $events, bool $ascending): string
820
    {
821
        $row = $this->firstEvent($events, $ascending);
822
823
        if ($row !== null) {
824
            $record = Registry::gedcomRecordFactory()->make($row->id, $this->tree);
825
826
            if ($record instanceof GedcomRecord) {
827
                return '<a href="' . e($record->url()) . '">' . $record->fullName() . '</a>';
828
            }
829
        }
830
831
        return '';
832
    }
833
834
    /**
835
     * @param array<string> $events
836
     */
837
    public function firstEventPlace(array $events, bool $ascending): string
838
    {
839
        $row = $this->firstEvent($events, $ascending);
840
841
        if ($row !== null) {
842
            $record = Registry::gedcomRecordFactory()->make($row->id, $this->tree);
843
            $fact   = null;
844
845
            if ($record instanceof GedcomRecord) {
846
                $fact = $record->facts([$row->fact])->first();
847
            }
848
849
            if ($fact instanceof Fact) {
850
                return $fact->place()->shortName();
851
            }
852
        }
853
854
        return I18N::translate('Private');
855
    }
856
857
    /**
858
     * @param array<string> $events
859
     */
860
    public function firstEventRecord(array $events, bool $ascending): string
861
    {
862
        $row = $this->firstEvent($events, $ascending);
863
        $result = I18N::translate('This information is not available.');
864
865
        if ($row !== null) {
866
            $record = Registry::gedcomRecordFactory()->make($row->id, $this->tree);
867
868
            if ($record instanceof GedcomRecord && $record->canShow()) {
869
                $result = $record->formatList();
870
            } else {
871
                $result = I18N::translate('This information is private and cannot be shown.');
872
            }
873
        }
874
875
        return $result;
876
    }
877
878
    /**
879
     * @param array<string> $events
880
     */
881
    public function firstEventType(array $events, bool $ascending): string
882
    {
883
        $row = $this->firstEvent($events, $ascending);
884
885
        if ($row === null) {
886
            return '';
887
        }
888
889
        foreach ([Individual::RECORD_TYPE, Family::RECORD_TYPE] as $record_type) {
890
            $element = Registry::elementFactory()->make($record_type . ':' . $row->fact);
891
892
            if (!$element instanceof UnknownElement) {
893
                return $element->label();
894
            }
895
        }
896
897
        return $row->fact;
898
    }
899
900
    /**
901
     * @param array<string> $events
902
     */
903
    public function firstEventYear(array $events, bool $ascending): string
904
    {
905
        $row = $this->firstEvent($events, $ascending);
906
907
        if ($row === null) {
908
            return '-';
909
        }
910
911
        if ($row->year < 0) {
912
            $date = new Date($row->type . ' ' . abs($row->year) . ' B.C.');
913
        } else {
914
            $date = new Date($row->type . ' ' . $row->year);
915
        }
916
917
        return $date->display();
918
    }
919
920
    public function isUserLoggedIn(int|null $user_id): bool
921
    {
922
        return $user_id !== null && DB::table('session')
923
            ->where('user_id', '=', $user_id)
924
            ->exists();
925
    }
926
927
    public function latestUserId(): int|null
928
    {
929
        $user_id = DB::table('user')
930
            ->select(['user.user_id'])
931
            ->leftJoin('user_setting', 'user.user_id', '=', 'user_setting.user_id')
932
            ->where('setting_name', '=', UserInterface::PREF_TIMESTAMP_REGISTERED)
933
            ->orderByDesc('setting_value')
934
            ->value('user_id');
935
936
        if ($user_id === null) {
937
            return null;
938
        }
939
940
        return (int) $user_id;
941
    }
942
943
    /**
944
     * @return array<object{family:Family,child1:Individual,child2:Individual,age:string}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<object{family:Fami...Individual,age:string}> at position 2 could not be parsed: Expected '>' at position 2, but found 'object'.
Loading history...
945
     */
946
    public function maximumAgeBetweenSiblings(int $limit): array
947
    {
948
        return DB::table('link AS link1')
949
            ->join('link AS link2', static function (JoinClause $join): void {
950
                $join
951
                    ->on('link2.l_from', '=', 'link1.l_from')
952
                    ->on('link2.l_type', '=', 'link1.l_type')
953
                    ->on('link2.l_file', '=', 'link1.l_file');
954
            })
955
            ->join('dates AS child1', static function (JoinClause $join): void {
956
                $join
957
                    ->on('child1.d_gid', '=', 'link1.l_to')
958
                    ->on('child1.d_file', '=', 'link1.l_file')
959
                    ->where('child1.d_fact', '=', 'BIRT')
960
                    ->where('child1.d_julianday1', '<>', 0);
961
            })
962
            ->join('dates AS child2', static function (JoinClause $join): void {
963
                $join
964
                    ->on('child2.d_gid', '=', 'link2.l_to')
965
                    ->on('child2.d_file', '=', 'link2.l_file')
966
                    ->where('child2.d_fact', '=', 'BIRT')
967
                    ->whereColumn('child2.d_julianday2', '>', 'child1.d_julianday1');
968
            })
969
            ->where('link1.l_type', '=', 'CHIL')
970
            ->where('link1.l_file', '=', $this->tree->id())
971
            ->distinct()
972
            ->select(['link1.l_from AS family', 'link1.l_to AS child1', 'link2.l_to AS child2', new Expression(DB::prefix('child2.d_julianday2') . ' - ' . DB::prefix('child1.d_julianday1') . ' AS age')])
973
            ->orderBy('age', 'DESC')
974
            ->take($limit)
975
            ->get()
976
            ->map(fn (object $row): object => (object) [
977
                'family' => Registry::familyFactory()->make($row->family, $this->tree),
978
                'child1' => Registry::individualFactory()->make($row->child1, $this->tree),
979
                'child2' => Registry::individualFactory()->make($row->child2, $this->tree),
980
                'age'    => $this->calculateAge((int) $row->age),
981
            ])
982
            ->filter(static fn (object $row): bool => $row->family !== null)
983
            ->filter(static fn (object $row): bool => $row->child1 !== null)
984
            ->filter(static fn (object $row): bool => $row->child2 !== null)
985
            ->all();
986
    }
987
988
    /**
989
     * @return Collection<int,Individual>
990
     */
991
    public function topTenOldestAliveQuery(string $sex, int $limit): Collection
992
    {
993
        $query = DB::table('dates')
994
            ->join('individuals', static function (JoinClause $join): void {
995
                $join
996
                    ->on('i_id', '=', 'd_gid')
997
                    ->on('i_file', '=', 'd_file');
998
            })
999
            ->where('d_file', '=', $this->tree->id())
1000
            ->where('d_julianday1', '<>', 0)
1001
            ->where('d_fact', '=', 'BIRT')
1002
            ->where('i_gedcom', 'NOT LIKE', "%\n1 DEAT%")
1003
            ->where('i_gedcom', 'NOT LIKE', "%\n1 BURI%")
1004
            ->where('i_gedcom', 'NOT LIKE', "%\n1 CREM%");
1005
1006
        if ($sex === 'F' || $sex === 'M' || $sex === 'U' || $sex === 'X') {
1007
            $query->where('i_sex', '=', $sex);
1008
        }
1009
1010
        return $query
1011
            ->groupBy(['i_id', 'i_file'])
1012
            ->orderBy(new Expression('MIN(d_julianday1)'))
1013
            ->select(['individuals.*'])
1014
            ->take($limit)
1015
            ->get()
1016
            ->map(Registry::individualFactory()->mapper($this->tree))
1017
            ->filter(GedcomRecord::accessFilter());
1018
    }
1019
1020
    public function commonSurnamesQuery(string $type, bool $totals, int $threshold, int $limit, string $sort): string
1021
    {
1022
        $surnames = $this->commonSurnames($limit, $threshold, $sort);
1023
1024
        // find a module providing individual lists
1025
        $module = Registry::container()->get(ModuleService::class)
1026
            ->findByComponent(ModuleListInterface::class, $this->tree, Auth::user())
1027
            ->first(static fn (ModuleInterface $module): bool => $module instanceof IndividualListModule);
1028
1029
        if ($type === 'list') {
1030
            return view('lists/surnames-bullet-list', [
1031
                'surnames' => $surnames,
1032
                'module'   => $module,
1033
                'totals'   => $totals,
1034
                'tree'     => $this->tree,
1035
            ]);
1036
        }
1037
1038
        return view('lists/surnames-compact-list', [
1039
            'surnames' => $surnames,
1040
            'module'   => $module,
1041
            'totals'   => $totals,
1042
            'tree'     => $this->tree,
1043
        ]);
1044
    }
1045
1046
    /**
1047
     * @return  array<object{age:float,century:int,sex:string}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<object{age:float,century:int,sex:string}> at position 2 could not be parsed: Expected '>' at position 2, but found 'object'.
Loading history...
1048
     */
1049
    public function statsAge(): array
1050
    {
1051
        return DB::table('individuals')
1052
            ->select([
1053
                new Expression('AVG(' . DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ') / 365.25 AS age'),
1054
                new Expression('ROUND((' . DB::prefix('death.d_year') . ' + 49) / 100, 0) AS century'),
1055
                'i_sex AS sex'
1056
            ])
1057
            ->join('dates AS birth', static function (JoinClause $join): void {
1058
                $join
1059
                    ->on('birth.d_file', '=', 'i_file')
1060
                    ->on('birth.d_gid', '=', 'i_id');
1061
            })
1062
            ->join('dates AS death', static function (JoinClause $join): void {
1063
                $join
1064
                    ->on('death.d_file', '=', 'i_file')
1065
                    ->on('death.d_gid', '=', 'i_id');
1066
            })
1067
            ->where('i_file', '=', $this->tree->id())
1068
            ->where('birth.d_fact', '=', 'BIRT')
1069
            ->where('death.d_fact', '=', 'DEAT')
1070
            ->whereIn('birth.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1071
            ->whereIn('death.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1072
            ->whereColumn('death.d_julianday1', '>=', 'birth.d_julianday2')
1073
            ->where('birth.d_julianday2', '<>', 0)
1074
            ->groupBy(['century', 'sex'])
1075
            ->orderBy('century')
1076
            ->orderBy('sex')
1077
            ->get()
1078
            ->map(static fn (object $row): object => (object) [
1079
                'age'     => (float) $row->age,
1080
                'century' => (int) $row->century,
1081
                'sex'     => $row->sex,
1082
            ])
1083
            ->all();
1084
    }
1085
1086
    /**
1087
     * General query on ages.
1088
     *
1089
     * @return array<object{days:int}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<object{days:int}> at position 2 could not be parsed: Expected '>' at position 2, but found 'object'.
Loading history...
1090
     */
1091
    public function statsAgeQuery(string $sex, int $year1, int $year2): array
1092
    {
1093
        $query = $this->birthAndDeathQuery($sex);
1094
1095
        if ($year1 !== 0 && $year2 !== 0) {
1096
            $query
1097
                ->whereIn('birth.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1098
                ->whereIn('death.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1099
                ->whereBetween('death.d_year', [$year1, $year2]);
1100
        }
1101
1102
        return $query
1103
            ->select([new Expression(DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS days')])
1104
            ->orderBy('days', 'desc')
1105
            ->get()
1106
            ->map(static fn (object $row): object => (object) ['days' => (int) $row->days])
1107
            ->all();
1108
    }
1109
1110
    private function birthAndDeathQuery(string $sex): Builder
1111
    {
1112
        $query = DB::table('individuals')
1113
            ->where('i_file', '=', $this->tree->id())
1114
            ->join('dates AS birth', static function (JoinClause $join): void {
1115
                $join
1116
                    ->on('birth.d_file', '=', 'i_file')
1117
                    ->on('birth.d_gid', '=', 'i_id');
1118
            })
1119
            ->join('dates AS death', static function (JoinClause $join): void {
1120
                $join
1121
                    ->on('death.d_file', '=', 'i_file')
1122
                    ->on('death.d_gid', '=', 'i_id');
1123
            })
1124
            ->where('birth.d_fact', '=', 'BIRT')
1125
            ->where('death.d_fact', '=', 'DEAT')
1126
            ->whereColumn('death.d_julianday1', '>=', 'birth.d_julianday2')
1127
            ->where('birth.d_julianday2', '<>', 0);
1128
1129
        if ($sex !== 'ALL') {
1130
            $query->where('i_sex', '=', $sex);
1131
        }
1132
1133
        return $query;
1134
    }
1135
1136
    /**
1137
     * @return object{individual:Individual,days:int}|null
1138
     */
1139
    public function longlifeQuery(string $sex): object|null
1140
    {
1141
        return $this->birthAndDeathQuery($sex)
1142
            ->orderBy('days', 'desc')
1143
            ->select(['individuals.*', new Expression(DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS days')])
1144
            ->take(1)
1145
            ->get()
1146
            ->map(fn (object $row): object => (object) [
1147
                'individual' => Registry::individualFactory()->mapper($this->tree)($row),
1148
                'days'       => (int) $row->days
1149
            ])
1150
            ->first();
1151
    }
1152
1153
    /**
1154
     * @return Collection<int,object{individual:Individual,days:int}>
1155
     */
1156
    public function topTenOldestQuery(string $sex, int $limit): Collection
1157
    {
1158
        return $this->birthAndDeathQuery($sex)
1159
            ->groupBy(['i_id', 'i_file'])
1160
            ->orderBy('days', 'desc')
1161
            ->select(['individuals.*', new Expression('MAX(' . DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ') AS days')])
1162
            ->take($limit)
1163
            ->get()
1164
            ->map(fn (object $row): object => (object) [
1165
                'individual' => Registry::individualFactory()->mapper($this->tree)($row),
1166
                'days'       => (int) $row->days
1167
            ]);
1168
    }
1169
1170
    /**
1171
     * @return array<string>
1172
     */
1173
    private function getIso3166Countries(): array
1174
    {
1175
        // Get the country names for each language
1176
        $country_to_iso3166 = [];
1177
1178
        $current_language = I18N::languageTag();
1179
1180
        foreach (I18N::activeLocales() as $locale) {
1181
            I18N::init($locale->languageTag());
1182
1183
            $countries = $this->getAllCountries();
1184
1185
            foreach ($this->iso3166() as $three => $two) {
1186
                $country_to_iso3166[$three]             = $two;
1187
                $country_to_iso3166[$countries[$three]] = $two;
1188
            }
1189
        }
1190
1191
        I18N::init($current_language);
1192
1193
        return $country_to_iso3166;
1194
    }
1195
1196
    /**
1197
     * Returns the data structure required by google geochart.
1198
     *
1199
     * @param array<int> $places
1200
     *
1201
     * @return array<int,array<int|string|array<string,string>>>
1202
     */
1203
    private function createChartData(array $places): array
1204
    {
1205
        $data = [
1206
            [
1207
                I18N::translate('Country'),
1208
                I18N::translate('Total'),
1209
            ],
1210
        ];
1211
1212
        // webtrees uses 3-letter country codes and localised country names, but google uses 2 letter codes.
1213
        foreach ($places as $country => $count) {
1214
            $data[] = [
1215
                [
1216
                    'v' => $country,
1217
                    'f' => $this->mapTwoLetterToName($country),
1218
                ],
1219
                $count
1220
            ];
1221
        }
1222
1223
        return $data;
1224
    }
1225
1226
    /**
1227
     * @return array<string,int>
1228
     */
1229
    private function countIndividualsByCountry(Tree $tree): array
1230
    {
1231
        $rows = DB::table('places')
1232
            ->where('p_file', '=', $tree->id())
1233
            ->where('p_parent_id', '=', 0)
1234
            ->join('placelinks', static function (JoinClause $join): void {
1235
                $join
1236
                    ->on('pl_file', '=', 'p_file')
1237
                    ->on('pl_p_id', '=', 'p_id');
1238
            })
1239
            ->join('individuals', static function (JoinClause $join): void {
1240
                $join
1241
                    ->on('pl_file', '=', 'i_file')
1242
                    ->on('pl_gid', '=', 'i_id');
1243
            })
1244
            ->groupBy('p_place')
1245
            ->pluck(new Expression('COUNT(*) AS total'), 'p_place')
1246
            ->all();
1247
1248
        $totals = [];
1249
1250
        $country_to_iso3166 = $this->getIso3166Countries();
1251
1252
        foreach ($rows as $country => $count) {
1253
            $country_code = $country_to_iso3166[$country] ?? null;
1254
1255
            if ($country_code !== null) {
1256
                $totals[$country_code] ??= 0;
1257
                $totals[$country_code] += $count;
1258
            }
1259
        }
1260
1261
        return $totals;
1262
    }
1263
1264
    /**
1265
     * @return array<string,int>
1266
     */
1267
    private function countSurnamesByCountry(Tree $tree, string $surname): array
1268
    {
1269
        $rows =
1270
            DB::table('places')
1271
                ->where('p_file', '=', $tree->id())
1272
                ->where('p_parent_id', '=', 0)
1273
                ->join('placelinks', static function (JoinClause $join): void {
1274
                    $join
1275
                        ->on('pl_file', '=', 'p_file')
1276
                        ->on('pl_p_id', '=', 'p_id');
1277
                })
1278
                ->join('name', static function (JoinClause $join): void {
1279
                    $join
1280
                        ->on('n_file', '=', 'pl_file')
1281
                        ->on('n_id', '=', 'pl_gid');
1282
                })
1283
                ->where('n_surn', '=', $surname)
1284
                ->groupBy('p_place')
1285
                ->pluck(new Expression('COUNT(*) AS total'), 'p_place');
1286
1287
        $totals = [];
1288
1289
        $country_to_iso3166 = $this->getIso3166Countries();
1290
1291
        foreach ($rows as $country => $count) {
1292
            $country_code = $country_to_iso3166[$country] ?? null;
1293
1294
            if ($country_code !== null) {
1295
                $totals[$country_code] ??= 0;
1296
                $totals[$country_code] += $count;
1297
            }
1298
        }
1299
1300
        return $totals;
1301
    }
1302
1303
    /**
1304
     * @return array<string,int>
1305
     */
1306
    private function countFamilyEventsByCountry(Tree $tree, string $fact): array
1307
    {
1308
        $query = DB::table('places')
1309
            ->where('p_file', '=', $tree->id())
1310
            ->where('p_parent_id', '=', 0)
1311
            ->join('placelinks', static function (JoinClause $join): void {
1312
                $join
1313
                    ->on('pl_file', '=', 'p_file')
1314
                    ->on('pl_p_id', '=', 'p_id');
1315
            })
1316
            ->join('families', static function (JoinClause $join): void {
1317
                $join
1318
                    ->on('pl_file', '=', 'f_file')
1319
                    ->on('pl_gid', '=', 'f_id');
1320
            })
1321
            ->select(['p_place AS place', 'f_gedcom AS gedcom']);
1322
1323
        return $this->filterEventPlaces($query, $fact);
1324
    }
1325
1326
    /**
1327
     * @return array<string,int>
1328
     */
1329
    private function countIndividualEventsByCountry(Tree $tree, string $fact): array
1330
    {
1331
        $query = DB::table('places')
1332
            ->where('p_file', '=', $tree->id())
1333
            ->where('p_parent_id', '=', 0)
1334
            ->join('placelinks', static function (JoinClause $join): void {
1335
                $join
1336
                    ->on('pl_file', '=', 'p_file')
1337
                    ->on('pl_p_id', '=', 'p_id');
1338
            })
1339
            ->join('individuals', static function (JoinClause $join): void {
1340
                $join
1341
                    ->on('pl_file', '=', 'i_file')
1342
                    ->on('pl_gid', '=', 'i_id');
1343
            })
1344
            ->select(['p_place AS place', 'i_gedcom AS gedcom']);
1345
1346
        return $this->filterEventPlaces($query, $fact);
1347
    }
1348
1349
    /**
1350
     * @return array<string,int>
1351
     */
1352
    private function filterEventPlaces(Builder $query, string $fact): array
1353
    {
1354
        $totals = [];
1355
1356
        $country_to_iso3166 = $this->getIso3166Countries();
1357
1358
        foreach ($query->cursor() as $row) {
1359
            $country_code = $country_to_iso3166[$row->place] ?? null;
1360
1361
            if ($country_code !== null) {
1362
                $place_regex = '/\n1 ' . $fact . '(?:\n[2-9].*)*\n2 PLAC.*[, ]' . preg_quote($row->place, '(?:\n|$)/i') . '\n/';
1363
1364
                if (preg_match($place_regex, $row->gedcom) === 1) {
1365
                    $totals[$country_code] = 1 + ($totals[$country_code] ?? 0);
1366
                }
1367
            }
1368
        }
1369
1370
        return $totals;
1371
    }
1372
1373
    /**
1374
     * Create a chart showing where events occurred.
1375
     *
1376
     * @param string $chart_shows The type of chart map to show
1377
     * @param string $chart_type  The type of chart to show
1378
     * @param string $surname     The surname for surname based distribution chart
1379
     */
1380
    public function chartDistribution(
1381
        string $chart_shows = 'world',
1382
        string $chart_type = '',
1383
        string $surname = ''
1384
    ): string {
1385
        switch ($chart_type) {
1386
            case 'surname_distribution_chart':
1387
                $chart_title = I18N::translate('Surname distribution chart') . ': ' . $surname;
1388
                $surnames    = $this->commonSurnames(1, 0, 'count');
1389
                $surname     = implode(I18N::$list_separator, array_keys(array_shift($surnames) ?? []));
1390
                $data        = $this->createChartData($this->countSurnamesByCountry($this->tree, $surname));
1391
                break;
1392
1393
            case 'birth_distribution_chart':
1394
                $chart_title = I18N::translate('Birth by country');
1395
                $data        = $this->createChartData($this->countIndividualEventsByCountry($this->tree, 'BIRT'));
1396
                break;
1397
1398
            case 'death_distribution_chart':
1399
                $chart_title = I18N::translate('Death by country');
1400
                $data        = $this->createChartData($this->countIndividualEventsByCountry($this->tree, 'DEAT'));
1401
                break;
1402
1403
            case 'marriage_distribution_chart':
1404
                $chart_title = I18N::translate('Marriage by country');
1405
                $data        = $this->createChartData($this->countFamilyEventsByCountry($this->tree, 'MARR'));
1406
                break;
1407
1408
            case 'indi_distribution_chart':
1409
            default:
1410
                $chart_title = I18N::translate('Individual distribution chart');
1411
                $data        = $this->createChartData($this->countIndividualsByCountry($this->tree));
1412
                break;
1413
        }
1414
1415
        return view('statistics/other/charts/geo', [
1416
            'chart_title'  => $chart_title,
1417
            'chart_color2' => '84beff',
1418
            'chart_color3' => 'c3dfff',
1419
            'region'       => $chart_shows,
1420
            'data'         => $data,
1421
            'language'     => I18N::languageTag(),
1422
        ]);
1423
    }
1424
1425
    /**
1426
     * @return array<array{family:Family,count:int}>
1427
     */
1428
    private function topTenGrandFamilyQuery(int $limit): array
1429
    {
1430
        return DB::table('families')
1431
            ->join('link AS children', static function (JoinClause $join): void {
1432
                $join
1433
                    ->on('children.l_from', '=', 'f_id')
1434
                    ->on('children.l_file', '=', 'f_file')
1435
                    ->where('children.l_type', '=', 'CHIL');
1436
            })->join('link AS mchildren', static function (JoinClause $join): void {
1437
                $join
1438
                    ->on('mchildren.l_file', '=', 'children.l_file')
1439
                    ->on('mchildren.l_from', '=', 'children.l_to')
1440
                    ->where('mchildren.l_type', '=', 'FAMS');
1441
            })->join('link AS gchildren', static function (JoinClause $join): void {
1442
                $join
1443
                    ->on('gchildren.l_file', '=', 'mchildren.l_file')
1444
                    ->on('gchildren.l_from', '=', 'mchildren.l_to')
1445
                    ->where('gchildren.l_type', '=', 'CHIL');
1446
            })
1447
            ->where('f_file', '=', $this->tree->id())
1448
            ->groupBy(['f_id', 'f_file'])
1449
            ->orderBy(new Expression('COUNT(*)'), 'DESC')
1450
            ->select(['families.*'])
1451
            ->limit($limit)
1452
            ->get()
1453
            ->map(Registry::familyFactory()->mapper($this->tree))
1454
            ->filter(GedcomRecord::accessFilter())
1455
            ->map(static function (Family $family): array {
1456
                $count = 0;
1457
                foreach ($family->children() as $child) {
1458
                    foreach ($child->spouseFamilies() as $spouse_family) {
1459
                        $count += $spouse_family->children()->count();
1460
                    }
1461
                }
1462
1463
                return [
1464
                    'family' => $family,
1465
                    'count'  => $count,
1466
                ];
1467
            })
1468
            ->all();
1469
    }
1470
1471
    public function topTenLargestGrandFamily(int $limit = 10): string
1472
    {
1473
        return view('statistics/families/top10-nolist-grand', [
1474
            'records' => $this->topTenGrandFamilyQuery($limit),
1475
        ]);
1476
    }
1477
1478
    public function topTenLargestGrandFamilyList(int $limit = 10): string
1479
    {
1480
        return view('statistics/families/top10-list-grand', [
1481
            'records' => $this->topTenGrandFamilyQuery($limit),
1482
        ]);
1483
    }
1484
1485
    public function noChildrenFamiliesList(string $type = 'list'): string
1486
    {
1487
        $families = DB::table('families')
1488
            ->where('f_file', '=', $this->tree->id())
1489
            ->where('f_numchil', '=', 0)
1490
            ->get()
1491
            ->map(Registry::familyFactory()->mapper($this->tree))
1492
            ->filter(GedcomRecord::accessFilter());
1493
1494
        $top10 = [];
1495
1496
        foreach ($families as $family) {
1497
            if ($type === 'list') {
1498
                $top10[] = '<li><a href="' . e($family->url()) . '">' . $family->fullName() . '</a></li>';
1499
            } else {
1500
                $top10[] = '<a href="' . e($family->url()) . '">' . $family->fullName() . '</a>';
1501
            }
1502
        }
1503
1504
        if ($type === 'list') {
1505
            $top10 = implode('', $top10);
1506
        } else {
1507
            $top10 = implode('; ', $top10);
1508
        }
1509
1510
        if ($type === 'list') {
1511
            return '<ul>' . $top10 . '</ul>';
1512
        }
1513
1514
        return $top10;
1515
    }
1516
1517
    /**
1518
     * Returns the calculated age the time of event.
1519
     *
1520
     * @param int $age The age from the database record
1521
     */
1522
    private function calculateAge(int $age): string
1523
    {
1524
        if ($age < 31) {
1525
            return I18N::plural('%s day', '%s days', $age, I18N::number($age));
1526
        }
1527
1528
        if ($age < 365) {
1529
            $months = (int) ($age / 30.5);
1530
1531
            return I18N::plural('%s month', '%s months', $months, I18N::number($months));
1532
        }
1533
1534
        $years = (int) ($age / 365.25);
1535
1536
        return I18N::plural('%s year', '%s years', $years, I18N::number($years));
1537
    }
1538
1539
    public function topAgeBetweenSiblings(): string
1540
    {
1541
        $rows = $this->maximumAgeBetweenSiblings(1);
1542
1543
        if ($rows === []) {
1544
            return I18N::translate('This information is not available.');
1545
        }
1546
1547
        return $rows[0]->age;
1548
    }
1549
1550
    public function topAgeBetweenSiblingsFullName(): string
1551
    {
1552
        $rows = $this->maximumAgeBetweenSiblings(1);
1553
1554
        if ($rows === []) {
1555
            return I18N::translate('This information is not available.');
1556
        }
1557
1558
        return view('statistics/families/top10-nolist-age', ['record' => (array) $rows[0]]);
1559
    }
1560
1561
    public function topAgeBetweenSiblingsList(int $limit, bool $unique_families): string
1562
    {
1563
        $rows    = $this->maximumAgeBetweenSiblings($limit);
1564
        $records = [];
1565
        $dist    = [];
1566
1567
        foreach ($rows as $row) {
1568
            if (!$unique_families || !in_array($row->family, $dist, true)) {
1569
                $records[] = [
1570
                    'child1' => $row->child1,
1571
                    'child2' => $row->child2,
1572
                    'family' => $row->family,
1573
                    'age'    => $row->age,
1574
                ];
1575
1576
                $dist[] = $row->family;
1577
            }
1578
        }
1579
1580
        return view('statistics/families/top10-list-age', [
1581
            'records' => $records,
1582
        ]);
1583
    }
1584
1585
    /**
1586
     * @return array<object{f_numchil:int,total:int}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<object{f_numchil:int,total:int}> at position 2 could not be parsed: Expected '>' at position 2, but found 'object'.
Loading history...
1587
     */
1588
    public function statsChildrenQuery(int $year1, int $year2): array
1589
    {
1590
        $query = DB::table('families')
1591
            ->where('f_file', '=', $this->tree->id())
1592
            ->groupBy(['f_numchil'])
1593
            ->select(['f_numchil', new Expression('COUNT(*) AS total')]);
1594
1595
        if ($year1 !== 0 && $year2 !== 0) {
1596
            $query
1597
                ->join('dates', static function (JoinClause $join): void {
1598
                    $join
1599
                        ->on('d_file', '=', 'f_file')
1600
                        ->on('d_gid', '=', 'f_id');
1601
                })
1602
                ->where('d_fact', '=', 'MARR')
1603
                ->whereIn('d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1604
                ->whereBetween('d_year', [$year1, $year2]);
1605
        }
1606
1607
        return $query->get()
1608
            ->map(static fn (object $row): object => (object) [
1609
                'f_numchil' => (int) $row->f_numchil,
1610
                'total'     => (int) $row->total,
1611
            ])
1612
            ->all();
1613
    }
1614
1615
    /**
1616
     * @return array<array{family:Family,count:int}>
1617
     */
1618
    private function topTenFamilyQuery(int $limit): array
1619
    {
1620
        return DB::table('families')
1621
            ->where('f_file', '=', $this->tree->id())
1622
            ->orderBy('f_numchil', 'DESC')
1623
            ->limit($limit)
1624
            ->get()
1625
            ->map(Registry::familyFactory()->mapper($this->tree))
1626
            ->filter(GedcomRecord::accessFilter())
1627
            ->map(static fn (Family $family): array => [
1628
                'family' => $family,
1629
                'count'  => $family->numberOfChildren(),
1630
            ])
1631
            ->all();
1632
    }
1633
1634
    public function topTenLargestFamily(int $limit = 10): string
1635
    {
1636
        $records = $this->topTenFamilyQuery($limit);
1637
1638
        return view('statistics/families/top10-nolist', [
1639
            'records' => $records,
1640
        ]);
1641
    }
1642
1643
    public function topTenLargestFamilyList(int $limit = 10): string
1644
    {
1645
        $records = $this->topTenFamilyQuery($limit);
1646
1647
        return view('statistics/families/top10-list', [
1648
            'records' => $records,
1649
        ]);
1650
    }
1651
1652
    public function parentsQuery(string $type, string $age_dir, string $sex, bool $show_years): string
1653
    {
1654
        if ($sex === 'F') {
1655
            $sex_field = 'WIFE';
1656
        } else {
1657
            $sex_field = 'HUSB';
1658
        }
1659
1660
        if ($age_dir !== 'ASC') {
1661
            $age_dir = 'DESC';
1662
        }
1663
1664
        $row = DB::table('link AS parentfamily')
1665
            ->join('link AS childfamily', static function (JoinClause $join): void {
1666
                $join
1667
                    ->on('childfamily.l_file', '=', 'parentfamily.l_file')
1668
                    ->on('childfamily.l_from', '=', 'parentfamily.l_from')
1669
                    ->where('childfamily.l_type', '=', 'CHIL');
1670
            })
1671
            ->join('dates AS birth', static function (JoinClause $join): void {
1672
                $join
1673
                    ->on('birth.d_file', '=', 'parentfamily.l_file')
1674
                    ->on('birth.d_gid', '=', 'parentfamily.l_to')
1675
                    ->where('birth.d_fact', '=', 'BIRT')
1676
                    ->where('birth.d_julianday1', '<>', 0);
1677
            })
1678
            ->join('dates AS childbirth', static function (JoinClause $join): void {
1679
                $join
1680
                    ->on('childbirth.d_file', '=', 'parentfamily.l_file')
1681
                    ->on('childbirth.d_gid', '=', 'childfamily.l_to')
1682
                    ->where('childbirth.d_fact', '=', 'BIRT');
1683
            })
1684
            ->where('childfamily.l_file', '=', $this->tree->id())
1685
            ->where('parentfamily.l_type', '=', $sex_field)
1686
            ->where('childbirth.d_julianday2', '>', new Expression(DB::prefix('birth.d_julianday1')))
1687
            ->select(['parentfamily.l_to AS id', new Expression(DB::prefix('childbirth.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS age')])
1688
            ->take(1)
1689
            ->orderBy('age', $age_dir)
1690
            ->get()
1691
            ->first();
1692
1693
        if ($row === null) {
1694
            return I18N::translate('This information is not available.');
1695
        }
1696
1697
        $person = Registry::individualFactory()->make($row->id, $this->tree);
1698
1699
        switch ($type) {
1700
            default:
1701
            case 'full':
1702
                if ($person !== null && $person->canShow()) {
1703
                    $result = $person->formatList();
1704
                } else {
1705
                    $result = I18N::translate('This information is private and cannot be shown.');
1706
                }
1707
                break;
1708
1709
            case 'name':
1710
                $result = '<a href="' . e($person->url()) . '">' . $person->fullName() . '</a>';
1711
                break;
1712
1713
            case 'age':
1714
                $age = $row->age;
1715
1716
                if ($show_years) {
1717
                    $result = $this->calculateAge((int) $row->age);
1718
                } else {
1719
                    $result = (string) floor($age / 365.25);
1720
                }
1721
1722
                break;
1723
        }
1724
1725
        return $result;
1726
    }
1727
1728
    /**
1729
     * General query on age at marriage.
1730
     *
1731
     * @param string $type
1732
     * @param string $age_dir "ASC" or "DESC"
1733
     * @param int    $limit
1734
     */
1735
    public function ageOfMarriageQuery(string $type, string $age_dir, int $limit): string
1736
    {
1737
        $hrows = DB::table('families')
1738
            ->where('f_file', '=', $this->tree->id())
1739
            ->join('dates AS married', static function (JoinClause $join): void {
1740
                $join
1741
                    ->on('married.d_file', '=', 'f_file')
1742
                    ->on('married.d_gid', '=', 'f_id')
1743
                    ->where('married.d_fact', '=', 'MARR')
1744
                    ->where('married.d_julianday1', '<>', 0);
1745
            })
1746
            ->join('dates AS husbdeath', static function (JoinClause $join): void {
1747
                $join
1748
                    ->on('husbdeath.d_gid', '=', 'f_husb')
1749
                    ->on('husbdeath.d_file', '=', 'f_file')
1750
                    ->where('husbdeath.d_fact', '=', 'DEAT');
1751
            })
1752
            ->whereColumn('married.d_julianday1', '<', 'husbdeath.d_julianday2')
1753
            ->groupBy(['f_id'])
1754
            ->select(['f_id AS family', new Expression('MIN(' . DB::prefix('husbdeath.d_julianday2') . ' - ' . DB::prefix('married.d_julianday1') . ') AS age')])
1755
            ->get()
1756
            ->all();
1757
1758
        $wrows = DB::table('families')
1759
            ->where('f_file', '=', $this->tree->id())
1760
            ->join('dates AS married', static function (JoinClause $join): void {
1761
                $join
1762
                    ->on('married.d_file', '=', 'f_file')
1763
                    ->on('married.d_gid', '=', 'f_id')
1764
                    ->where('married.d_fact', '=', 'MARR')
1765
                    ->where('married.d_julianday1', '<>', 0);
1766
            })
1767
            ->join('dates AS wifedeath', static function (JoinClause $join): void {
1768
                $join
1769
                    ->on('wifedeath.d_gid', '=', 'f_wife')
1770
                    ->on('wifedeath.d_file', '=', 'f_file')
1771
                    ->where('wifedeath.d_fact', '=', 'DEAT');
1772
            })
1773
            ->whereColumn('married.d_julianday1', '<', 'wifedeath.d_julianday2')
1774
            ->groupBy(['f_id'])
1775
            ->select(['f_id AS family', new Expression('MIN(' . DB::prefix('wifedeath.d_julianday2') . ' - ' . DB::prefix('married.d_julianday1') . ') AS age')])
1776
            ->get()
1777
            ->all();
1778
1779
        $drows = DB::table('families')
1780
            ->where('f_file', '=', $this->tree->id())
1781
            ->join('dates AS married', static function (JoinClause $join): void {
1782
                $join
1783
                    ->on('married.d_file', '=', 'f_file')
1784
                    ->on('married.d_gid', '=', 'f_id')
1785
                    ->where('married.d_fact', '=', 'MARR')
1786
                    ->where('married.d_julianday1', '<>', 0);
1787
            })
1788
            ->join('dates AS divorced', static function (JoinClause $join): void {
1789
                $join
1790
                    ->on('divorced.d_gid', '=', 'f_id')
1791
                    ->on('divorced.d_file', '=', 'f_file')
1792
                    ->whereIn('divorced.d_fact', ['DIV', 'ANUL', '_SEPR']);
1793
            })
1794
            ->whereColumn('married.d_julianday1', '<', 'divorced.d_julianday2')
1795
            ->groupBy(['f_id'])
1796
            ->select(['f_id AS family', new Expression('MIN(' . DB::prefix('divorced.d_julianday2') . ' - ' . DB::prefix('married.d_julianday1') . ') AS age')])
1797
            ->get()
1798
            ->all();
1799
1800
        $rows = [];
1801
        foreach ($drows as $family) {
1802
            $rows[$family->family] = $family->age;
1803
        }
1804
1805
        foreach ($hrows as $family) {
1806
            if (!isset($rows[$family->family])) {
1807
                $rows[$family->family] = $family->age;
1808
            }
1809
        }
1810
1811
        foreach ($wrows as $family) {
1812
            if (!isset($rows[$family->family])) {
1813
                $rows[$family->family] = $family->age;
1814
            } elseif ($rows[$family->family] > $family->age) {
1815
                $rows[$family->family] = $family->age;
1816
            }
1817
        }
1818
1819
        if ($age_dir === 'DESC') {
1820
            arsort($rows);
1821
        } else {
1822
            asort($rows);
1823
        }
1824
1825
        $top10 = [];
1826
        $i     = 0;
1827
        foreach ($rows as $xref => $age) {
1828
            $family = Registry::familyFactory()->make((string) $xref, $this->tree);
1829
            if ($type === 'name') {
1830
                return $family->formatList();
1831
            }
1832
1833
            $age = $this->calculateAge((int) $age);
1834
1835
            if ($type === 'age') {
1836
                return $age;
1837
            }
1838
1839
            $husb = $family->husband();
1840
            $wife = $family->wife();
1841
1842
            if (
1843
                $husb instanceof Individual &&
1844
                $wife instanceof Individual &&
1845
                ($husb->getAllDeathDates() || !$husb->isDead()) &&
1846
                ($wife->getAllDeathDates() || !$wife->isDead())
1847
            ) {
1848
                if ($family->canShow()) {
1849
                    if ($type === 'list') {
1850
                        $top10[] = '<li><a href="' . e($family->url()) . '">' . $family->fullName() . '</a> (' . $age . ')' . '</li>';
1851
                    } else {
1852
                        $top10[] = '<a href="' . e($family->url()) . '">' . $family->fullName() . '</a> (' . $age . ')';
1853
                    }
1854
                }
1855
                if (++$i === $limit) {
1856
                    break;
1857
                }
1858
            }
1859
        }
1860
1861
        if ($type === 'list') {
1862
            $top10 = implode('', $top10);
1863
        } else {
1864
            $top10 = implode('; ', $top10);
1865
        }
1866
1867
        if (I18N::direction() === 'rtl') {
1868
            $top10 = str_replace([
1869
                '[',
1870
                ']',
1871
                '(',
1872
                ')',
1873
                '+',
1874
            ], [
1875
                '&rlm;[',
1876
                '&rlm;]',
1877
                '&rlm;(',
1878
                '&rlm;)',
1879
                '&rlm;+',
1880
            ], $top10);
1881
        }
1882
1883
        if ($type === 'list') {
1884
            return '<ul>' . $top10 . '</ul>';
1885
        }
1886
1887
        return $top10;
1888
    }
1889
1890
    /**
1891
     * @return array<array{family:Family,age:string}>
1892
     */
1893
    private function ageBetweenSpousesQuery(string $age_dir, int $limit): array
1894
    {
1895
        $query = DB::table('families')
1896
            ->where('f_file', '=', $this->tree->id())
1897
            ->join('dates AS wife', static function (JoinClause $join): void {
1898
                $join
1899
                    ->on('wife.d_gid', '=', 'f_wife')
1900
                    ->on('wife.d_file', '=', 'f_file')
1901
                    ->where('wife.d_fact', '=', 'BIRT')
1902
                    ->where('wife.d_julianday1', '<>', 0);
1903
            })
1904
            ->join('dates AS husb', static function (JoinClause $join): void {
1905
                $join
1906
                    ->on('husb.d_gid', '=', 'f_husb')
1907
                    ->on('husb.d_file', '=', 'f_file')
1908
                    ->where('husb.d_fact', '=', 'BIRT')
1909
                    ->where('husb.d_julianday1', '<>', 0);
1910
            });
1911
1912
        if ($age_dir === 'DESC') {
1913
            $query
1914
                ->whereColumn('wife.d_julianday1', '>=', 'husb.d_julianday1')
1915
                ->orderBy(new Expression('MIN(' . DB::prefix('wife.d_julianday1') . ') - MIN(' . DB::prefix('husb.d_julianday1') . ')'), 'DESC');
1916
        } else {
1917
            $query
1918
                ->whereColumn('husb.d_julianday1', '>=', 'wife.d_julianday1')
1919
                ->orderBy(new Expression('MIN(' . DB::prefix('husb.d_julianday1') . ') - MIN(' . DB::prefix('wife.d_julianday1') . ')'), 'DESC');
1920
        }
1921
1922
        return $query
1923
            ->groupBy(['f_id', 'f_file'])
1924
            ->select(['families.*'])
1925
            ->take($limit)
1926
            ->get()
1927
            ->map(Registry::familyFactory()->mapper($this->tree))
1928
            ->filter(GedcomRecord::accessFilter())
1929
            ->map(function (Family $family) use ($age_dir): array {
1930
                $husb_birt_jd = $family->husband()->getBirthDate()->minimumJulianDay();
1931
                $wife_birt_jd = $family->wife()->getBirthDate()->minimumJulianDay();
1932
1933
                if ($age_dir === 'DESC') {
1934
                    $diff = $wife_birt_jd - $husb_birt_jd;
1935
                } else {
1936
                    $diff = $husb_birt_jd - $wife_birt_jd;
1937
                }
1938
1939
                return [
1940
                    'family' => $family,
1941
                    'age'    => $this->calculateAge($diff),
1942
                ];
1943
            })
1944
            ->all();
1945
    }
1946
1947
    public function ageBetweenSpousesMF(int $limit = 10): string
1948
    {
1949
        $records = $this->ageBetweenSpousesQuery('DESC', $limit);
1950
1951
        return view('statistics/families/top10-nolist-spouses', [
1952
            'records' => $records,
1953
        ]);
1954
    }
1955
1956
    public function ageBetweenSpousesMFList(int $limit = 10): string
1957
    {
1958
        $records = $this->ageBetweenSpousesQuery('DESC', $limit);
1959
1960
        return view('statistics/families/top10-list-spouses', [
1961
            'records' => $records,
1962
        ]);
1963
    }
1964
1965
    public function ageBetweenSpousesFM(int $limit = 10): string
1966
    {
1967
        return view('statistics/families/top10-nolist-spouses', [
1968
            'records' => $this->ageBetweenSpousesQuery('ASC', $limit),
1969
        ]);
1970
    }
1971
1972
    public function ageBetweenSpousesFMList(int $limit = 10): string
1973
    {
1974
        return view('statistics/families/top10-list-spouses', [
1975
            'records' => $this->ageBetweenSpousesQuery('ASC', $limit),
1976
        ]);
1977
    }
1978
1979
    /**
1980
     * @return array<object{f_id:string,d_gid:string,age:int}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<object{f_id:string,d_gid:string,age:int}> at position 2 could not be parsed: Expected '>' at position 2, but found 'object'.
Loading history...
1981
     */
1982
    public function statsMarrAgeQuery(string $sex, int $year1, int $year2): array
1983
    {
1984
        $query = DB::table('dates AS married')
1985
            ->join('families', static function (JoinClause $join): void {
1986
                $join
1987
                    ->on('f_file', '=', 'married.d_file')
1988
                    ->on('f_id', '=', 'married.d_gid');
1989
            })
1990
            ->join('dates AS birth', static function (JoinClause $join) use ($sex): void {
1991
                $join
1992
                    ->on('birth.d_file', '=', 'married.d_file')
1993
                    ->on('birth.d_gid', '=', $sex === 'M' ? 'f_husb' : 'f_wife')
1994
                    ->where('birth.d_julianday1', '<>', 0)
1995
                    ->where('birth.d_fact', '=', 'BIRT')
1996
                    ->whereIn('birth.d_type', ['@#DGREGORIAN@', '@#DJULIAN@']);
1997
            })
1998
            ->where('married.d_file', '=', $this->tree->id())
1999
            ->where('married.d_fact', '=', 'MARR')
2000
            ->whereIn('married.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
2001
            ->whereColumn('married.d_julianday1', '>', 'birth.d_julianday1')
2002
            ->select(['f_id', 'birth.d_gid', new Expression(DB::prefix('married.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS age')]);
2003
2004
        if ($year1 !== 0 && $year2 !== 0) {
2005
            $query->whereBetween('married.d_year', [$year1, $year2]);
2006
        }
2007
2008
        return $query
2009
            ->get()
2010
            ->map(static fn (object $row): object => (object) [
2011
                'f_id'  => $row->f_id,
2012
                'd_gid' => $row->d_gid,
2013
                'age'   => (int) $row->age,
2014
            ])
2015
            ->all();
2016
    }
2017
2018
    /**
2019
     * Query the database for marriage tags.
2020
     *
2021
     * @param string $show    "full", "name" or "age"
2022
     * @param string $age_dir "ASC" or "DESC"
2023
     * @param string $sex     "F" or "M"
2024
     * @param bool   $show_years
2025
     */
2026
    public function marriageQuery(string $show, string $age_dir, string $sex, bool $show_years): string
2027
    {
2028
        if ($sex === 'F') {
2029
            $sex_field = 'f_wife';
2030
        } else {
2031
            $sex_field = 'f_husb';
2032
        }
2033
2034
        if ($age_dir !== 'ASC') {
2035
            $age_dir = 'DESC';
2036
        }
2037
2038
        $row = DB::table('families')
2039
            ->join('dates AS married', static function (JoinClause $join): void {
2040
                $join
2041
                    ->on('married.d_file', '=', 'f_file')
2042
                    ->on('married.d_gid', '=', 'f_id')
2043
                    ->where('married.d_fact', '=', 'MARR');
2044
            })
2045
            ->join('individuals', static function (JoinClause $join) use ($sex, $sex_field): void {
2046
                $join
2047
                    ->on('i_file', '=', 'f_file')
2048
                    ->on('i_id', '=', $sex_field)
2049
                    ->where('i_sex', '=', $sex);
2050
            })
2051
            ->join('dates AS birth', static function (JoinClause $join): void {
2052
                $join
2053
                    ->on('birth.d_file', '=', 'i_file')
2054
                    ->on('birth.d_gid', '=', 'i_id')
2055
                    ->where('birth.d_fact', '=', 'BIRT')
2056
                    ->where('birth.d_julianday1', '<>', 0);
2057
            })
2058
            ->where('f_file', '=', $this->tree->id())
2059
            ->where('married.d_julianday2', '>', new Expression(DB::prefix('birth.d_julianday1')))
2060
            ->orderBy(new Expression(DB::prefix('married.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1')), $age_dir)
2061
            ->select(['f_id AS famid', $sex_field, new Expression(DB::prefix('married.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS age'), 'i_id'])
2062
            ->take(1)
2063
            ->get()
2064
            ->first();
2065
2066
        if ($row === null) {
2067
            return I18N::translate('This information is not available.');
2068
        }
2069
2070
        $family = Registry::familyFactory()->make($row->famid, $this->tree);
2071
        $person = Registry::individualFactory()->make($row->i_id, $this->tree);
2072
2073
        switch ($show) {
2074
            default:
2075
            case 'full':
2076
                if ($family !== null && $family->canShow()) {
2077
                    $result = $family->formatList();
2078
                } else {
2079
                    $result = I18N::translate('This information is private and cannot be shown.');
2080
                }
2081
                break;
2082
2083
            case 'name':
2084
                $result = '<a href="' . e($family->url()) . '">' . $person->fullName() . '</a>';
2085
                break;
2086
2087
            case 'age':
2088
                $age = $row->age;
2089
2090
                if ($show_years) {
2091
                    $result = $this->calculateAge((int) $row->age);
2092
                } else {
2093
                    $result = I18N::number((int) ($age / 365.25));
2094
                }
2095
2096
                break;
2097
        }
2098
2099
        return $result;
2100
    }
2101
2102
    /**
2103
     * Who is currently logged in?
2104
     *
2105
     * @param string $type "list" or "nolist"
2106
     */
2107
    private function usersLoggedInQuery(string $type): string
2108
    {
2109
        $content   = '';
2110
        $anonymous = 0;
2111
        $logged_in = [];
2112
2113
        foreach ($this->user_service->allLoggedIn() as $user) {
2114
            if (Auth::isAdmin() || $user->getPreference(UserInterface::PREF_IS_VISIBLE_ONLINE) === '1') {
2115
                $logged_in[] = $user;
2116
            } else {
2117
                $anonymous++;
2118
            }
2119
        }
2120
2121
        $count_logged_in = count($logged_in);
2122
2123
        if ($count_logged_in === 0 && $anonymous === 0) {
2124
            $content .= I18N::translate('No signed-in and no anonymous users');
2125
        }
2126
2127
        if ($anonymous > 0) {
2128
            $content .= '<b>' . I18N::plural('%s anonymous signed-in user', '%s anonymous signed-in users', $anonymous, I18N::number($anonymous)) . '</b>';
2129
        }
2130
2131
        if ($count_logged_in > 0) {
2132
            if ($anonymous !== 0) {
2133
                if ($type === 'list') {
2134
                    $content .= '<br><br>';
2135
                } else {
2136
                    $content .= ' ' . I18N::translate('and') . ' ';
2137
                }
2138
            }
2139
            $content .= '<b>' . I18N::plural('%s signed-in user', '%s signed-in users', $count_logged_in, I18N::number($count_logged_in)) . '</b>';
2140
            if ($type === 'list') {
2141
                $content .= '<ul>';
2142
            } else {
2143
                $content .= ': ';
2144
            }
2145
        }
2146
2147
        if (Auth::check()) {
2148
            foreach ($logged_in as $user) {
2149
                if ($type === 'list') {
2150
                    $content .= '<li>';
2151
                }
2152
2153
                $individual = Registry::individualFactory()->make($this->tree->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF), $this->tree);
2154
2155
                if ($individual instanceof Individual && $individual->canShow()) {
2156
                    $content .= '<a href="' . e($individual->url()) . '">' . e($user->realName()) . '</a>';
2157
                } else {
2158
                    $content .= e($user->realName());
2159
                }
2160
2161
                $content .= ' - ' . e($user->userName());
2162
2163
                if ($user->getPreference(UserInterface::PREF_CONTACT_METHOD) !== MessageService::CONTACT_METHOD_NONE && Auth::id() !== $user->id()) {
2164
                    $content .= '<a href="' . e(route(MessagePage::class, ['to' => $user->userName(), 'tree' => $this->tree->name()])) . '" class="btn btn-link" title="' . I18N::translate('Send a message') . '">' . view('icons/email') . '</a>';
2165
                }
2166
2167
                if ($type === 'list') {
2168
                    $content .= '</li>';
2169
                }
2170
            }
2171
        }
2172
2173
        if ($type === 'list') {
2174
            $content .= '</ul>';
2175
        }
2176
2177
        return $content;
2178
    }
2179
2180
    public function usersLoggedIn(): string
2181
    {
2182
        return $this->usersLoggedInQuery('nolist');
2183
    }
2184
2185
    public function usersLoggedInList(): string
2186
    {
2187
        return $this->usersLoggedInQuery('list');
2188
    }
2189
2190
    /**
2191
     * Century name, English => 21st, Polish => XXI, etc.
2192
     */
2193
    private function centuryName(int $century): string
2194
    {
2195
        if ($century < 0) {
2196
            return I18N::translate('%s BCE', $this->centuryName(-$century));
2197
        }
2198
2199
        // The current chart engine (Google charts) can't handle <sup></sup> markup
2200
        return match ($century) {
2201
            21 => strip_tags(I18N::translateContext('CENTURY', '21st')),
2202
            20 => strip_tags(I18N::translateContext('CENTURY', '20th')),
2203
            19 => strip_tags(I18N::translateContext('CENTURY', '19th')),
2204
            18 => strip_tags(I18N::translateContext('CENTURY', '18th')),
2205
            17 => strip_tags(I18N::translateContext('CENTURY', '17th')),
2206
            16 => strip_tags(I18N::translateContext('CENTURY', '16th')),
2207
            15 => strip_tags(I18N::translateContext('CENTURY', '15th')),
2208
            14 => strip_tags(I18N::translateContext('CENTURY', '14th')),
2209
            13 => strip_tags(I18N::translateContext('CENTURY', '13th')),
2210
            12 => strip_tags(I18N::translateContext('CENTURY', '12th')),
2211
            11 => strip_tags(I18N::translateContext('CENTURY', '11th')),
2212
            10 => strip_tags(I18N::translateContext('CENTURY', '10th')),
2213
            9 => strip_tags(I18N::translateContext('CENTURY', '9th')),
2214
            8 => strip_tags(I18N::translateContext('CENTURY', '8th')),
2215
            7 => strip_tags(I18N::translateContext('CENTURY', '7th')),
2216
            6 => strip_tags(I18N::translateContext('CENTURY', '6th')),
2217
            5 => strip_tags(I18N::translateContext('CENTURY', '5th')),
2218
            4 => strip_tags(I18N::translateContext('CENTURY', '4th')),
2219
            3 => strip_tags(I18N::translateContext('CENTURY', '3rd')),
2220
            2 => strip_tags(I18N::translateContext('CENTURY', '2nd')),
2221
            1 => strip_tags(I18N::translateContext('CENTURY', '1st')),
2222
            default => ($century - 1) . '01-' . $century . '00',
2223
        };
2224
    }
2225
2226
    /**
2227
     * @return array<string>
2228
     */
2229
    private function getAllCountries(): array
2230
    {
2231
        return [
2232
            /* I18N: Name of a country or state */
2233
            '???' => I18N::translate('Unknown'),
2234
            /* I18N: Name of a country or state */
2235
            'ABW' => I18N::translate('Aruba'),
2236
            /* I18N: Name of a country or state */
2237
            'AFG' => I18N::translate('Afghanistan'),
2238
            /* I18N: Name of a country or state */
2239
            'AGO' => I18N::translate('Angola'),
2240
            /* I18N: Name of a country or state */
2241
            'AIA' => I18N::translate('Anguilla'),
2242
            /* I18N: Name of a country or state */
2243
            'ALA' => I18N::translate('Åland Islands'),
2244
            /* I18N: Name of a country or state */
2245
            'ALB' => I18N::translate('Albania'),
2246
            /* I18N: Name of a country or state */
2247
            'AND' => I18N::translate('Andorra'),
2248
            /* I18N: Name of a country or state */
2249
            'ARE' => I18N::translate('United Arab Emirates'),
2250
            /* I18N: Name of a country or state */
2251
            'ARG' => I18N::translate('Argentina'),
2252
            /* I18N: Name of a country or state */
2253
            'ARM' => I18N::translate('Armenia'),
2254
            /* I18N: Name of a country or state */
2255
            'ASM' => I18N::translate('American Samoa'),
2256
            /* I18N: Name of a country or state */
2257
            'ATA' => I18N::translate('Antarctica'),
2258
            /* I18N: Name of a country or state */
2259
            'ATF' => I18N::translate('French Southern Territories'),
2260
            /* I18N: Name of a country or state */
2261
            'ATG' => I18N::translate('Antigua and Barbuda'),
2262
            /* I18N: Name of a country or state */
2263
            'AUS' => I18N::translate('Australia'),
2264
            /* I18N: Name of a country or state */
2265
            'AUT' => I18N::translate('Austria'),
2266
            /* I18N: Name of a country or state */
2267
            'AZE' => I18N::translate('Azerbaijan'),
2268
            /* I18N: Name of a country or state */
2269
            'AZR' => I18N::translate('Azores'),
2270
            /* I18N: Name of a country or state */
2271
            'BDI' => I18N::translate('Burundi'),
2272
            /* I18N: Name of a country or state */
2273
            'BEL' => I18N::translate('Belgium'),
2274
            /* I18N: Name of a country or state */
2275
            'BEN' => I18N::translate('Benin'),
2276
            // BES => Bonaire, Sint Eustatius and Saba
2277
            /* I18N: Name of a country or state */
2278
            'BFA' => I18N::translate('Burkina Faso'),
2279
            /* I18N: Name of a country or state */
2280
            'BGD' => I18N::translate('Bangladesh'),
2281
            /* I18N: Name of a country or state */
2282
            'BGR' => I18N::translate('Bulgaria'),
2283
            /* I18N: Name of a country or state */
2284
            'BHR' => I18N::translate('Bahrain'),
2285
            /* I18N: Name of a country or state */
2286
            'BHS' => I18N::translate('Bahamas'),
2287
            /* I18N: Name of a country or state */
2288
            'BIH' => I18N::translate('Bosnia and Herzegovina'),
2289
            // BLM => Saint Barthélemy
2290
            'BLM' => I18N::translate('Saint Barthélemy'),
2291
            /* I18N: Name of a country or state */
2292
            'BLR' => I18N::translate('Belarus'),
2293
            /* I18N: Name of a country or state */
2294
            'BLZ' => I18N::translate('Belize'),
2295
            /* I18N: Name of a country or state */
2296
            'BMU' => I18N::translate('Bermuda'),
2297
            /* I18N: Name of a country or state */
2298
            'BOL' => I18N::translate('Bolivia'),
2299
            /* I18N: Name of a country or state */
2300
            'BRA' => I18N::translate('Brazil'),
2301
            /* I18N: Name of a country or state */
2302
            'BRB' => I18N::translate('Barbados'),
2303
            /* I18N: Name of a country or state */
2304
            'BRN' => I18N::translate('Brunei Darussalam'),
2305
            /* I18N: Name of a country or state */
2306
            'BTN' => I18N::translate('Bhutan'),
2307
            /* I18N: Name of a country or state */
2308
            'BVT' => I18N::translate('Bouvet Island'),
2309
            /* I18N: Name of a country or state */
2310
            'BWA' => I18N::translate('Botswana'),
2311
            /* I18N: Name of a country or state */
2312
            'CAF' => I18N::translate('Central African Republic'),
2313
            /* I18N: Name of a country or state */
2314
            'CAN' => I18N::translate('Canada'),
2315
            /* I18N: Name of a country or state */
2316
            'CCK' => I18N::translate('Cocos (Keeling) Islands'),
2317
            /* I18N: Name of a country or state */
2318
            'CHE' => I18N::translate('Switzerland'),
2319
            /* I18N: Name of a country or state */
2320
            'CHL' => I18N::translate('Chile'),
2321
            /* I18N: Name of a country or state */
2322
            'CHN' => I18N::translate('China'),
2323
            /* I18N: Name of a country or state */
2324
            'CIV' => I18N::translate('Côte d’Ivoire'),
2325
            /* I18N: Name of a country or state */
2326
            'CMR' => I18N::translate('Cameroon'),
2327
            /* I18N: Name of a country or state */
2328
            'COD' => I18N::translate('Democratic Republic of the Congo'),
2329
            /* I18N: Name of a country or state */
2330
            'COG' => I18N::translate('Republic of the Congo'),
2331
            /* I18N: Name of a country or state */
2332
            'COK' => I18N::translate('Cook Islands'),
2333
            /* I18N: Name of a country or state */
2334
            'COL' => I18N::translate('Colombia'),
2335
            /* I18N: Name of a country or state */
2336
            'COM' => I18N::translate('Comoros'),
2337
            /* I18N: Name of a country or state */
2338
            'CPV' => I18N::translate('Cape Verde'),
2339
            /* I18N: Name of a country or state */
2340
            'CRI' => I18N::translate('Costa Rica'),
2341
            /* I18N: Name of a country or state */
2342
            'CUB' => I18N::translate('Cuba'),
2343
            /* I18N: Name of a country or state */
2344
            'CUW' => I18N::translate('Curaçao'),
2345
            /* I18N: Name of a country or state */
2346
            'CXR' => I18N::translate('Christmas Island'),
2347
            /* I18N: Name of a country or state */
2348
            'CYM' => I18N::translate('Cayman Islands'),
2349
            /* I18N: Name of a country or state */
2350
            'CYP' => I18N::translate('Cyprus'),
2351
            /* I18N: Name of a country or state */
2352
            'CZE' => I18N::translate('Czech Republic'),
2353
            /* I18N: Name of a country or state */
2354
            'DEU' => I18N::translate('Germany'),
2355
            /* I18N: Name of a country or state */
2356
            'DJI' => I18N::translate('Djibouti'),
2357
            /* I18N: Name of a country or state */
2358
            'DMA' => I18N::translate('Dominica'),
2359
            /* I18N: Name of a country or state */
2360
            'DNK' => I18N::translate('Denmark'),
2361
            /* I18N: Name of a country or state */
2362
            'DOM' => I18N::translate('Dominican Republic'),
2363
            /* I18N: Name of a country or state */
2364
            'DZA' => I18N::translate('Algeria'),
2365
            /* I18N: Name of a country or state */
2366
            'ECU' => I18N::translate('Ecuador'),
2367
            /* I18N: Name of a country or state */
2368
            'EGY' => I18N::translate('Egypt'),
2369
            /* I18N: Name of a country or state */
2370
            'ENG' => I18N::translate('England'),
2371
            /* I18N: Name of a country or state */
2372
            'ERI' => I18N::translate('Eritrea'),
2373
            /* I18N: Name of a country or state */
2374
            'ESH' => I18N::translate('Western Sahara'),
2375
            /* I18N: Name of a country or state */
2376
            'ESP' => I18N::translate('Spain'),
2377
            /* I18N: Name of a country or state */
2378
            'EST' => I18N::translate('Estonia'),
2379
            /* I18N: Name of a country or state */
2380
            'ETH' => I18N::translate('Ethiopia'),
2381
            /* I18N: Name of a country or state */
2382
            'FIN' => I18N::translate('Finland'),
2383
            /* I18N: Name of a country or state */
2384
            'FJI' => I18N::translate('Fiji'),
2385
            /* I18N: Name of a country or state */
2386
            'FLD' => I18N::translate('Flanders'),
2387
            /* I18N: Name of a country or state */
2388
            'FLK' => I18N::translate('Falkland Islands'),
2389
            /* I18N: Name of a country or state */
2390
            'FRA' => I18N::translate('France'),
2391
            /* I18N: Name of a country or state */
2392
            'FRO' => I18N::translate('Faroe Islands'),
2393
            /* I18N: Name of a country or state */
2394
            'FSM' => I18N::translate('Micronesia'),
2395
            /* I18N: Name of a country or state */
2396
            'GAB' => I18N::translate('Gabon'),
2397
            /* I18N: Name of a country or state */
2398
            'GBR' => I18N::translate('United Kingdom'),
2399
            /* I18N: Name of a country or state */
2400
            'GEO' => I18N::translate('Georgia'),
2401
            /* I18N: Name of a country or state */
2402
            'GGY' => I18N::translate('Guernsey'),
2403
            /* I18N: Name of a country or state */
2404
            'GHA' => I18N::translate('Ghana'),
2405
            /* I18N: Name of a country or state */
2406
            'GIB' => I18N::translate('Gibraltar'),
2407
            /* I18N: Name of a country or state */
2408
            'GIN' => I18N::translate('Guinea'),
2409
            /* I18N: Name of a country or state */
2410
            'GLP' => I18N::translate('Guadeloupe'),
2411
            /* I18N: Name of a country or state */
2412
            'GMB' => I18N::translate('Gambia'),
2413
            /* I18N: Name of a country or state */
2414
            'GNB' => I18N::translate('Guinea-Bissau'),
2415
            /* I18N: Name of a country or state */
2416
            'GNQ' => I18N::translate('Equatorial Guinea'),
2417
            /* I18N: Name of a country or state */
2418
            'GRC' => I18N::translate('Greece'),
2419
            /* I18N: Name of a country or state */
2420
            'GRD' => I18N::translate('Grenada'),
2421
            /* I18N: Name of a country or state */
2422
            'GRL' => I18N::translate('Greenland'),
2423
            /* I18N: Name of a country or state */
2424
            'GTM' => I18N::translate('Guatemala'),
2425
            /* I18N: Name of a country or state */
2426
            'GUF' => I18N::translate('French Guiana'),
2427
            /* I18N: Name of a country or state */
2428
            'GUM' => I18N::translate('Guam'),
2429
            /* I18N: Name of a country or state */
2430
            'GUY' => I18N::translate('Guyana'),
2431
            /* I18N: Name of a country or state */
2432
            'HKG' => I18N::translate('Hong Kong'),
2433
            /* I18N: Name of a country or state */
2434
            'HMD' => I18N::translate('Heard Island and McDonald Islands'),
2435
            /* I18N: Name of a country or state */
2436
            'HND' => I18N::translate('Honduras'),
2437
            /* I18N: Name of a country or state */
2438
            'HRV' => I18N::translate('Croatia'),
2439
            /* I18N: Name of a country or state */
2440
            'HTI' => I18N::translate('Haiti'),
2441
            /* I18N: Name of a country or state */
2442
            'HUN' => I18N::translate('Hungary'),
2443
            /* I18N: Name of a country or state */
2444
            'IDN' => I18N::translate('Indonesia'),
2445
            /* I18N: Name of a country or state */
2446
            'IND' => I18N::translate('India'),
2447
            /* I18N: Name of a country or state */
2448
            'IOM' => I18N::translate('Isle of Man'),
2449
            /* I18N: Name of a country or state */
2450
            'IOT' => I18N::translate('British Indian Ocean Territory'),
2451
            /* I18N: Name of a country or state */
2452
            'IRL' => I18N::translate('Ireland'),
2453
            /* I18N: Name of a country or state */
2454
            'IRN' => I18N::translate('Iran'),
2455
            /* I18N: Name of a country or state */
2456
            'IRQ' => I18N::translate('Iraq'),
2457
            /* I18N: Name of a country or state */
2458
            'ISL' => I18N::translate('Iceland'),
2459
            /* I18N: Name of a country or state */
2460
            'ISR' => I18N::translate('Israel'),
2461
            /* I18N: Name of a country or state */
2462
            'ITA' => I18N::translate('Italy'),
2463
            /* I18N: Name of a country or state */
2464
            'JAM' => I18N::translate('Jamaica'),
2465
            //'JEY' => Jersey
2466
            /* I18N: Name of a country or state */
2467
            'JOR' => I18N::translate('Jordan'),
2468
            /* I18N: Name of a country or state */
2469
            'JPN' => I18N::translate('Japan'),
2470
            /* I18N: Name of a country or state */
2471
            'KAZ' => I18N::translate('Kazakhstan'),
2472
            /* I18N: Name of a country or state */
2473
            'KEN' => I18N::translate('Kenya'),
2474
            /* I18N: Name of a country or state */
2475
            'KGZ' => I18N::translate('Kyrgyzstan'),
2476
            /* I18N: Name of a country or state */
2477
            'KHM' => I18N::translate('Cambodia'),
2478
            /* I18N: Name of a country or state */
2479
            'KIR' => I18N::translate('Kiribati'),
2480
            /* I18N: Name of a country or state */
2481
            'KNA' => I18N::translate('Saint Kitts and Nevis'),
2482
            /* I18N: Name of a country or state */
2483
            'KOR' => I18N::translate('Korea'),
2484
            /* I18N: Name of a country or state */
2485
            'KWT' => I18N::translate('Kuwait'),
2486
            /* I18N: Name of a country or state */
2487
            'LAO' => I18N::translate('Laos'),
2488
            /* I18N: Name of a country or state */
2489
            'LBN' => I18N::translate('Lebanon'),
2490
            /* I18N: Name of a country or state */
2491
            'LBR' => I18N::translate('Liberia'),
2492
            /* I18N: Name of a country or state */
2493
            'LBY' => I18N::translate('Libya'),
2494
            /* I18N: Name of a country or state */
2495
            'LCA' => I18N::translate('Saint Lucia'),
2496
            /* I18N: Name of a country or state */
2497
            'LIE' => I18N::translate('Liechtenstein'),
2498
            /* I18N: Name of a country or state */
2499
            'LKA' => I18N::translate('Sri Lanka'),
2500
            /* I18N: Name of a country or state */
2501
            'LSO' => I18N::translate('Lesotho'),
2502
            /* I18N: Name of a country or state */
2503
            'LTU' => I18N::translate('Lithuania'),
2504
            /* I18N: Name of a country or state */
2505
            'LUX' => I18N::translate('Luxembourg'),
2506
            /* I18N: Name of a country or state */
2507
            'LVA' => I18N::translate('Latvia'),
2508
            /* I18N: Name of a country or state */
2509
            'MAC' => I18N::translate('Macau'),
2510
            // MAF => Saint Martin
2511
            /* I18N: Name of a country or state */
2512
            'MAR' => I18N::translate('Morocco'),
2513
            /* I18N: Name of a country or state */
2514
            'MCO' => I18N::translate('Monaco'),
2515
            /* I18N: Name of a country or state */
2516
            'MDA' => I18N::translate('Moldova'),
2517
            /* I18N: Name of a country or state */
2518
            'MDG' => I18N::translate('Madagascar'),
2519
            /* I18N: Name of a country or state */
2520
            'MDV' => I18N::translate('Maldives'),
2521
            /* I18N: Name of a country or state */
2522
            'MEX' => I18N::translate('Mexico'),
2523
            /* I18N: Name of a country or state */
2524
            'MHL' => I18N::translate('Marshall Islands'),
2525
            /* I18N: Name of a country or state */
2526
            'MKD' => I18N::translate('Macedonia'),
2527
            /* I18N: Name of a country or state */
2528
            'MLI' => I18N::translate('Mali'),
2529
            /* I18N: Name of a country or state */
2530
            'MLT' => I18N::translate('Malta'),
2531
            /* I18N: Name of a country or state */
2532
            'MMR' => I18N::translate('Myanmar'),
2533
            /* I18N: Name of a country or state */
2534
            'MNG' => I18N::translate('Mongolia'),
2535
            /* I18N: Name of a country or state */
2536
            'MNP' => I18N::translate('Northern Mariana Islands'),
2537
            /* I18N: Name of a country or state */
2538
            'MNT' => I18N::translate('Montenegro'),
2539
            /* I18N: Name of a country or state */
2540
            'MOZ' => I18N::translate('Mozambique'),
2541
            /* I18N: Name of a country or state */
2542
            'MRT' => I18N::translate('Mauritania'),
2543
            /* I18N: Name of a country or state */
2544
            'MSR' => I18N::translate('Montserrat'),
2545
            /* I18N: Name of a country or state */
2546
            'MTQ' => I18N::translate('Martinique'),
2547
            /* I18N: Name of a country or state */
2548
            'MUS' => I18N::translate('Mauritius'),
2549
            /* I18N: Name of a country or state */
2550
            'MWI' => I18N::translate('Malawi'),
2551
            /* I18N: Name of a country or state */
2552
            'MYS' => I18N::translate('Malaysia'),
2553
            /* I18N: Name of a country or state */
2554
            'MYT' => I18N::translate('Mayotte'),
2555
            /* I18N: Name of a country or state */
2556
            'NAM' => I18N::translate('Namibia'),
2557
            /* I18N: Name of a country or state */
2558
            'NCL' => I18N::translate('New Caledonia'),
2559
            /* I18N: Name of a country or state */
2560
            'NER' => I18N::translate('Niger'),
2561
            /* I18N: Name of a country or state */
2562
            'NFK' => I18N::translate('Norfolk Island'),
2563
            /* I18N: Name of a country or state */
2564
            'NGA' => I18N::translate('Nigeria'),
2565
            /* I18N: Name of a country or state */
2566
            'NIC' => I18N::translate('Nicaragua'),
2567
            /* I18N: Name of a country or state */
2568
            'NIR' => I18N::translate('Northern Ireland'),
2569
            /* I18N: Name of a country or state */
2570
            'NIU' => I18N::translate('Niue'),
2571
            /* I18N: Name of a country or state */
2572
            'NLD' => I18N::translate('Netherlands'),
2573
            /* I18N: Name of a country or state */
2574
            'NOR' => I18N::translate('Norway'),
2575
            /* I18N: Name of a country or state */
2576
            'NPL' => I18N::translate('Nepal'),
2577
            /* I18N: Name of a country or state */
2578
            'NRU' => I18N::translate('Nauru'),
2579
            /* I18N: Name of a country or state */
2580
            'NZL' => I18N::translate('New Zealand'),
2581
            /* I18N: Name of a country or state */
2582
            'OMN' => I18N::translate('Oman'),
2583
            /* I18N: Name of a country or state */
2584
            'PAK' => I18N::translate('Pakistan'),
2585
            /* I18N: Name of a country or state */
2586
            'PAN' => I18N::translate('Panama'),
2587
            /* I18N: Name of a country or state */
2588
            'PCN' => I18N::translate('Pitcairn'),
2589
            /* I18N: Name of a country or state */
2590
            'PER' => I18N::translate('Peru'),
2591
            /* I18N: Name of a country or state */
2592
            'PHL' => I18N::translate('Philippines'),
2593
            /* I18N: Name of a country or state */
2594
            'PLW' => I18N::translate('Palau'),
2595
            /* I18N: Name of a country or state */
2596
            'PNG' => I18N::translate('Papua New Guinea'),
2597
            /* I18N: Name of a country or state */
2598
            'POL' => I18N::translate('Poland'),
2599
            /* I18N: Name of a country or state */
2600
            'PRI' => I18N::translate('Puerto Rico'),
2601
            /* I18N: Name of a country or state */
2602
            'PRK' => I18N::translate('North Korea'),
2603
            /* I18N: Name of a country or state */
2604
            'PRT' => I18N::translate('Portugal'),
2605
            /* I18N: Name of a country or state */
2606
            'PRY' => I18N::translate('Paraguay'),
2607
            /* I18N: Name of a country or state */
2608
            'PSE' => I18N::translate('Occupied Palestinian Territory'),
2609
            /* I18N: Name of a country or state */
2610
            'PYF' => I18N::translate('French Polynesia'),
2611
            /* I18N: Name of a country or state */
2612
            'QAT' => I18N::translate('Qatar'),
2613
            /* I18N: Name of a country or state */
2614
            'REU' => I18N::translate('Réunion'),
2615
            /* I18N: Name of a country or state */
2616
            'ROM' => I18N::translate('Romania'),
2617
            /* I18N: Name of a country or state */
2618
            'RUS' => I18N::translate('Russia'),
2619
            /* I18N: Name of a country or state */
2620
            'RWA' => I18N::translate('Rwanda'),
2621
            /* I18N: Name of a country or state */
2622
            'SAU' => I18N::translate('Saudi Arabia'),
2623
            /* I18N: Name of a country or state */
2624
            'SCT' => I18N::translate('Scotland'),
2625
            /* I18N: Name of a country or state */
2626
            'SDN' => I18N::translate('Sudan'),
2627
            /* I18N: Name of a country or state */
2628
            'SEA' => I18N::translate('At sea'),
2629
            /* I18N: Name of a country or state */
2630
            'SEN' => I18N::translate('Senegal'),
2631
            /* I18N: Name of a country or state */
2632
            'SER' => I18N::translate('Serbia'),
2633
            /* I18N: Name of a country or state */
2634
            'SGP' => I18N::translate('Singapore'),
2635
            /* I18N: Name of a country or state */
2636
            'SGS' => I18N::translate('South Georgia and the South Sandwich Islands'),
2637
            /* I18N: Name of a country or state */
2638
            'SHN' => I18N::translate('Saint Helena'),
2639
            /* I18N: Name of a country or state */
2640
            'SJM' => I18N::translate('Svalbard and Jan Mayen'),
2641
            /* I18N: Name of a country or state */
2642
            'SLB' => I18N::translate('Solomon Islands'),
2643
            /* I18N: Name of a country or state */
2644
            'SLE' => I18N::translate('Sierra Leone'),
2645
            /* I18N: Name of a country or state */
2646
            'SLV' => I18N::translate('El Salvador'),
2647
            /* I18N: Name of a country or state */
2648
            'SMR' => I18N::translate('San Marino'),
2649
            /* I18N: Name of a country or state */
2650
            'SOM' => I18N::translate('Somalia'),
2651
            /* I18N: Name of a country or state */
2652
            'SPM' => I18N::translate('Saint Pierre and Miquelon'),
2653
            /* I18N: Name of a country or state */
2654
            'SSD' => I18N::translate('South Sudan'),
2655
            /* I18N: Name of a country or state */
2656
            'STP' => I18N::translate('Sao Tome and Principe'),
2657
            /* I18N: Name of a country or state */
2658
            'SUR' => I18N::translate('Suriname'),
2659
            /* I18N: Name of a country or state */
2660
            'SVK' => I18N::translate('Slovakia'),
2661
            /* I18N: Name of a country or state */
2662
            'SVN' => I18N::translate('Slovenia'),
2663
            /* I18N: Name of a country or state */
2664
            'SWE' => I18N::translate('Sweden'),
2665
            /* I18N: Name of a country or state */
2666
            'SWZ' => I18N::translate('Swaziland'),
2667
            // SXM => Sint Maarten
2668
            /* I18N: Name of a country or state */
2669
            'SYC' => I18N::translate('Seychelles'),
2670
            /* I18N: Name of a country or state */
2671
            'SYR' => I18N::translate('Syria'),
2672
            /* I18N: Name of a country or state */
2673
            'TCA' => I18N::translate('Turks and Caicos Islands'),
2674
            /* I18N: Name of a country or state */
2675
            'TCD' => I18N::translate('Chad'),
2676
            /* I18N: Name of a country or state */
2677
            'TGO' => I18N::translate('Togo'),
2678
            /* I18N: Name of a country or state */
2679
            'THA' => I18N::translate('Thailand'),
2680
            /* I18N: Name of a country or state */
2681
            'TJK' => I18N::translate('Tajikistan'),
2682
            /* I18N: Name of a country or state */
2683
            'TKL' => I18N::translate('Tokelau'),
2684
            /* I18N: Name of a country or state */
2685
            'TKM' => I18N::translate('Turkmenistan'),
2686
            /* I18N: Name of a country or state */
2687
            'TLS' => I18N::translate('Timor-Leste'),
2688
            /* I18N: Name of a country or state */
2689
            'TON' => I18N::translate('Tonga'),
2690
            /* I18N: Name of a country or state */
2691
            'TTO' => I18N::translate('Trinidad and Tobago'),
2692
            /* I18N: Name of a country or state */
2693
            'TUN' => I18N::translate('Tunisia'),
2694
            /* I18N: Name of a country or state */
2695
            'TUR' => I18N::translate('Turkey'),
2696
            /* I18N: Name of a country or state */
2697
            'TUV' => I18N::translate('Tuvalu'),
2698
            /* I18N: Name of a country or state */
2699
            'TWN' => I18N::translate('Taiwan'),
2700
            /* I18N: Name of a country or state */
2701
            'TZA' => I18N::translate('Tanzania'),
2702
            /* I18N: Name of a country or state */
2703
            'UGA' => I18N::translate('Uganda'),
2704
            /* I18N: Name of a country or state */
2705
            'UKR' => I18N::translate('Ukraine'),
2706
            /* I18N: Name of a country or state */
2707
            'UMI' => I18N::translate('US Minor Outlying Islands'),
2708
            /* I18N: Name of a country or state */
2709
            'URY' => I18N::translate('Uruguay'),
2710
            /* I18N: Name of a country or state */
2711
            'USA' => I18N::translate('United States'),
2712
            /* I18N: Name of a country or state */
2713
            'UZB' => I18N::translate('Uzbekistan'),
2714
            /* I18N: Name of a country or state */
2715
            'VAT' => I18N::translate('Vatican City'),
2716
            /* I18N: Name of a country or state */
2717
            'VCT' => I18N::translate('Saint Vincent and the Grenadines'),
2718
            /* I18N: Name of a country or state */
2719
            'VEN' => I18N::translate('Venezuela'),
2720
            /* I18N: Name of a country or state */
2721
            'VGB' => I18N::translate('British Virgin Islands'),
2722
            /* I18N: Name of a country or state */
2723
            'VIR' => I18N::translate('US Virgin Islands'),
2724
            /* I18N: Name of a country or state */
2725
            'VNM' => I18N::translate('Vietnam'),
2726
            /* I18N: Name of a country or state */
2727
            'VUT' => I18N::translate('Vanuatu'),
2728
            /* I18N: Name of a country or state */
2729
            'WLF' => I18N::translate('Wallis and Futuna'),
2730
            /* I18N: Name of a country or state */
2731
            'WLS' => I18N::translate('Wales'),
2732
            /* I18N: Name of a country or state */
2733
            'WSM' => I18N::translate('Samoa'),
2734
            /* I18N: Name of a country or state */
2735
            'YEM' => I18N::translate('Yemen'),
2736
            /* I18N: Name of a country or state */
2737
            'ZAF' => I18N::translate('South Africa'),
2738
            /* I18N: Name of a country or state */
2739
            'ZMB' => I18N::translate('Zambia'),
2740
            /* I18N: Name of a country or state */
2741
            'ZWE' => I18N::translate('Zimbabwe'),
2742
        ];
2743
    }
2744
2745
    /**
2746
     * ISO3166 3 letter codes, with their 2 letter equivalent.
2747
     * NOTE: this is not 1:1. ENG/SCO/WAL/NIR => GB
2748
     * NOTE: this also includes chapman codes and others. Should it?
2749
     *
2750
     * @return array<string>
2751
     */
2752
    private function iso3166(): array
2753
    {
2754
        return [
2755
            'GBR' => 'GB', // Must come before ENG, NIR, SCT and WLS
2756
            'ABW' => 'AW',
2757
            'AFG' => 'AF',
2758
            'AGO' => 'AO',
2759
            'AIA' => 'AI',
2760
            'ALA' => 'AX',
2761
            'ALB' => 'AL',
2762
            'AND' => 'AD',
2763
            'ARE' => 'AE',
2764
            'ARG' => 'AR',
2765
            'ARM' => 'AM',
2766
            'ASM' => 'AS',
2767
            'ATA' => 'AQ',
2768
            'ATF' => 'TF',
2769
            'ATG' => 'AG',
2770
            'AUS' => 'AU',
2771
            'AUT' => 'AT',
2772
            'AZE' => 'AZ',
2773
            'BDI' => 'BI',
2774
            'BEL' => 'BE',
2775
            'BEN' => 'BJ',
2776
            'BFA' => 'BF',
2777
            'BGD' => 'BD',
2778
            'BGR' => 'BG',
2779
            'BHR' => 'BH',
2780
            'BHS' => 'BS',
2781
            'BIH' => 'BA',
2782
            'BLR' => 'BY',
2783
            'BLZ' => 'BZ',
2784
            'BMU' => 'BM',
2785
            'BOL' => 'BO',
2786
            'BRA' => 'BR',
2787
            'BRB' => 'BB',
2788
            'BRN' => 'BN',
2789
            'BTN' => 'BT',
2790
            'BVT' => 'BV',
2791
            'BWA' => 'BW',
2792
            'CAF' => 'CF',
2793
            'CAN' => 'CA',
2794
            'CCK' => 'CC',
2795
            'CHE' => 'CH',
2796
            'CHL' => 'CL',
2797
            'CHN' => 'CN',
2798
            'CIV' => 'CI',
2799
            'CMR' => 'CM',
2800
            'COD' => 'CD',
2801
            'COG' => 'CG',
2802
            'COK' => 'CK',
2803
            'COL' => 'CO',
2804
            'COM' => 'KM',
2805
            'CPV' => 'CV',
2806
            'CRI' => 'CR',
2807
            'CUB' => 'CU',
2808
            'CXR' => 'CX',
2809
            'CYM' => 'KY',
2810
            'CYP' => 'CY',
2811
            'CZE' => 'CZ',
2812
            'DEU' => 'DE',
2813
            'DJI' => 'DJ',
2814
            'DMA' => 'DM',
2815
            'DNK' => 'DK',
2816
            'DOM' => 'DO',
2817
            'DZA' => 'DZ',
2818
            'ECU' => 'EC',
2819
            'EGY' => 'EG',
2820
            'ENG' => 'GB',
2821
            'ERI' => 'ER',
2822
            'ESH' => 'EH',
2823
            'ESP' => 'ES',
2824
            'EST' => 'EE',
2825
            'ETH' => 'ET',
2826
            'FIN' => 'FI',
2827
            'FJI' => 'FJ',
2828
            'FLK' => 'FK',
2829
            'FRA' => 'FR',
2830
            'FRO' => 'FO',
2831
            'FSM' => 'FM',
2832
            'GAB' => 'GA',
2833
            'GEO' => 'GE',
2834
            'GHA' => 'GH',
2835
            'GIB' => 'GI',
2836
            'GIN' => 'GN',
2837
            'GLP' => 'GP',
2838
            'GMB' => 'GM',
2839
            'GNB' => 'GW',
2840
            'GNQ' => 'GQ',
2841
            'GRC' => 'GR',
2842
            'GRD' => 'GD',
2843
            'GRL' => 'GL',
2844
            'GTM' => 'GT',
2845
            'GUF' => 'GF',
2846
            'GUM' => 'GU',
2847
            'GUY' => 'GY',
2848
            'HKG' => 'HK',
2849
            'HMD' => 'HM',
2850
            'HND' => 'HN',
2851
            'HRV' => 'HR',
2852
            'HTI' => 'HT',
2853
            'HUN' => 'HU',
2854
            'IDN' => 'ID',
2855
            'IND' => 'IN',
2856
            'IOT' => 'IO',
2857
            'IRL' => 'IE',
2858
            'IRN' => 'IR',
2859
            'IRQ' => 'IQ',
2860
            'ISL' => 'IS',
2861
            'ISR' => 'IL',
2862
            'ITA' => 'IT',
2863
            'JAM' => 'JM',
2864
            'JOR' => 'JO',
2865
            'JPN' => 'JP',
2866
            'KAZ' => 'KZ',
2867
            'KEN' => 'KE',
2868
            'KGZ' => 'KG',
2869
            'KHM' => 'KH',
2870
            'KIR' => 'KI',
2871
            'KNA' => 'KN',
2872
            'KOR' => 'KO',
2873
            'KWT' => 'KW',
2874
            'LAO' => 'LA',
2875
            'LBN' => 'LB',
2876
            'LBR' => 'LR',
2877
            'LBY' => 'LY',
2878
            'LCA' => 'LC',
2879
            'LIE' => 'LI',
2880
            'LKA' => 'LK',
2881
            'LSO' => 'LS',
2882
            'LTU' => 'LT',
2883
            'LUX' => 'LU',
2884
            'LVA' => 'LV',
2885
            'MAC' => 'MO',
2886
            'MAR' => 'MA',
2887
            'MCO' => 'MC',
2888
            'MDA' => 'MD',
2889
            'MDG' => 'MG',
2890
            'MDV' => 'MV',
2891
            'MEX' => 'MX',
2892
            'MHL' => 'MH',
2893
            'MKD' => 'MK',
2894
            'MLI' => 'ML',
2895
            'MLT' => 'MT',
2896
            'MMR' => 'MM',
2897
            'MNG' => 'MN',
2898
            'MNP' => 'MP',
2899
            'MNT' => 'ME',
2900
            'MOZ' => 'MZ',
2901
            'MRT' => 'MR',
2902
            'MSR' => 'MS',
2903
            'MTQ' => 'MQ',
2904
            'MUS' => 'MU',
2905
            'MWI' => 'MW',
2906
            'MYS' => 'MY',
2907
            'MYT' => 'YT',
2908
            'NAM' => 'NA',
2909
            'NCL' => 'NC',
2910
            'NER' => 'NE',
2911
            'NFK' => 'NF',
2912
            'NGA' => 'NG',
2913
            'NIC' => 'NI',
2914
            'NIR' => 'GB',
2915
            'NIU' => 'NU',
2916
            'NLD' => 'NL',
2917
            'NOR' => 'NO',
2918
            'NPL' => 'NP',
2919
            'NRU' => 'NR',
2920
            'NZL' => 'NZ',
2921
            'OMN' => 'OM',
2922
            'PAK' => 'PK',
2923
            'PAN' => 'PA',
2924
            'PCN' => 'PN',
2925
            'PER' => 'PE',
2926
            'PHL' => 'PH',
2927
            'PLW' => 'PW',
2928
            'PNG' => 'PG',
2929
            'POL' => 'PL',
2930
            'PRI' => 'PR',
2931
            'PRK' => 'KP',
2932
            'PRT' => 'PT',
2933
            'PRY' => 'PY',
2934
            'PSE' => 'PS',
2935
            'PYF' => 'PF',
2936
            'QAT' => 'QA',
2937
            'REU' => 'RE',
2938
            'ROM' => 'RO',
2939
            'RUS' => 'RU',
2940
            'RWA' => 'RW',
2941
            'SAU' => 'SA',
2942
            'SCT' => 'GB',
2943
            'SDN' => 'SD',
2944
            'SEN' => 'SN',
2945
            'SER' => 'RS',
2946
            'SGP' => 'SG',
2947
            'SGS' => 'GS',
2948
            'SHN' => 'SH',
2949
            'SJM' => 'SJ',
2950
            'SLB' => 'SB',
2951
            'SLE' => 'SL',
2952
            'SLV' => 'SV',
2953
            'SMR' => 'SM',
2954
            'SOM' => 'SO',
2955
            'SPM' => 'PM',
2956
            'STP' => 'ST',
2957
            'SUR' => 'SR',
2958
            'SVK' => 'SK',
2959
            'SVN' => 'SI',
2960
            'SWE' => 'SE',
2961
            'SWZ' => 'SZ',
2962
            'SYC' => 'SC',
2963
            'SYR' => 'SY',
2964
            'TCA' => 'TC',
2965
            'TCD' => 'TD',
2966
            'TGO' => 'TG',
2967
            'THA' => 'TH',
2968
            'TJK' => 'TJ',
2969
            'TKL' => 'TK',
2970
            'TKM' => 'TM',
2971
            'TLS' => 'TL',
2972
            'TON' => 'TO',
2973
            'TTO' => 'TT',
2974
            'TUN' => 'TN',
2975
            'TUR' => 'TR',
2976
            'TUV' => 'TV',
2977
            'TWN' => 'TW',
2978
            'TZA' => 'TZ',
2979
            'UGA' => 'UG',
2980
            'UKR' => 'UA',
2981
            'UMI' => 'UM',
2982
            'URY' => 'UY',
2983
            'USA' => 'US',
2984
            'UZB' => 'UZ',
2985
            'VAT' => 'VA',
2986
            'VCT' => 'VC',
2987
            'VEN' => 'VE',
2988
            'VGB' => 'VG',
2989
            'VIR' => 'VI',
2990
            'VNM' => 'VN',
2991
            'VUT' => 'VU',
2992
            'WLF' => 'WF',
2993
            'WLS' => 'GB',
2994
            'WSM' => 'WS',
2995
            'YEM' => 'YE',
2996
            'ZAF' => 'ZA',
2997
            'ZMB' => 'ZM',
2998
            'ZWE' => 'ZW',
2999
        ];
3000
    }
3001
3002
    /**
3003
     * Returns the translated country name based on the given two letter country code.
3004
     */
3005
    private function mapTwoLetterToName(string $twoLetterCode): string
3006
    {
3007
        $threeLetterCode = array_search($twoLetterCode, $this->iso3166(), true);
3008
        $threeLetterCode = $threeLetterCode ?: '???';
3009
3010
        return $this->getAllCountries()[$threeLetterCode];
3011
    }
3012
}
3013