StatisticsData::birthAndDeathQuery()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 24
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 17
nc 2
nop 1
dl 0
loc 24
rs 9.7
c 1
b 0
f 0
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;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Contracts\UserInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Services\MessageService was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
29
use Fisharebest\Webtrees\Services\ModuleService;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Services\ModuleService was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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')
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\DB was not found. Did you mean DB? If so, make sure to prefix the type with \.
Loading history...
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')])
0 ignored issues
show
Bug introduced by
'AVG(' . Fisharebest\Web...ianday1') . ') AS days' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

76
            ->select([new Expression(/** @scrutinizer ignore-type */ 'AVG(' . DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ') AS days')])
Loading history...
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)
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Individual was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
89
            ->where(new Expression('LENGTH(n_givn)'), '>', 1);
0 ignored issues
show
Bug introduced by
'LENGTH(n_givn)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

89
            ->where(new Expression(/** @scrutinizer ignore-type */ 'LENGTH(n_givn)'), '>', 1);
Loading history...
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)'), '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')
0 ignored issues
show
Bug introduced by
'COUNT(n_surn)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

139
            ->orderBy(new Expression(/** @scrutinizer ignore-type */ 'COUNT(n_surn)'), 'DESC')
Loading history...
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());
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\I18N was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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(*)'))
0 ignored issues
show
Bug introduced by
'COUNT(*)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

225
            ->orderByDesc(new Expression(/** @scrutinizer ignore-type */ 'COUNT(*)'))
Loading history...
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(*)'), 'd_month')
0 ignored issues
show
Bug introduced by
'COUNT(*)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

260
            ->pluck(new Expression(/** @scrutinizer ignore-type */ 'COUNT(*)'), 'd_month')
Loading history...
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')])
0 ignored issues
show
Bug introduced by
'COUNT(*) AS total' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

277
            ->select(['d_month', 'i_sex', new Expression(/** @scrutinizer ignore-type */ 'COUNT(*) AS total')])
Loading history...
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')])
0 ignored issues
show
Bug introduced by
'ROUND((d_year + 49) / 100, 0) AS century' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

293
            ->select([new Expression(/** @scrutinizer ignore-type */ 'ROUND((d_year + 49) / 100, 0) AS century'), new Expression('COUNT(*) AS total')])
Loading history...
294
            ->groupBy(['century'])
295
            ->orderBy('century')
0 ignored issues
show
Bug introduced by
'century' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

295
            ->orderBy(/** @scrutinizer ignore-type */ 'century')
Loading history...
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(*)'), 'd_month')
0 ignored issues
show
Bug introduced by
'COUNT(*)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

353
            ->pluck(new Expression(/** @scrutinizer ignore-type */ 'COUNT(*)'), 'd_month')
Loading history...
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')])
0 ignored issues
show
Bug introduced by
'COUNT(*) AS total' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

370
            ->select(['d_month', 'i_sex', new Expression(/** @scrutinizer ignore-type */ 'COUNT(*) AS total')])
Loading history...
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')])
0 ignored issues
show
Bug introduced by
'MIN(d_julianday1) AS min_birth_jd' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

392
            ->select(['l_from AS family_id', new Expression(/** @scrutinizer ignore-type */ 'MIN(d_julianday1) AS min_birth_jd')])
Loading history...
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
            ->count();
552
    }
553
554
    public function countIndividualsWithSources(): int
555
    {
556
        return DB::table('individuals')
557
            ->select(['i_id'])
558
            ->distinct()
559
            ->join('link', static function (JoinClause $join): void {
560
                $join->on('i_id', '=', 'l_from')
561
                    ->on('i_file', '=', 'l_file');
562
            })
563
            ->where('l_file', '=', $this->tree->id())
564
            ->where('l_type', '=', 'SOUR')
565
            ->count('i_id');
566
    }
567
568
    public function countMarriedFemales(): int
569
    {
570
        return DB::table('families')
571
            ->where('f_file', '=', $this->tree->id())
572
            ->where('f_gedcom', 'LIKE', "%\n1 MARR%")
573
            ->distinct()
574
            ->count('f_wife');
575
    }
576
577
    public function countMarriedMales(): int
578
    {
579
        return DB::table('families')
580
            ->where('f_file', '=', $this->tree->id())
581
            ->where('f_gedcom', 'LIKE', "%\n1 MARR%")
582
            ->distinct()
583
            ->count('f_husb');
584
    }
585
586
    public function countMedia(string $type = 'all'): int
587
    {
588
        $query = DB::table('media_file')
589
            ->where('m_file', '=', $this->tree->id());
590
591
        if ($type !== 'all') {
592
            $query->where('source_media_type', '=', $type);
593
        }
594
595
        return $query->count();
596
    }
597
598
    /**
599
     * @return array<array{0:string,1:int}>
600
     */
601
    public function countMediaByType(): array
602
    {
603
        $element = Registry::elementFactory()->make('OBJE:FILE:FORM:TYPE');
604
        $values  = $element->values();
605
606
        return DB::table('media_file')
607
            ->where('m_file', '=', $this->tree->id())
608
            ->groupBy('source_media_type')
609
            ->select([new Expression('COUNT(*) AS total'), 'source_media_type'])
0 ignored issues
show
Bug introduced by
'COUNT(*) AS total' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

609
            ->select([new Expression(/** @scrutinizer ignore-type */ 'COUNT(*) AS total'), 'source_media_type'])
Loading history...
610
            ->get()
611
            ->map(static fn (object $row): array => [
612
                $values[$element->canonical($row->source_media_type)] ?? I18N::translate('Other'),
613
                (int) $row->total,
614
            ])
615
            ->all();
616
    }
617
618
    public function countNotes(): int
619
    {
620
        return DB::table('other')
621
            ->where('o_file', '=', $this->tree->id())
622
            ->where('o_type', '=', 'NOTE')
623
            ->count();
624
    }
625
626
    /**
627
     * @param array<string> $events
628
     */
629
    public function countOtherEvents(array $events): int
630
    {
631
        return DB::table('dates')
632
            ->where('d_file', '=', $this->tree->id())
633
            ->whereNotIn('d_fact', $events)
634
            ->count();
635
    }
636
637
    /**
638
     * @param array<string> $rows
639
     *
640
     * @return array<array{place:Place,count:int}>
641
     */
642
    private function countPlaces(array $rows, string $event, int $limit): array
643
    {
644
        $places = [];
645
646
        foreach ($rows as $gedcom) {
647
            if (preg_match('/\n1 ' . $event . '(?:\n[2-9].*)*\n2 PLAC (.+)/', $gedcom, $match) === 1) {
648
                $places[$match[1]] ??= 0;
649
                $places[$match[1]]++;
650
            }
651
        }
652
653
        arsort($places);
654
655
        $records = [];
656
657
        foreach (array_slice($places, 0, $limit) as $place => $count) {
658
            $records[] = [
659
                'place' => new Place((string) $place, $this->tree),
660
                'count' => $count,
661
            ];
662
        }
663
664
        return $records;
665
    }
666
667
    /**
668
     * @return array<array{place:Place,count:int}>
669
     */
670
    public function countPlacesForFamilies(string $event, int $limit): array
671
    {
672
        $rows = DB::table('families')
673
            ->where('f_file', '=', $this->tree->id())
674
            ->where('f_gedcom', 'LIKE', "%\n2 PLAC %")
675
            ->pluck('f_gedcom')
676
            ->all();
677
678
        return $this->countPlaces($rows, $event, $limit);
679
    }
680
681
    /**
682
     * @return array<array{place:Place,count:int}>
683
     */
684
    public function countPlacesForIndividuals(string $event, int $limit): array
685
    {
686
        $rows = DB::table('individuals')
687
            ->where('i_file', '=', $this->tree->id())
688
            ->where('i_gedcom', 'LIKE', "%\n2 PLAC %")
689
            ->pluck('i_gedcom')
690
            ->all();
691
692
        return $this->countPlaces($rows, $event, $limit);
693
    }
694
695
    public function countRepositories(): int
696
    {
697
        return DB::table('other')
698
            ->where('o_file', '=', $this->tree->id())
699
            ->where('o_type', '=', 'REPO')
700
            ->count();
701
    }
702
703
    public function countSources(): int
704
    {
705
        return DB::table('sources')
706
            ->where('s_file', '=', $this->tree->id())
707
            ->count();
708
    }
709
710
    /**
711
     * @param array<string> $names
712
     */
713
    public function countSurnames(array $names): int
714
    {
715
        if ($names === []) {
716
            // Count number of distinct surnames
717
            return DB::table('name')
718
                ->where('n_file', '=', $this->tree->id())->distinct()
719
                ->whereNotNull('n_surn')
720
                ->count('n_surn');
721
        }
722
723
        // Count number of occurrences of specific surnames.
724
        return DB::table('name')
725
            ->where('n_file', '=', $this->tree->id())
726
            ->whereIn('n_surn', $names)
727
            ->count('n_surn');
728
    }
729
730
    public function countTreeFavorites(): int
731
    {
732
        return DB::table('favorite')
733
            ->where('gedcom_id', '=', $this->tree->id())
734
            ->count();
735
    }
736
737
    public function countTreeNews(): int
738
    {
739
        return DB::table('news')
740
            ->where('gedcom_id', '=', $this->tree->id())
741
            ->count();
742
    }
743
744
    public function countUserfavorites(): int
745
    {
746
        return DB::table('favorite')
747
            ->where('user_id', '=', Auth::id())
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Auth was not found. Did you mean Auth? If so, make sure to prefix the type with \.
Loading history...
748
            ->count();
749
    }
750
751
    public function countUserJournal(): int
752
    {
753
        return DB::table('news')
754
            ->where('user_id', '=', Auth::id())
755
            ->count();
756
    }
757
758
    public function countUserMessages(): int
759
    {
760
        return DB::table('message')
761
            ->where('user_id', '=', Auth::id())
762
            ->count();
763
    }
764
765
    /**
766
     * @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...
767
     */
768
    public function familiesWithTheMostChildren(int $limit): array
769
    {
770
        return DB::table('families')
771
            ->where('f_file', '=', $this->tree->id())
772
            ->orderByDesc('f_numchil')
773
            ->limit($limit)
774
            ->get()
775
            ->map(fn (object $row): object => (object) [
776
                'family'   => Registry::familyFactory()->make($row->f_id, $this->tree, $row->f_gedcom),
777
                'children' => (int) $row->f_numchil,
778
            ])
779
            ->all();
780
    }
781
782
    /**
783
     * @param array<string> $events
784
     *
785
     * @return object{id:string,year:int,fact:string,type:string}|null
786
     */
787
    private function firstEvent(array $events, bool $ascending): object|null
788
    {
789
        if ($events === []) {
790
            $events = [
791
                ...Gedcom::BIRTH_EVENTS,
792
                ...Gedcom::DEATH_EVENTS,
793
                ...Gedcom::MARRIAGE_EVENTS,
794
                ...Gedcom::DIVORCE_EVENTS,
795
            ];
796
        }
797
798
        return DB::table('dates')
799
            ->select(['d_gid as id', 'd_year as year', 'd_fact AS fact', 'd_type AS type'])
800
            ->where('d_file', '=', $this->tree->id())
801
            ->whereIn('d_fact', $events)
802
            ->where('d_julianday1', '<>', 0)
803
            ->orderBy('d_julianday1', $ascending ? 'ASC' : 'DESC')
804
            ->limit(1)
805
            ->get()
806
            ->map(static fn (object $row): object => (object) [
807
                'id'   => $row->id,
808
                'year' => (int) $row->year,
809
                'fact' => $row->fact,
810
                'type' => $row->type,
811
            ])
812
            ->first();
813
    }
814
815
    /**
816
     * @param array<string> $events
817
     */
818
    public function firstEventName(array $events, bool $ascending): string
819
    {
820
        $row = $this->firstEvent($events, $ascending);
821
822
        if ($row !== null) {
823
            $record = Registry::gedcomRecordFactory()->make($row->id, $this->tree);
824
825
            if ($record instanceof GedcomRecord) {
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\GedcomRecord was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
826
                return '<a href="' . e($record->url()) . '">' . $record->fullName() . '</a>';
827
            }
828
        }
829
830
        return '';
831
    }
832
833
    /**
834
     * @param array<string> $events
835
     */
836
    public function firstEventPlace(array $events, bool $ascending): string
837
    {
838
        $row = $this->firstEvent($events, $ascending);
839
840
        if ($row !== null) {
841
            $record = Registry::gedcomRecordFactory()->make($row->id, $this->tree);
842
            $fact   = null;
843
844
            if ($record instanceof GedcomRecord) {
845
                $fact = $record->facts([$row->fact])->first();
846
            }
847
848
            if ($fact instanceof Fact) {
849
                return $fact->place()->shortName();
850
            }
851
        }
852
853
        return I18N::translate('Private');
854
    }
855
856
    /**
857
     * @param array<string> $events
858
     */
859
    public function firstEventRecord(array $events, bool $ascending): string
860
    {
861
        $row = $this->firstEvent($events, $ascending);
862
        $result = I18N::translate('This information is not available.');
863
864
        if ($row !== null) {
865
            $record = Registry::gedcomRecordFactory()->make($row->id, $this->tree);
866
867
            if ($record instanceof GedcomRecord && $record->canShow()) {
868
                $result = $record->formatList();
869
            } else {
870
                $result = I18N::translate('This information is private and cannot be shown.');
871
            }
872
        }
873
874
        return $result;
875
    }
876
877
    /**
878
     * @param array<string> $events
879
     */
880
    public function firstEventType(array $events, bool $ascending): string
881
    {
882
        $row = $this->firstEvent($events, $ascending);
883
884
        if ($row === null) {
885
            return '';
886
        }
887
888
        foreach ([Individual::RECORD_TYPE, Family::RECORD_TYPE] as $record_type) {
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Family was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
889
            $element = Registry::elementFactory()->make($record_type . ':' . $row->fact);
890
891
            if (!$element instanceof UnknownElement) {
892
                return $element->label();
893
            }
894
        }
895
896
        return $row->fact;
897
    }
898
899
    /**
900
     * @param array<string> $events
901
     */
902
    public function firstEventYear(array $events, bool $ascending): string
903
    {
904
        $row = $this->firstEvent($events, $ascending);
905
906
        if ($row === null) {
907
            return '-';
908
        }
909
910
        if ($row->year < 0) {
911
            $date = new Date($row->type . ' ' . abs($row->year) . ' B.C.');
912
        } else {
913
            $date = new Date($row->type . ' ' . $row->year);
914
        }
915
916
        return $date->display();
917
    }
918
919
    public function isUserLoggedIn(int|null $user_id): bool
920
    {
921
        return $user_id !== null && DB::table('session')
922
            ->where('user_id', '=', $user_id)
923
            ->exists();
924
    }
925
926
    public function latestUserId(): int|null
927
    {
928
        $user_id = DB::table('user')
929
            ->select(['user.user_id'])
930
            ->leftJoin('user_setting', 'user.user_id', '=', 'user_setting.user_id')
931
            ->where('setting_name', '=', UserInterface::PREF_TIMESTAMP_REGISTERED)
932
            ->orderByDesc('setting_value')
933
            ->value('user_id');
934
935
        if ($user_id === null) {
936
            return null;
937
        }
938
939
        return (int) $user_id;
940
    }
941
942
    /**
943
     * @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...
944
     */
945
    public function maximumAgeBetweenSiblings(int $limit): array
946
    {
947
        return DB::table('link AS link1')
948
            ->join('link AS link2', static function (JoinClause $join): void {
949
                $join
950
                    ->on('link2.l_from', '=', 'link1.l_from')
951
                    ->on('link2.l_type', '=', 'link1.l_type')
952
                    ->on('link2.l_file', '=', 'link1.l_file');
953
            })
954
            ->join('dates AS child1', static function (JoinClause $join): void {
955
                $join
956
                    ->on('child1.d_gid', '=', 'link1.l_to')
957
                    ->on('child1.d_file', '=', 'link1.l_file')
958
                    ->where('child1.d_fact', '=', 'BIRT')
959
                    ->where('child1.d_julianday1', '<>', 0);
960
            })
961
            ->join('dates AS child2', static function (JoinClause $join): void {
962
                $join
963
                    ->on('child2.d_gid', '=', 'link2.l_to')
964
                    ->on('child2.d_file', '=', 'link2.l_file')
965
                    ->where('child2.d_fact', '=', 'BIRT')
966
                    ->whereColumn('child2.d_julianday2', '>', 'child1.d_julianday1');
967
            })
968
            ->where('link1.l_type', '=', 'CHIL')
969
            ->where('link1.l_file', '=', $this->tree->id())
970
            ->distinct()
971
            ->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')])
0 ignored issues
show
Bug introduced by
Fisharebest\Webtrees\DB:...ulianday1') . ' AS age' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

971
            ->select(['link1.l_from AS family', 'link1.l_to AS child1', 'link2.l_to AS child2', new Expression(/** @scrutinizer ignore-type */ DB::prefix('child2.d_julianday2') . ' - ' . DB::prefix('child1.d_julianday1') . ' AS age')])
Loading history...
972
            ->orderBy('age', 'DESC')
973
            ->take($limit)
974
            ->get()
975
            ->map(fn (object $row): object => (object) [
976
                'family' => Registry::familyFactory()->make($row->family, $this->tree),
977
                'child1' => Registry::individualFactory()->make($row->child1, $this->tree),
978
                'child2' => Registry::individualFactory()->make($row->child2, $this->tree),
979
                'age'    => $this->calculateAge((int) $row->age),
980
            ])
981
            ->filter(static fn (object $row): bool => $row->family !== null)
982
            ->filter(static fn (object $row): bool => $row->child1 !== null)
983
            ->filter(static fn (object $row): bool => $row->child2 !== null)
984
            ->all();
985
    }
986
987
    /**
988
     * @return Collection<int,Individual>
989
     */
990
    public function topTenOldestAliveQuery(string $sex, int $limit): Collection
991
    {
992
        $query = DB::table('dates')
993
            ->join('individuals', static function (JoinClause $join): void {
994
                $join
995
                    ->on('i_id', '=', 'd_gid')
996
                    ->on('i_file', '=', 'd_file');
997
            })
998
            ->where('d_file', '=', $this->tree->id())
999
            ->where('d_julianday1', '<>', 0)
1000
            ->where('d_fact', '=', 'BIRT')
1001
            ->where('i_gedcom', 'NOT LIKE', "%\n1 DEAT%")
1002
            ->where('i_gedcom', 'NOT LIKE', "%\n1 BURI%")
1003
            ->where('i_gedcom', 'NOT LIKE', "%\n1 CREM%");
1004
1005
        if ($sex === 'F' || $sex === 'M' || $sex === 'U' || $sex === 'X') {
1006
            $query->where('i_sex', '=', $sex);
1007
        }
1008
1009
        return $query
1010
            ->groupBy(['i_id', 'i_file'])
1011
            ->orderBy(new Expression('MIN(d_julianday1)'))
0 ignored issues
show
Bug introduced by
'MIN(d_julianday1)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1011
            ->orderBy(new Expression(/** @scrutinizer ignore-type */ 'MIN(d_julianday1)'))
Loading history...
1012
            ->select(['individuals.*'])
1013
            ->take($limit)
1014
            ->get()
1015
            ->map(Registry::individualFactory()->mapper($this->tree))
1016
            ->filter(GedcomRecord::accessFilter());
1017
    }
1018
1019
    public function commonSurnamesQuery(string $type, bool $totals, int $threshold, int $limit, string $sort): string
1020
    {
1021
        $surnames = $this->commonSurnames($limit, $threshold, $sort);
1022
1023
        // find a module providing individual lists
1024
        $module = Registry::container()->get(ModuleService::class)
1025
            ->findByComponent(ModuleListInterface::class, $this->tree, Auth::user())
1026
            ->first(static fn (ModuleInterface $module): bool => $module instanceof IndividualListModule);
1027
1028
        if ($type === 'list') {
1029
            return view('lists/surnames-bullet-list', [
1030
                'surnames' => $surnames,
1031
                'module'   => $module,
1032
                'totals'   => $totals,
1033
                'tree'     => $this->tree,
1034
            ]);
1035
        }
1036
1037
        return view('lists/surnames-compact-list', [
1038
            'surnames' => $surnames,
1039
            'module'   => $module,
1040
            'totals'   => $totals,
1041
            'tree'     => $this->tree,
1042
        ]);
1043
    }
1044
1045
    /**
1046
     * @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...
1047
     */
1048
    public function statsAge(): array
1049
    {
1050
        return DB::table('individuals')
1051
            ->select([
1052
                new Expression('AVG(' . DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ') / 365.25 AS age'),
0 ignored issues
show
Bug introduced by
'AVG(' . Fisharebest\Web...) . ') / 365.25 AS age' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1052
                new Expression(/** @scrutinizer ignore-type */ 'AVG(' . DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ') / 365.25 AS age'),
Loading history...
1053
                new Expression('ROUND((' . DB::prefix('death.d_year') . ' + 49) / 100, 0) AS century'),
1054
                'i_sex AS sex'
1055
            ])
1056
            ->join('dates AS birth', static function (JoinClause $join): void {
1057
                $join
1058
                    ->on('birth.d_file', '=', 'i_file')
1059
                    ->on('birth.d_gid', '=', 'i_id');
1060
            })
1061
            ->join('dates AS death', static function (JoinClause $join): void {
1062
                $join
1063
                    ->on('death.d_file', '=', 'i_file')
1064
                    ->on('death.d_gid', '=', 'i_id');
1065
            })
1066
            ->where('i_file', '=', $this->tree->id())
1067
            ->where('birth.d_fact', '=', 'BIRT')
1068
            ->where('death.d_fact', '=', 'DEAT')
1069
            ->whereIn('birth.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1070
            ->whereIn('death.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1071
            ->whereColumn('death.d_julianday1', '>=', 'birth.d_julianday2')
1072
            ->where('birth.d_julianday2', '<>', 0)
1073
            ->groupBy(['century', 'sex'])
1074
            ->orderBy('century')
1075
            ->orderBy('sex')
1076
            ->get()
1077
            ->map(static fn (object $row): object => (object) [
1078
                'age'     => (float) $row->age,
1079
                'century' => (int) $row->century,
1080
                'sex'     => $row->sex,
1081
            ])
1082
            ->all();
1083
    }
1084
1085
    /**
1086
     * General query on ages.
1087
     *
1088
     * @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...
1089
     */
1090
    public function statsAgeQuery(string $sex, int $year1, int $year2): array
1091
    {
1092
        $query = $this->birthAndDeathQuery($sex);
1093
1094
        if ($year1 !== 0 && $year2 !== 0) {
1095
            $query
1096
                ->whereIn('birth.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1097
                ->whereIn('death.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1098
                ->whereBetween('death.d_year', [$year1, $year2]);
1099
        }
1100
1101
        return $query
1102
            ->select([new Expression(DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS days')])
0 ignored issues
show
Bug introduced by
Fisharebest\Webtrees\DB:...lianday1') . ' AS days' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1102
            ->select([new Expression(/** @scrutinizer ignore-type */ DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS days')])
Loading history...
1103
            ->orderBy('days', 'desc')
0 ignored issues
show
Bug introduced by
'days' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1103
            ->orderBy(/** @scrutinizer ignore-type */ 'days', 'desc')
Loading history...
1104
            ->get()
1105
            ->map(static fn (object $row): object => (object) ['days' => (int) $row->days])
1106
            ->all();
1107
    }
1108
1109
    private function birthAndDeathQuery(string $sex): Builder
1110
    {
1111
        $query = DB::table('individuals')
1112
            ->where('i_file', '=', $this->tree->id())
1113
            ->join('dates AS birth', static function (JoinClause $join): void {
1114
                $join
1115
                    ->on('birth.d_file', '=', 'i_file')
1116
                    ->on('birth.d_gid', '=', 'i_id');
1117
            })
1118
            ->join('dates AS death', static function (JoinClause $join): void {
1119
                $join
1120
                    ->on('death.d_file', '=', 'i_file')
1121
                    ->on('death.d_gid', '=', 'i_id');
1122
            })
1123
            ->where('birth.d_fact', '=', 'BIRT')
1124
            ->where('death.d_fact', '=', 'DEAT')
1125
            ->whereColumn('death.d_julianday1', '>=', 'birth.d_julianday2')
1126
            ->where('birth.d_julianday2', '<>', 0);
1127
1128
        if ($sex !== 'ALL') {
1129
            $query->where('i_sex', '=', $sex);
1130
        }
1131
1132
        return $query;
1133
    }
1134
1135
    /**
1136
     * @return object{individual:Individual,days:int}|null
1137
     */
1138
    public function longlifeQuery(string $sex): object|null
1139
    {
1140
        return $this->birthAndDeathQuery($sex)
1141
            ->orderBy('days', 'desc')
0 ignored issues
show
Bug introduced by
'days' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1141
            ->orderBy(/** @scrutinizer ignore-type */ 'days', 'desc')
Loading history...
1142
            ->select(['individuals.*', new Expression(DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS days')])
0 ignored issues
show
Bug introduced by
Fisharebest\Webtrees\DB:...lianday1') . ' AS days' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1142
            ->select(['individuals.*', new Expression(/** @scrutinizer ignore-type */ DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS days')])
Loading history...
1143
            ->take(1)
1144
            ->get()
1145
            ->map(fn (object $row): object => (object) [
1146
                'individual' => Registry::individualFactory()->mapper($this->tree)($row),
1147
                'days'       => (int) $row->days
1148
            ])
1149
            ->first();
1150
    }
1151
1152
    /**
1153
     * @return Collection<int,object{individual:Individual,days:int}>
1154
     */
1155
    public function topTenOldestQuery(string $sex, int $limit): Collection
1156
    {
1157
        return $this->birthAndDeathQuery($sex)
1158
            ->groupBy(['i_id', 'i_file'])
1159
            ->orderBy('days', 'desc')
0 ignored issues
show
Bug introduced by
'days' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1159
            ->orderBy(/** @scrutinizer ignore-type */ 'days', 'desc')
Loading history...
1160
            ->select(['individuals.*', new Expression('MAX(' . DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ') AS days')])
0 ignored issues
show
Bug introduced by
'MAX(' . Fisharebest\Web...ianday1') . ') AS days' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1160
            ->select(['individuals.*', new Expression(/** @scrutinizer ignore-type */ 'MAX(' . DB::prefix('death.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ') AS days')])
Loading history...
1161
            ->take($limit)
1162
            ->get()
1163
            ->map(fn (object $row): object => (object) [
1164
                'individual' => Registry::individualFactory()->mapper($this->tree)($row),
1165
                'days'       => (int) $row->days
1166
            ]);
1167
    }
1168
1169
    /**
1170
     * @return array<string>
1171
     */
1172
    private function getIso3166Countries(): array
1173
    {
1174
        // Get the country names for each language
1175
        $country_to_iso3166 = [];
1176
1177
        $current_language = I18N::languageTag();
1178
1179
        foreach (I18N::activeLocales() as $locale) {
1180
            I18N::init($locale->languageTag());
1181
1182
            $countries = $this->getAllCountries();
1183
1184
            foreach ($this->iso3166() as $three => $two) {
1185
                $country_to_iso3166[$three]             = $two;
1186
                $country_to_iso3166[$countries[$three]] = $two;
1187
            }
1188
        }
1189
1190
        I18N::init($current_language);
1191
1192
        return $country_to_iso3166;
1193
    }
1194
1195
    /**
1196
     * Returns the data structure required by google geochart.
1197
     *
1198
     * @param array<int> $places
1199
     *
1200
     * @return array<int,array<int|string|array<string,string>>>
1201
     */
1202
    private function createChartData(array $places): array
1203
    {
1204
        $data = [
1205
            [
1206
                I18N::translate('Country'),
1207
                I18N::translate('Total'),
1208
            ],
1209
        ];
1210
1211
        // webtrees uses 3-letter country codes and localised country names, but google uses 2 letter codes.
1212
        foreach ($places as $country => $count) {
1213
            $data[] = [
1214
                [
1215
                    'v' => $country,
1216
                    'f' => $this->mapTwoLetterToName($country),
1217
                ],
1218
                $count
1219
            ];
1220
        }
1221
1222
        return $data;
1223
    }
1224
1225
    /**
1226
     * @return array<string,int>
1227
     */
1228
    private function countIndividualsByCountry(Tree $tree): array
1229
    {
1230
        $rows = DB::table('places')
1231
            ->where('p_file', '=', $tree->id())
1232
            ->where('p_parent_id', '=', 0)
1233
            ->join('placelinks', static function (JoinClause $join): void {
1234
                $join
1235
                    ->on('pl_file', '=', 'p_file')
1236
                    ->on('pl_p_id', '=', 'p_id');
1237
            })
1238
            ->join('individuals', static function (JoinClause $join): void {
1239
                $join
1240
                    ->on('pl_file', '=', 'i_file')
1241
                    ->on('pl_gid', '=', 'i_id');
1242
            })
1243
            ->groupBy('p_place')
1244
            ->pluck(new Expression('COUNT(*)'), 'p_place')
0 ignored issues
show
Bug introduced by
'COUNT(*)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1244
            ->pluck(new Expression(/** @scrutinizer ignore-type */ 'COUNT(*)'), 'p_place')
Loading history...
1245
            ->all();
1246
1247
        $totals = [];
1248
1249
        $country_to_iso3166 = $this->getIso3166Countries();
1250
1251
        foreach ($rows as $country => $count) {
1252
            $country_code = $country_to_iso3166[$country] ?? null;
1253
1254
            if ($country_code !== null) {
1255
                $totals[$country_code] ??= 0;
1256
                $totals[$country_code] += $count;
1257
            }
1258
        }
1259
1260
        return $totals;
1261
    }
1262
1263
    /**
1264
     * @return array<string,int>
1265
     */
1266
    private function countSurnamesByCountry(Tree $tree, string $surname): array
1267
    {
1268
        $rows =
1269
            DB::table('places')
1270
                ->where('p_file', '=', $tree->id())
1271
                ->where('p_parent_id', '=', 0)
1272
                ->join('placelinks', static function (JoinClause $join): void {
1273
                    $join
1274
                        ->on('pl_file', '=', 'p_file')
1275
                        ->on('pl_p_id', '=', 'p_id');
1276
                })
1277
                ->join('name', static function (JoinClause $join): void {
1278
                    $join
1279
                        ->on('n_file', '=', 'pl_file')
1280
                        ->on('n_id', '=', 'pl_gid');
1281
                })
1282
                ->where('n_surn', '=', $surname)
1283
                ->groupBy('p_place')
1284
                ->pluck(new Expression('COUNT(*)'), 'p_place');
0 ignored issues
show
Bug introduced by
'COUNT(*)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1284
                ->pluck(new Expression(/** @scrutinizer ignore-type */ 'COUNT(*)'), 'p_place');
Loading history...
1285
1286
        $totals = [];
1287
1288
        $country_to_iso3166 = $this->getIso3166Countries();
1289
1290
        foreach ($rows as $country => $count) {
1291
            $country_code = $country_to_iso3166[$country] ?? null;
1292
1293
            if ($country_code !== null) {
1294
                $totals[$country_code] ??= 0;
1295
                $totals[$country_code] += $count;
1296
            }
1297
        }
1298
1299
        return $totals;
1300
    }
1301
1302
    /**
1303
     * @return array<string,int>
1304
     */
1305
    private function countFamilyEventsByCountry(Tree $tree, string $fact): array
1306
    {
1307
        $query = DB::table('places')
1308
            ->where('p_file', '=', $tree->id())
1309
            ->where('p_parent_id', '=', 0)
1310
            ->join('placelinks', static function (JoinClause $join): void {
1311
                $join
1312
                    ->on('pl_file', '=', 'p_file')
1313
                    ->on('pl_p_id', '=', 'p_id');
1314
            })
1315
            ->join('families', static function (JoinClause $join): void {
1316
                $join
1317
                    ->on('pl_file', '=', 'f_file')
1318
                    ->on('pl_gid', '=', 'f_id');
1319
            })
1320
            ->select(['p_place AS place', 'f_gedcom AS gedcom']);
1321
1322
        return $this->filterEventPlaces($query, $fact);
1323
    }
1324
1325
    /**
1326
     * @return array<string,int>
1327
     */
1328
    private function countIndividualEventsByCountry(Tree $tree, string $fact): array
1329
    {
1330
        $query = DB::table('places')
1331
            ->where('p_file', '=', $tree->id())
1332
            ->where('p_parent_id', '=', 0)
1333
            ->join('placelinks', static function (JoinClause $join): void {
1334
                $join
1335
                    ->on('pl_file', '=', 'p_file')
1336
                    ->on('pl_p_id', '=', 'p_id');
1337
            })
1338
            ->join('individuals', static function (JoinClause $join): void {
1339
                $join
1340
                    ->on('pl_file', '=', 'i_file')
1341
                    ->on('pl_gid', '=', 'i_id');
1342
            })
1343
            ->select(['p_place AS place', 'i_gedcom AS gedcom']);
1344
1345
        return $this->filterEventPlaces($query, $fact);
1346
    }
1347
1348
    /**
1349
     * @return array<string,int>
1350
     */
1351
    private function filterEventPlaces(Builder $query, string $fact): array
1352
    {
1353
        $totals = [];
1354
1355
        $country_to_iso3166 = $this->getIso3166Countries();
1356
1357
        foreach ($query->cursor() as $row) {
1358
            $country_code = $country_to_iso3166[$row->place] ?? null;
1359
1360
            if ($country_code !== null) {
1361
                $place_regex = '/\n1 ' . $fact . '(?:\n[2-9].*)*\n2 PLAC.*[, ]' . preg_quote($row->place, '(?:\n|$)/i') . '\n/';
1362
1363
                if (preg_match($place_regex, $row->gedcom) === 1) {
1364
                    $totals[$country_code] = 1 + ($totals[$country_code] ?? 0);
1365
                }
1366
            }
1367
        }
1368
1369
        return $totals;
1370
    }
1371
1372
    /**
1373
     * Create a chart showing where events occurred.
1374
     *
1375
     * @param string $chart_shows The type of chart map to show
1376
     * @param string $chart_type  The type of chart to show
1377
     * @param string $surname     The surname for surname based distribution chart
1378
     */
1379
    public function chartDistribution(
1380
        string $chart_shows = 'world',
1381
        string $chart_type = '',
1382
        string $surname = ''
1383
    ): string {
1384
        switch ($chart_type) {
1385
            case 'surname_distribution_chart':
1386
                $chart_title = I18N::translate('Surname distribution chart') . ': ' . $surname;
1387
                $surnames    = $this->commonSurnames(1, 0, 'count');
1388
                $surname     = implode(I18N::$list_separator, array_keys(array_shift($surnames) ?? []));
1389
                $data        = $this->createChartData($this->countSurnamesByCountry($this->tree, $surname));
1390
                break;
1391
1392
            case 'birth_distribution_chart':
1393
                $chart_title = I18N::translate('Birth by country');
1394
                $data        = $this->createChartData($this->countIndividualEventsByCountry($this->tree, 'BIRT'));
1395
                break;
1396
1397
            case 'death_distribution_chart':
1398
                $chart_title = I18N::translate('Death by country');
1399
                $data        = $this->createChartData($this->countIndividualEventsByCountry($this->tree, 'DEAT'));
1400
                break;
1401
1402
            case 'marriage_distribution_chart':
1403
                $chart_title = I18N::translate('Marriage by country');
1404
                $data        = $this->createChartData($this->countFamilyEventsByCountry($this->tree, 'MARR'));
1405
                break;
1406
1407
            case 'indi_distribution_chart':
1408
            default:
1409
                $chart_title = I18N::translate('Individual distribution chart');
1410
                $data        = $this->createChartData($this->countIndividualsByCountry($this->tree));
1411
                break;
1412
        }
1413
1414
        return view('statistics/other/charts/geo', [
1415
            'chart_title'  => $chart_title,
1416
            'chart_color2' => '84beff',
1417
            'chart_color3' => 'c3dfff',
1418
            'region'       => $chart_shows,
1419
            'data'         => $data,
1420
            'language'     => I18N::languageTag(),
1421
        ]);
1422
    }
1423
1424
    /**
1425
     * @return array<array{family:Family,count:int}>
1426
     */
1427
    private function topTenGrandFamilyQuery(int $limit): array
1428
    {
1429
        return DB::table('families')
1430
            ->join('link AS children', static function (JoinClause $join): void {
1431
                $join
1432
                    ->on('children.l_from', '=', 'f_id')
1433
                    ->on('children.l_file', '=', 'f_file')
1434
                    ->where('children.l_type', '=', 'CHIL');
1435
            })->join('link AS mchildren', static function (JoinClause $join): void {
1436
                $join
1437
                    ->on('mchildren.l_file', '=', 'children.l_file')
1438
                    ->on('mchildren.l_from', '=', 'children.l_to')
1439
                    ->where('mchildren.l_type', '=', 'FAMS');
1440
            })->join('link AS gchildren', static function (JoinClause $join): void {
1441
                $join
1442
                    ->on('gchildren.l_file', '=', 'mchildren.l_file')
1443
                    ->on('gchildren.l_from', '=', 'mchildren.l_to')
1444
                    ->where('gchildren.l_type', '=', 'CHIL');
1445
            })
1446
            ->where('f_file', '=', $this->tree->id())
1447
            ->groupBy(['f_id', 'f_file'])
1448
            ->orderBy(new Expression('COUNT(*)'), 'DESC')
0 ignored issues
show
Bug introduced by
'COUNT(*)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1448
            ->orderBy(new Expression(/** @scrutinizer ignore-type */ 'COUNT(*)'), 'DESC')
Loading history...
1449
            ->select(['families.*'])
1450
            ->limit($limit)
1451
            ->get()
1452
            ->map(Registry::familyFactory()->mapper($this->tree))
1453
            ->filter(GedcomRecord::accessFilter())
1454
            ->map(static function (Family $family): array {
1455
                $count = 0;
1456
                foreach ($family->children() as $child) {
1457
                    foreach ($child->spouseFamilies() as $spouse_family) {
1458
                        $count += $spouse_family->children()->count();
1459
                    }
1460
                }
1461
1462
                return [
1463
                    'family' => $family,
1464
                    'count'  => $count,
1465
                ];
1466
            })
1467
            ->all();
1468
    }
1469
1470
    public function topTenLargestGrandFamily(int $limit = 10): string
1471
    {
1472
        return view('statistics/families/top10-nolist-grand', [
1473
            'records' => $this->topTenGrandFamilyQuery($limit),
1474
        ]);
1475
    }
1476
1477
    public function topTenLargestGrandFamilyList(int $limit = 10): string
1478
    {
1479
        return view('statistics/families/top10-list-grand', [
1480
            'records' => $this->topTenGrandFamilyQuery($limit),
1481
        ]);
1482
    }
1483
1484
    public function noChildrenFamiliesList(string $type = 'list'): string
1485
    {
1486
        $families = DB::table('families')
1487
            ->where('f_file', '=', $this->tree->id())
1488
            ->where('f_numchil', '=', 0)
1489
            ->get()
1490
            ->map(Registry::familyFactory()->mapper($this->tree))
1491
            ->filter(GedcomRecord::accessFilter());
1492
1493
        $top10 = [];
1494
1495
        foreach ($families as $family) {
1496
            if ($type === 'list') {
1497
                $top10[] = '<li><a href="' . e($family->url()) . '">' . $family->fullName() . '</a></li>';
1498
            } else {
1499
                $top10[] = '<a href="' . e($family->url()) . '">' . $family->fullName() . '</a>';
1500
            }
1501
        }
1502
1503
        if ($type === 'list') {
1504
            $top10 = implode('', $top10);
1505
        } else {
1506
            $top10 = implode('; ', $top10);
1507
        }
1508
1509
        if ($type === 'list') {
1510
            return '<ul>' . $top10 . '</ul>';
1511
        }
1512
1513
        return $top10;
1514
    }
1515
1516
    /**
1517
     * Returns the calculated age the time of event.
1518
     *
1519
     * @param int $age The age from the database record
1520
     */
1521
    private function calculateAge(int $age): string
1522
    {
1523
        if ($age < 31) {
1524
            return I18N::plural('%s day', '%s days', $age, I18N::number($age));
1525
        }
1526
1527
        if ($age < 365) {
1528
            $months = (int) ($age / 30.5);
1529
1530
            return I18N::plural('%s month', '%s months', $months, I18N::number($months));
1531
        }
1532
1533
        $years = (int) ($age / 365.25);
1534
1535
        return I18N::plural('%s year', '%s years', $years, I18N::number($years));
1536
    }
1537
1538
    public function topAgeBetweenSiblings(): string
1539
    {
1540
        $rows = $this->maximumAgeBetweenSiblings(1);
1541
1542
        if ($rows === []) {
1543
            return I18N::translate('This information is not available.');
1544
        }
1545
1546
        return $rows[0]->age;
1547
    }
1548
1549
    public function topAgeBetweenSiblingsFullName(): string
1550
    {
1551
        $rows = $this->maximumAgeBetweenSiblings(1);
1552
1553
        if ($rows === []) {
1554
            return I18N::translate('This information is not available.');
1555
        }
1556
1557
        return view('statistics/families/top10-nolist-age', ['record' => (array) $rows[0]]);
1558
    }
1559
1560
    public function topAgeBetweenSiblingsList(int $limit, bool $unique_families): string
1561
    {
1562
        $rows    = $this->maximumAgeBetweenSiblings($limit);
1563
        $records = [];
1564
        $dist    = [];
1565
1566
        foreach ($rows as $row) {
1567
            if (!$unique_families || !in_array($row->family, $dist, true)) {
1568
                $records[] = [
1569
                    'child1' => $row->child1,
1570
                    'child2' => $row->child2,
1571
                    'family' => $row->family,
1572
                    'age'    => $row->age,
1573
                ];
1574
1575
                $dist[] = $row->family;
1576
            }
1577
        }
1578
1579
        return view('statistics/families/top10-list-age', [
1580
            'records' => $records,
1581
        ]);
1582
    }
1583
1584
    /**
1585
     * @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...
1586
     */
1587
    public function statsChildrenQuery(int $year1, int $year2): array
1588
    {
1589
        $query = DB::table('families')
1590
            ->where('f_file', '=', $this->tree->id())
1591
            ->groupBy(['f_numchil'])
1592
            ->select(['f_numchil', new Expression('COUNT(*) AS total')]);
0 ignored issues
show
Bug introduced by
'COUNT(*) AS total' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1592
            ->select(['f_numchil', new Expression(/** @scrutinizer ignore-type */ 'COUNT(*) AS total')]);
Loading history...
1593
1594
        if ($year1 !== 0 && $year2 !== 0) {
1595
            $query
1596
                ->join('dates', static function (JoinClause $join): void {
1597
                    $join
1598
                        ->on('d_file', '=', 'f_file')
1599
                        ->on('d_gid', '=', 'f_id');
1600
                })
1601
                ->where('d_fact', '=', 'MARR')
1602
                ->whereIn('d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
1603
                ->whereBetween('d_year', [$year1, $year2]);
1604
        }
1605
1606
        return $query->get()
1607
            ->map(static fn (object $row): object => (object) [
1608
                'f_numchil' => (int) $row->f_numchil,
1609
                'total'     => (int) $row->total,
1610
            ])
1611
            ->all();
1612
    }
1613
1614
    /**
1615
     * @return array<array{family:Family,count:int}>
1616
     */
1617
    private function topTenFamilyQuery(int $limit): array
1618
    {
1619
        return DB::table('families')
1620
            ->where('f_file', '=', $this->tree->id())
1621
            ->orderBy('f_numchil', 'DESC')
1622
            ->limit($limit)
1623
            ->get()
1624
            ->map(Registry::familyFactory()->mapper($this->tree))
1625
            ->filter(GedcomRecord::accessFilter())
1626
            ->map(static fn (Family $family): array => [
1627
                'family' => $family,
1628
                'count'  => $family->numberOfChildren(),
1629
            ])
1630
            ->all();
1631
    }
1632
1633
    public function topTenLargestFamily(int $limit = 10): string
1634
    {
1635
        $records = $this->topTenFamilyQuery($limit);
1636
1637
        return view('statistics/families/top10-nolist', [
1638
            'records' => $records,
1639
        ]);
1640
    }
1641
1642
    public function topTenLargestFamilyList(int $limit = 10): string
1643
    {
1644
        $records = $this->topTenFamilyQuery($limit);
1645
1646
        return view('statistics/families/top10-list', [
1647
            'records' => $records,
1648
        ]);
1649
    }
1650
1651
    public function parentsQuery(string $type, string $age_dir, string $sex, bool $show_years): string
1652
    {
1653
        if ($sex === 'F') {
1654
            $sex_field = 'WIFE';
1655
        } else {
1656
            $sex_field = 'HUSB';
1657
        }
1658
1659
        if ($age_dir !== 'ASC') {
1660
            $age_dir = 'DESC';
1661
        }
1662
1663
        $row = DB::table('link AS parentfamily')
1664
            ->join('link AS childfamily', static function (JoinClause $join): void {
1665
                $join
1666
                    ->on('childfamily.l_file', '=', 'parentfamily.l_file')
1667
                    ->on('childfamily.l_from', '=', 'parentfamily.l_from')
1668
                    ->where('childfamily.l_type', '=', 'CHIL');
1669
            })
1670
            ->join('dates AS birth', static function (JoinClause $join): void {
1671
                $join
1672
                    ->on('birth.d_file', '=', 'parentfamily.l_file')
1673
                    ->on('birth.d_gid', '=', 'parentfamily.l_to')
1674
                    ->where('birth.d_fact', '=', 'BIRT')
1675
                    ->where('birth.d_julianday1', '<>', 0);
1676
            })
1677
            ->join('dates AS childbirth', static function (JoinClause $join): void {
1678
                $join
1679
                    ->on('childbirth.d_file', '=', 'parentfamily.l_file')
1680
                    ->on('childbirth.d_gid', '=', 'childfamily.l_to')
1681
                    ->where('childbirth.d_fact', '=', 'BIRT');
1682
            })
1683
            ->where('childfamily.l_file', '=', $this->tree->id())
1684
            ->where('parentfamily.l_type', '=', $sex_field)
1685
            ->where('childbirth.d_julianday2', '>', new Expression(DB::prefix('birth.d_julianday1')))
1686
            ->select(['parentfamily.l_to AS id', new Expression(DB::prefix('childbirth.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS age')])
0 ignored issues
show
Bug introduced by
Fisharebest\Webtrees\DB:...ulianday1') . ' AS age' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1686
            ->select(['parentfamily.l_to AS id', new Expression(/** @scrutinizer ignore-type */ DB::prefix('childbirth.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS age')])
Loading history...
1687
            ->take(1)
1688
            ->orderBy('age', $age_dir)
1689
            ->get()
1690
            ->first();
1691
1692
        if ($row === null) {
1693
            return I18N::translate('This information is not available.');
1694
        }
1695
1696
        $person = Registry::individualFactory()->make($row->id, $this->tree);
1697
1698
        switch ($type) {
1699
            default:
1700
            case 'full':
1701
                if ($person !== null && $person->canShow()) {
1702
                    $result = $person->formatList();
1703
                } else {
1704
                    $result = I18N::translate('This information is private and cannot be shown.');
1705
                }
1706
                break;
1707
1708
            case 'name':
1709
                $result = '<a href="' . e($person->url()) . '">' . $person->fullName() . '</a>';
1710
                break;
1711
1712
            case 'age':
1713
                $age = $row->age;
1714
1715
                if ($show_years) {
1716
                    $result = $this->calculateAge((int) $row->age);
1717
                } else {
1718
                    $result = (string) floor($age / 365.25);
1719
                }
1720
1721
                break;
1722
        }
1723
1724
        return $result;
1725
    }
1726
1727
    /**
1728
     * General query on age at marriage.
1729
     *
1730
     * @param string $type
1731
     * @param string $age_dir "ASC" or "DESC"
1732
     * @param int    $limit
1733
     */
1734
    public function ageOfMarriageQuery(string $type, string $age_dir, int $limit): string
1735
    {
1736
        $hrows = DB::table('families')
1737
            ->where('f_file', '=', $this->tree->id())
1738
            ->join('dates AS married', static function (JoinClause $join): void {
1739
                $join
1740
                    ->on('married.d_file', '=', 'f_file')
1741
                    ->on('married.d_gid', '=', 'f_id')
1742
                    ->where('married.d_fact', '=', 'MARR')
1743
                    ->where('married.d_julianday1', '<>', 0);
1744
            })
1745
            ->join('dates AS husbdeath', static function (JoinClause $join): void {
1746
                $join
1747
                    ->on('husbdeath.d_gid', '=', 'f_husb')
1748
                    ->on('husbdeath.d_file', '=', 'f_file')
1749
                    ->where('husbdeath.d_fact', '=', 'DEAT');
1750
            })
1751
            ->whereColumn('married.d_julianday1', '<', 'husbdeath.d_julianday2')
1752
            ->groupBy(['f_id'])
1753
            ->select(['f_id AS family', new Expression('MIN(' . DB::prefix('husbdeath.d_julianday2') . ' - ' . DB::prefix('married.d_julianday1') . ') AS age')])
0 ignored issues
show
Bug introduced by
'MIN(' . Fisharebest\Web...lianday1') . ') AS age' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1753
            ->select(['f_id AS family', new Expression(/** @scrutinizer ignore-type */ 'MIN(' . DB::prefix('husbdeath.d_julianday2') . ' - ' . DB::prefix('married.d_julianday1') . ') AS age')])
Loading history...
1754
            ->get()
1755
            ->all();
1756
1757
        $wrows = DB::table('families')
1758
            ->where('f_file', '=', $this->tree->id())
1759
            ->join('dates AS married', static function (JoinClause $join): void {
1760
                $join
1761
                    ->on('married.d_file', '=', 'f_file')
1762
                    ->on('married.d_gid', '=', 'f_id')
1763
                    ->where('married.d_fact', '=', 'MARR')
1764
                    ->where('married.d_julianday1', '<>', 0);
1765
            })
1766
            ->join('dates AS wifedeath', static function (JoinClause $join): void {
1767
                $join
1768
                    ->on('wifedeath.d_gid', '=', 'f_wife')
1769
                    ->on('wifedeath.d_file', '=', 'f_file')
1770
                    ->where('wifedeath.d_fact', '=', 'DEAT');
1771
            })
1772
            ->whereColumn('married.d_julianday1', '<', 'wifedeath.d_julianday2')
1773
            ->groupBy(['f_id'])
1774
            ->select(['f_id AS family', new Expression('MIN(' . DB::prefix('wifedeath.d_julianday2') . ' - ' . DB::prefix('married.d_julianday1') . ') AS age')])
1775
            ->get()
1776
            ->all();
1777
1778
        $drows = DB::table('families')
1779
            ->where('f_file', '=', $this->tree->id())
1780
            ->join('dates AS married', static function (JoinClause $join): void {
1781
                $join
1782
                    ->on('married.d_file', '=', 'f_file')
1783
                    ->on('married.d_gid', '=', 'f_id')
1784
                    ->where('married.d_fact', '=', 'MARR')
1785
                    ->where('married.d_julianday1', '<>', 0);
1786
            })
1787
            ->join('dates AS divorced', static function (JoinClause $join): void {
1788
                $join
1789
                    ->on('divorced.d_gid', '=', 'f_id')
1790
                    ->on('divorced.d_file', '=', 'f_file')
1791
                    ->whereIn('divorced.d_fact', ['DIV', 'ANUL', '_SEPR']);
1792
            })
1793
            ->whereColumn('married.d_julianday1', '<', 'divorced.d_julianday2')
1794
            ->groupBy(['f_id'])
1795
            ->select(['f_id AS family', new Expression('MIN(' . DB::prefix('divorced.d_julianday2') . ' - ' . DB::prefix('married.d_julianday1') . ') AS age')])
1796
            ->get()
1797
            ->all();
1798
1799
        $rows = [];
1800
        foreach ($drows as $family) {
1801
            $rows[$family->family] = $family->age;
1802
        }
1803
1804
        foreach ($hrows as $family) {
1805
            if (!isset($rows[$family->family])) {
1806
                $rows[$family->family] = $family->age;
1807
            }
1808
        }
1809
1810
        foreach ($wrows as $family) {
1811
            if (!isset($rows[$family->family])) {
1812
                $rows[$family->family] = $family->age;
1813
            } elseif ($rows[$family->family] > $family->age) {
1814
                $rows[$family->family] = $family->age;
1815
            }
1816
        }
1817
1818
        if ($age_dir === 'DESC') {
1819
            arsort($rows);
1820
        } else {
1821
            asort($rows);
1822
        }
1823
1824
        $top10 = [];
1825
        $i     = 0;
1826
        foreach ($rows as $xref => $age) {
1827
            $family = Registry::familyFactory()->make((string) $xref, $this->tree);
1828
            if ($type === 'name') {
1829
                return $family->formatList();
1830
            }
1831
1832
            $age = $this->calculateAge((int) $age);
1833
1834
            if ($type === 'age') {
1835
                return $age;
1836
            }
1837
1838
            $husb = $family->husband();
1839
            $wife = $family->wife();
1840
1841
            if (
1842
                $husb instanceof Individual &&
1843
                $wife instanceof Individual &&
1844
                ($husb->getAllDeathDates() || !$husb->isDead()) &&
1845
                ($wife->getAllDeathDates() || !$wife->isDead())
1846
            ) {
1847
                if ($family->canShow()) {
1848
                    if ($type === 'list') {
1849
                        $top10[] = '<li><a href="' . e($family->url()) . '">' . $family->fullName() . '</a> (' . $age . ')' . '</li>';
1850
                    } else {
1851
                        $top10[] = '<a href="' . e($family->url()) . '">' . $family->fullName() . '</a> (' . $age . ')';
1852
                    }
1853
                }
1854
                if (++$i === $limit) {
1855
                    break;
1856
                }
1857
            }
1858
        }
1859
1860
        if ($type === 'list') {
1861
            $top10 = implode('', $top10);
1862
        } else {
1863
            $top10 = implode('; ', $top10);
1864
        }
1865
1866
        if (I18N::direction() === 'rtl') {
1867
            $top10 = str_replace([
1868
                '[',
1869
                ']',
1870
                '(',
1871
                ')',
1872
                '+',
1873
            ], [
1874
                '&rlm;[',
1875
                '&rlm;]',
1876
                '&rlm;(',
1877
                '&rlm;)',
1878
                '&rlm;+',
1879
            ], $top10);
1880
        }
1881
1882
        if ($type === 'list') {
1883
            return '<ul>' . $top10 . '</ul>';
1884
        }
1885
1886
        return $top10;
1887
    }
1888
1889
    /**
1890
     * @return array<array{family:Family,age:string}>
1891
     */
1892
    private function ageBetweenSpousesQuery(string $age_dir, int $limit): array
1893
    {
1894
        $query = DB::table('families')
1895
            ->where('f_file', '=', $this->tree->id())
1896
            ->join('dates AS wife', static function (JoinClause $join): void {
1897
                $join
1898
                    ->on('wife.d_gid', '=', 'f_wife')
1899
                    ->on('wife.d_file', '=', 'f_file')
1900
                    ->where('wife.d_fact', '=', 'BIRT')
1901
                    ->where('wife.d_julianday1', '<>', 0);
1902
            })
1903
            ->join('dates AS husb', static function (JoinClause $join): void {
1904
                $join
1905
                    ->on('husb.d_gid', '=', 'f_husb')
1906
                    ->on('husb.d_file', '=', 'f_file')
1907
                    ->where('husb.d_fact', '=', 'BIRT')
1908
                    ->where('husb.d_julianday1', '<>', 0);
1909
            });
1910
1911
        if ($age_dir === 'DESC') {
1912
            $query
1913
                ->whereColumn('wife.d_julianday1', '>=', 'husb.d_julianday1')
1914
                ->orderBy(new Expression('MIN(' . DB::prefix('wife.d_julianday1') . ') - MIN(' . DB::prefix('husb.d_julianday1') . ')'), 'DESC');
0 ignored issues
show
Bug introduced by
'MIN(' . Fisharebest\Web...sb.d_julianday1') . ')' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1914
                ->orderBy(new Expression(/** @scrutinizer ignore-type */ 'MIN(' . DB::prefix('wife.d_julianday1') . ') - MIN(' . DB::prefix('husb.d_julianday1') . ')'), 'DESC');
Loading history...
1915
        } else {
1916
            $query
1917
                ->whereColumn('husb.d_julianday1', '>=', 'wife.d_julianday1')
1918
                ->orderBy(new Expression('MIN(' . DB::prefix('husb.d_julianday1') . ') - MIN(' . DB::prefix('wife.d_julianday1') . ')'), 'DESC');
1919
        }
1920
1921
        return $query
1922
            ->groupBy(['f_id', 'f_file'])
1923
            ->select(['families.*'])
1924
            ->take($limit)
1925
            ->get()
1926
            ->map(Registry::familyFactory()->mapper($this->tree))
1927
            ->filter(GedcomRecord::accessFilter())
1928
            ->map(function (Family $family) use ($age_dir): array {
1929
                $husb_birt_jd = $family->husband()->getBirthDate()->minimumJulianDay();
1930
                $wife_birt_jd = $family->wife()->getBirthDate()->minimumJulianDay();
1931
1932
                if ($age_dir === 'DESC') {
1933
                    $diff = $wife_birt_jd - $husb_birt_jd;
1934
                } else {
1935
                    $diff = $husb_birt_jd - $wife_birt_jd;
1936
                }
1937
1938
                return [
1939
                    'family' => $family,
1940
                    'age'    => $this->calculateAge($diff),
1941
                ];
1942
            })
1943
            ->all();
1944
    }
1945
1946
    public function ageBetweenSpousesMF(int $limit = 10): string
1947
    {
1948
        $records = $this->ageBetweenSpousesQuery('DESC', $limit);
1949
1950
        return view('statistics/families/top10-nolist-spouses', [
1951
            'records' => $records,
1952
        ]);
1953
    }
1954
1955
    public function ageBetweenSpousesMFList(int $limit = 10): string
1956
    {
1957
        $records = $this->ageBetweenSpousesQuery('DESC', $limit);
1958
1959
        return view('statistics/families/top10-list-spouses', [
1960
            'records' => $records,
1961
        ]);
1962
    }
1963
1964
    public function ageBetweenSpousesFM(int $limit = 10): string
1965
    {
1966
        return view('statistics/families/top10-nolist-spouses', [
1967
            'records' => $this->ageBetweenSpousesQuery('ASC', $limit),
1968
        ]);
1969
    }
1970
1971
    public function ageBetweenSpousesFMList(int $limit = 10): string
1972
    {
1973
        return view('statistics/families/top10-list-spouses', [
1974
            'records' => $this->ageBetweenSpousesQuery('ASC', $limit),
1975
        ]);
1976
    }
1977
1978
    /**
1979
     * @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...
1980
     */
1981
    public function statsMarrAgeQuery(string $sex, int $year1, int $year2): array
1982
    {
1983
        $query = DB::table('dates AS married')
1984
            ->join('families', static function (JoinClause $join): void {
1985
                $join
1986
                    ->on('f_file', '=', 'married.d_file')
1987
                    ->on('f_id', '=', 'married.d_gid');
1988
            })
1989
            ->join('dates AS birth', static function (JoinClause $join) use ($sex): void {
1990
                $join
1991
                    ->on('birth.d_file', '=', 'married.d_file')
1992
                    ->on('birth.d_gid', '=', $sex === 'M' ? 'f_husb' : 'f_wife')
1993
                    ->where('birth.d_julianday1', '<>', 0)
1994
                    ->where('birth.d_fact', '=', 'BIRT')
1995
                    ->whereIn('birth.d_type', ['@#DGREGORIAN@', '@#DJULIAN@']);
1996
            })
1997
            ->where('married.d_file', '=', $this->tree->id())
1998
            ->where('married.d_fact', '=', 'MARR')
1999
            ->whereIn('married.d_type', ['@#DGREGORIAN@', '@#DJULIAN@'])
2000
            ->whereColumn('married.d_julianday1', '>', 'birth.d_julianday1')
2001
            ->select(['f_id', 'birth.d_gid', new Expression(DB::prefix('married.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS age')]);
0 ignored issues
show
Bug introduced by
Fisharebest\Webtrees\DB:...ulianday1') . ' AS age' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2001
            ->select(['f_id', 'birth.d_gid', new Expression(/** @scrutinizer ignore-type */ DB::prefix('married.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS age')]);
Loading history...
2002
2003
        if ($year1 !== 0 && $year2 !== 0) {
2004
            $query->whereBetween('married.d_year', [$year1, $year2]);
2005
        }
2006
2007
        return $query
2008
            ->get()
2009
            ->map(static fn (object $row): object => (object) [
2010
                'f_id'  => $row->f_id,
2011
                'd_gid' => $row->d_gid,
2012
                'age'   => (int) $row->age,
2013
            ])
2014
            ->all();
2015
    }
2016
2017
    /**
2018
     * Query the database for marriage tags.
2019
     *
2020
     * @param string $show    "full", "name" or "age"
2021
     * @param string $age_dir "ASC" or "DESC"
2022
     * @param string $sex     "F" or "M"
2023
     * @param bool   $show_years
2024
     */
2025
    public function marriageQuery(string $show, string $age_dir, string $sex, bool $show_years): string
2026
    {
2027
        if ($sex === 'F') {
2028
            $sex_field = 'f_wife';
2029
        } else {
2030
            $sex_field = 'f_husb';
2031
        }
2032
2033
        if ($age_dir !== 'ASC') {
2034
            $age_dir = 'DESC';
2035
        }
2036
2037
        $row = DB::table('families')
2038
            ->join('dates AS married', static function (JoinClause $join): void {
2039
                $join
2040
                    ->on('married.d_file', '=', 'f_file')
2041
                    ->on('married.d_gid', '=', 'f_id')
2042
                    ->where('married.d_fact', '=', 'MARR');
2043
            })
2044
            ->join('individuals', static function (JoinClause $join) use ($sex, $sex_field): void {
2045
                $join
2046
                    ->on('i_file', '=', 'f_file')
2047
                    ->on('i_id', '=', $sex_field)
2048
                    ->where('i_sex', '=', $sex);
2049
            })
2050
            ->join('dates AS birth', static function (JoinClause $join): void {
2051
                $join
2052
                    ->on('birth.d_file', '=', 'i_file')
2053
                    ->on('birth.d_gid', '=', 'i_id')
2054
                    ->where('birth.d_fact', '=', 'BIRT')
2055
                    ->where('birth.d_julianday1', '<>', 0);
2056
            })
2057
            ->where('f_file', '=', $this->tree->id())
2058
            ->where('married.d_julianday2', '>', new Expression(DB::prefix('birth.d_julianday1')))
2059
            ->orderBy(new Expression(DB::prefix('married.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1')), $age_dir)
0 ignored issues
show
Bug introduced by
Fisharebest\Webtrees\DB:...x('birth.d_julianday1') of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2059
            ->orderBy(new Expression(/** @scrutinizer ignore-type */ DB::prefix('married.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1')), $age_dir)
Loading history...
2060
            ->select(['f_id AS famid', $sex_field, new Expression(DB::prefix('married.d_julianday2') . ' - ' . DB::prefix('birth.d_julianday1') . ' AS age'), 'i_id'])
2061
            ->take(1)
2062
            ->get()
2063
            ->first();
2064
2065
        if ($row === null) {
2066
            return I18N::translate('This information is not available.');
2067
        }
2068
2069
        $family = Registry::familyFactory()->make($row->famid, $this->tree);
2070
        $person = Registry::individualFactory()->make($row->i_id, $this->tree);
2071
2072
        switch ($show) {
2073
            default:
2074
            case 'full':
2075
                if ($family !== null && $family->canShow()) {
2076
                    $result = $family->formatList();
2077
                } else {
2078
                    $result = I18N::translate('This information is private and cannot be shown.');
2079
                }
2080
                break;
2081
2082
            case 'name':
2083
                $result = '<a href="' . e($family->url()) . '">' . $person->fullName() . '</a>';
2084
                break;
2085
2086
            case 'age':
2087
                $age = $row->age;
2088
2089
                if ($show_years) {
2090
                    $result = $this->calculateAge((int) $row->age);
2091
                } else {
2092
                    $result = I18N::number((int) ($age / 365.25));
2093
                }
2094
2095
                break;
2096
        }
2097
2098
        return $result;
2099
    }
2100
2101
    /**
2102
     * Who is currently logged in?
2103
     *
2104
     * @param string $type "list" or "nolist"
2105
     */
2106
    private function usersLoggedInQuery(string $type): string
2107
    {
2108
        $content   = '';
2109
        $anonymous = 0;
2110
        $logged_in = [];
2111
2112
        foreach ($this->user_service->allLoggedIn() as $user) {
2113
            if (Auth::isAdmin() || $user->getPreference(UserInterface::PREF_IS_VISIBLE_ONLINE) === '1') {
2114
                $logged_in[] = $user;
2115
            } else {
2116
                $anonymous++;
2117
            }
2118
        }
2119
2120
        $count_logged_in = count($logged_in);
2121
2122
        if ($count_logged_in === 0 && $anonymous === 0) {
2123
            $content .= I18N::translate('No signed-in and no anonymous users');
2124
        }
2125
2126
        if ($anonymous > 0) {
2127
            $content .= '<b>' . I18N::plural('%s anonymous signed-in user', '%s anonymous signed-in users', $anonymous, I18N::number($anonymous)) . '</b>';
2128
        }
2129
2130
        if ($count_logged_in > 0) {
2131
            if ($anonymous !== 0) {
2132
                if ($type === 'list') {
2133
                    $content .= '<br><br>';
2134
                } else {
2135
                    $content .= ' ' . I18N::translate('and') . ' ';
2136
                }
2137
            }
2138
            $content .= '<b>' . I18N::plural('%s signed-in user', '%s signed-in users', $count_logged_in, I18N::number($count_logged_in)) . '</b>';
2139
            if ($type === 'list') {
2140
                $content .= '<ul>';
2141
            } else {
2142
                $content .= ': ';
2143
            }
2144
        }
2145
2146
        if (Auth::check()) {
2147
            foreach ($logged_in as $user) {
2148
                if ($type === 'list') {
2149
                    $content .= '<li>';
2150
                }
2151
2152
                $individual = Registry::individualFactory()->make($this->tree->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF), $this->tree);
2153
2154
                if ($individual instanceof Individual && $individual->canShow()) {
2155
                    $content .= '<a href="' . e($individual->url()) . '">' . e($user->realName()) . '</a>';
2156
                } else {
2157
                    $content .= e($user->realName());
2158
                }
2159
2160
                $content .= ' - ' . e($user->userName());
2161
2162
                if ($user->getPreference(UserInterface::PREF_CONTACT_METHOD) !== MessageService::CONTACT_METHOD_NONE && Auth::id() !== $user->id()) {
2163
                    $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>';
2164
                }
2165
2166
                if ($type === 'list') {
2167
                    $content .= '</li>';
2168
                }
2169
            }
2170
        }
2171
2172
        if ($type === 'list') {
2173
            $content .= '</ul>';
2174
        }
2175
2176
        return $content;
2177
    }
2178
2179
    public function usersLoggedIn(): string
2180
    {
2181
        return $this->usersLoggedInQuery('nolist');
2182
    }
2183
2184
    public function usersLoggedInList(): string
2185
    {
2186
        return $this->usersLoggedInQuery('list');
2187
    }
2188
2189
    /**
2190
     * Century name, English => 21st, Polish => XXI, etc.
2191
     */
2192
    private function centuryName(int $century): string
2193
    {
2194
        if ($century < 0) {
2195
            return I18N::translate('%s BCE', $this->centuryName(-$century));
2196
        }
2197
2198
        // The current chart engine (Google charts) can't handle <sup></sup> markup
2199
        return match ($century) {
2200
            21 => strip_tags(I18N::translateContext('CENTURY', '21st')),
2201
            20 => strip_tags(I18N::translateContext('CENTURY', '20th')),
2202
            19 => strip_tags(I18N::translateContext('CENTURY', '19th')),
2203
            18 => strip_tags(I18N::translateContext('CENTURY', '18th')),
2204
            17 => strip_tags(I18N::translateContext('CENTURY', '17th')),
2205
            16 => strip_tags(I18N::translateContext('CENTURY', '16th')),
2206
            15 => strip_tags(I18N::translateContext('CENTURY', '15th')),
2207
            14 => strip_tags(I18N::translateContext('CENTURY', '14th')),
2208
            13 => strip_tags(I18N::translateContext('CENTURY', '13th')),
2209
            12 => strip_tags(I18N::translateContext('CENTURY', '12th')),
2210
            11 => strip_tags(I18N::translateContext('CENTURY', '11th')),
2211
            10 => strip_tags(I18N::translateContext('CENTURY', '10th')),
2212
            9 => strip_tags(I18N::translateContext('CENTURY', '9th')),
2213
            8 => strip_tags(I18N::translateContext('CENTURY', '8th')),
2214
            7 => strip_tags(I18N::translateContext('CENTURY', '7th')),
2215
            6 => strip_tags(I18N::translateContext('CENTURY', '6th')),
2216
            5 => strip_tags(I18N::translateContext('CENTURY', '5th')),
2217
            4 => strip_tags(I18N::translateContext('CENTURY', '4th')),
2218
            3 => strip_tags(I18N::translateContext('CENTURY', '3rd')),
2219
            2 => strip_tags(I18N::translateContext('CENTURY', '2nd')),
2220
            1 => strip_tags(I18N::translateContext('CENTURY', '1st')),
2221
            default => ($century - 1) . '01-' . $century . '00',
2222
        };
2223
    }
2224
2225
    /**
2226
     * @return array<string>
2227
     */
2228
    private function getAllCountries(): array
2229
    {
2230
        return [
2231
            /* I18N: Name of a country or state */
2232
            '???' => I18N::translate('Unknown'),
2233
            /* I18N: Name of a country or state */
2234
            'ABW' => I18N::translate('Aruba'),
2235
            /* I18N: Name of a country or state */
2236
            'AFG' => I18N::translate('Afghanistan'),
2237
            /* I18N: Name of a country or state */
2238
            'AGO' => I18N::translate('Angola'),
2239
            /* I18N: Name of a country or state */
2240
            'AIA' => I18N::translate('Anguilla'),
2241
            /* I18N: Name of a country or state */
2242
            'ALA' => I18N::translate('Åland Islands'),
2243
            /* I18N: Name of a country or state */
2244
            'ALB' => I18N::translate('Albania'),
2245
            /* I18N: Name of a country or state */
2246
            'AND' => I18N::translate('Andorra'),
2247
            /* I18N: Name of a country or state */
2248
            'ARE' => I18N::translate('United Arab Emirates'),
2249
            /* I18N: Name of a country or state */
2250
            'ARG' => I18N::translate('Argentina'),
2251
            /* I18N: Name of a country or state */
2252
            'ARM' => I18N::translate('Armenia'),
2253
            /* I18N: Name of a country or state */
2254
            'ASM' => I18N::translate('American Samoa'),
2255
            /* I18N: Name of a country or state */
2256
            'ATA' => I18N::translate('Antarctica'),
2257
            /* I18N: Name of a country or state */
2258
            'ATF' => I18N::translate('French Southern Territories'),
2259
            /* I18N: Name of a country or state */
2260
            'ATG' => I18N::translate('Antigua and Barbuda'),
2261
            /* I18N: Name of a country or state */
2262
            'AUS' => I18N::translate('Australia'),
2263
            /* I18N: Name of a country or state */
2264
            'AUT' => I18N::translate('Austria'),
2265
            /* I18N: Name of a country or state */
2266
            'AZE' => I18N::translate('Azerbaijan'),
2267
            /* I18N: Name of a country or state */
2268
            'AZR' => I18N::translate('Azores'),
2269
            /* I18N: Name of a country or state */
2270
            'BDI' => I18N::translate('Burundi'),
2271
            /* I18N: Name of a country or state */
2272
            'BEL' => I18N::translate('Belgium'),
2273
            /* I18N: Name of a country or state */
2274
            'BEN' => I18N::translate('Benin'),
2275
            // BES => Bonaire, Sint Eustatius and Saba
2276
            /* I18N: Name of a country or state */
2277
            'BFA' => I18N::translate('Burkina Faso'),
2278
            /* I18N: Name of a country or state */
2279
            'BGD' => I18N::translate('Bangladesh'),
2280
            /* I18N: Name of a country or state */
2281
            'BGR' => I18N::translate('Bulgaria'),
2282
            /* I18N: Name of a country or state */
2283
            'BHR' => I18N::translate('Bahrain'),
2284
            /* I18N: Name of a country or state */
2285
            'BHS' => I18N::translate('Bahamas'),
2286
            /* I18N: Name of a country or state */
2287
            'BIH' => I18N::translate('Bosnia and Herzegovina'),
2288
            // BLM => Saint Barthélemy
2289
            'BLM' => I18N::translate('Saint Barthélemy'),
2290
            /* I18N: Name of a country or state */
2291
            'BLR' => I18N::translate('Belarus'),
2292
            /* I18N: Name of a country or state */
2293
            'BLZ' => I18N::translate('Belize'),
2294
            /* I18N: Name of a country or state */
2295
            'BMU' => I18N::translate('Bermuda'),
2296
            /* I18N: Name of a country or state */
2297
            'BOL' => I18N::translate('Bolivia'),
2298
            /* I18N: Name of a country or state */
2299
            'BRA' => I18N::translate('Brazil'),
2300
            /* I18N: Name of a country or state */
2301
            'BRB' => I18N::translate('Barbados'),
2302
            /* I18N: Name of a country or state */
2303
            'BRN' => I18N::translate('Brunei Darussalam'),
2304
            /* I18N: Name of a country or state */
2305
            'BTN' => I18N::translate('Bhutan'),
2306
            /* I18N: Name of a country or state */
2307
            'BVT' => I18N::translate('Bouvet Island'),
2308
            /* I18N: Name of a country or state */
2309
            'BWA' => I18N::translate('Botswana'),
2310
            /* I18N: Name of a country or state */
2311
            'CAF' => I18N::translate('Central African Republic'),
2312
            /* I18N: Name of a country or state */
2313
            'CAN' => I18N::translate('Canada'),
2314
            /* I18N: Name of a country or state */
2315
            'CCK' => I18N::translate('Cocos (Keeling) Islands'),
2316
            /* I18N: Name of a country or state */
2317
            'CHE' => I18N::translate('Switzerland'),
2318
            /* I18N: Name of a country or state */
2319
            'CHL' => I18N::translate('Chile'),
2320
            /* I18N: Name of a country or state */
2321
            'CHN' => I18N::translate('China'),
2322
            /* I18N: Name of a country or state */
2323
            'CIV' => I18N::translate('Côte d’Ivoire'),
2324
            /* I18N: Name of a country or state */
2325
            'CMR' => I18N::translate('Cameroon'),
2326
            /* I18N: Name of a country or state */
2327
            'COD' => I18N::translate('Democratic Republic of the Congo'),
2328
            /* I18N: Name of a country or state */
2329
            'COG' => I18N::translate('Republic of the Congo'),
2330
            /* I18N: Name of a country or state */
2331
            'COK' => I18N::translate('Cook Islands'),
2332
            /* I18N: Name of a country or state */
2333
            'COL' => I18N::translate('Colombia'),
2334
            /* I18N: Name of a country or state */
2335
            'COM' => I18N::translate('Comoros'),
2336
            /* I18N: Name of a country or state */
2337
            'CPV' => I18N::translate('Cape Verde'),
2338
            /* I18N: Name of a country or state */
2339
            'CRI' => I18N::translate('Costa Rica'),
2340
            /* I18N: Name of a country or state */
2341
            'CUB' => I18N::translate('Cuba'),
2342
            /* I18N: Name of a country or state */
2343
            'CUW' => I18N::translate('Curaçao'),
2344
            /* I18N: Name of a country or state */
2345
            'CXR' => I18N::translate('Christmas Island'),
2346
            /* I18N: Name of a country or state */
2347
            'CYM' => I18N::translate('Cayman Islands'),
2348
            /* I18N: Name of a country or state */
2349
            'CYP' => I18N::translate('Cyprus'),
2350
            /* I18N: Name of a country or state */
2351
            'CZE' => I18N::translate('Czech Republic'),
2352
            /* I18N: Name of a country or state */
2353
            'DEU' => I18N::translate('Germany'),
2354
            /* I18N: Name of a country or state */
2355
            'DJI' => I18N::translate('Djibouti'),
2356
            /* I18N: Name of a country or state */
2357
            'DMA' => I18N::translate('Dominica'),
2358
            /* I18N: Name of a country or state */
2359
            'DNK' => I18N::translate('Denmark'),
2360
            /* I18N: Name of a country or state */
2361
            'DOM' => I18N::translate('Dominican Republic'),
2362
            /* I18N: Name of a country or state */
2363
            'DZA' => I18N::translate('Algeria'),
2364
            /* I18N: Name of a country or state */
2365
            'ECU' => I18N::translate('Ecuador'),
2366
            /* I18N: Name of a country or state */
2367
            'EGY' => I18N::translate('Egypt'),
2368
            /* I18N: Name of a country or state */
2369
            'ENG' => I18N::translate('England'),
2370
            /* I18N: Name of a country or state */
2371
            'ERI' => I18N::translate('Eritrea'),
2372
            /* I18N: Name of a country or state */
2373
            'ESH' => I18N::translate('Western Sahara'),
2374
            /* I18N: Name of a country or state */
2375
            'ESP' => I18N::translate('Spain'),
2376
            /* I18N: Name of a country or state */
2377
            'EST' => I18N::translate('Estonia'),
2378
            /* I18N: Name of a country or state */
2379
            'ETH' => I18N::translate('Ethiopia'),
2380
            /* I18N: Name of a country or state */
2381
            'FIN' => I18N::translate('Finland'),
2382
            /* I18N: Name of a country or state */
2383
            'FJI' => I18N::translate('Fiji'),
2384
            /* I18N: Name of a country or state */
2385
            'FLD' => I18N::translate('Flanders'),
2386
            /* I18N: Name of a country or state */
2387
            'FLK' => I18N::translate('Falkland Islands'),
2388
            /* I18N: Name of a country or state */
2389
            'FRA' => I18N::translate('France'),
2390
            /* I18N: Name of a country or state */
2391
            'FRO' => I18N::translate('Faroe Islands'),
2392
            /* I18N: Name of a country or state */
2393
            'FSM' => I18N::translate('Micronesia'),
2394
            /* I18N: Name of a country or state */
2395
            'GAB' => I18N::translate('Gabon'),
2396
            /* I18N: Name of a country or state */
2397
            'GBR' => I18N::translate('United Kingdom'),
2398
            /* I18N: Name of a country or state */
2399
            'GEO' => I18N::translate('Georgia'),
2400
            /* I18N: Name of a country or state */
2401
            'GGY' => I18N::translate('Guernsey'),
2402
            /* I18N: Name of a country or state */
2403
            'GHA' => I18N::translate('Ghana'),
2404
            /* I18N: Name of a country or state */
2405
            'GIB' => I18N::translate('Gibraltar'),
2406
            /* I18N: Name of a country or state */
2407
            'GIN' => I18N::translate('Guinea'),
2408
            /* I18N: Name of a country or state */
2409
            'GLP' => I18N::translate('Guadeloupe'),
2410
            /* I18N: Name of a country or state */
2411
            'GMB' => I18N::translate('Gambia'),
2412
            /* I18N: Name of a country or state */
2413
            'GNB' => I18N::translate('Guinea-Bissau'),
2414
            /* I18N: Name of a country or state */
2415
            'GNQ' => I18N::translate('Equatorial Guinea'),
2416
            /* I18N: Name of a country or state */
2417
            'GRC' => I18N::translate('Greece'),
2418
            /* I18N: Name of a country or state */
2419
            'GRD' => I18N::translate('Grenada'),
2420
            /* I18N: Name of a country or state */
2421
            'GRL' => I18N::translate('Greenland'),
2422
            /* I18N: Name of a country or state */
2423
            'GTM' => I18N::translate('Guatemala'),
2424
            /* I18N: Name of a country or state */
2425
            'GUF' => I18N::translate('French Guiana'),
2426
            /* I18N: Name of a country or state */
2427
            'GUM' => I18N::translate('Guam'),
2428
            /* I18N: Name of a country or state */
2429
            'GUY' => I18N::translate('Guyana'),
2430
            /* I18N: Name of a country or state */
2431
            'HKG' => I18N::translate('Hong Kong'),
2432
            /* I18N: Name of a country or state */
2433
            'HMD' => I18N::translate('Heard Island and McDonald Islands'),
2434
            /* I18N: Name of a country or state */
2435
            'HND' => I18N::translate('Honduras'),
2436
            /* I18N: Name of a country or state */
2437
            'HRV' => I18N::translate('Croatia'),
2438
            /* I18N: Name of a country or state */
2439
            'HTI' => I18N::translate('Haiti'),
2440
            /* I18N: Name of a country or state */
2441
            'HUN' => I18N::translate('Hungary'),
2442
            /* I18N: Name of a country or state */
2443
            'IDN' => I18N::translate('Indonesia'),
2444
            /* I18N: Name of a country or state */
2445
            'IND' => I18N::translate('India'),
2446
            /* I18N: Name of a country or state */
2447
            'IOM' => I18N::translate('Isle of Man'),
2448
            /* I18N: Name of a country or state */
2449
            'IOT' => I18N::translate('British Indian Ocean Territory'),
2450
            /* I18N: Name of a country or state */
2451
            'IRL' => I18N::translate('Ireland'),
2452
            /* I18N: Name of a country or state */
2453
            'IRN' => I18N::translate('Iran'),
2454
            /* I18N: Name of a country or state */
2455
            'IRQ' => I18N::translate('Iraq'),
2456
            /* I18N: Name of a country or state */
2457
            'ISL' => I18N::translate('Iceland'),
2458
            /* I18N: Name of a country or state */
2459
            'ISR' => I18N::translate('Israel'),
2460
            /* I18N: Name of a country or state */
2461
            'ITA' => I18N::translate('Italy'),
2462
            /* I18N: Name of a country or state */
2463
            'JAM' => I18N::translate('Jamaica'),
2464
            //'JEY' => Jersey
2465
            /* I18N: Name of a country or state */
2466
            'JOR' => I18N::translate('Jordan'),
2467
            /* I18N: Name of a country or state */
2468
            'JPN' => I18N::translate('Japan'),
2469
            /* I18N: Name of a country or state */
2470
            'KAZ' => I18N::translate('Kazakhstan'),
2471
            /* I18N: Name of a country or state */
2472
            'KEN' => I18N::translate('Kenya'),
2473
            /* I18N: Name of a country or state */
2474
            'KGZ' => I18N::translate('Kyrgyzstan'),
2475
            /* I18N: Name of a country or state */
2476
            'KHM' => I18N::translate('Cambodia'),
2477
            /* I18N: Name of a country or state */
2478
            'KIR' => I18N::translate('Kiribati'),
2479
            /* I18N: Name of a country or state */
2480
            'KNA' => I18N::translate('Saint Kitts and Nevis'),
2481
            /* I18N: Name of a country or state */
2482
            'KOR' => I18N::translate('Korea'),
2483
            /* I18N: Name of a country or state */
2484
            'KWT' => I18N::translate('Kuwait'),
2485
            /* I18N: Name of a country or state */
2486
            'LAO' => I18N::translate('Laos'),
2487
            /* I18N: Name of a country or state */
2488
            'LBN' => I18N::translate('Lebanon'),
2489
            /* I18N: Name of a country or state */
2490
            'LBR' => I18N::translate('Liberia'),
2491
            /* I18N: Name of a country or state */
2492
            'LBY' => I18N::translate('Libya'),
2493
            /* I18N: Name of a country or state */
2494
            'LCA' => I18N::translate('Saint Lucia'),
2495
            /* I18N: Name of a country or state */
2496
            'LIE' => I18N::translate('Liechtenstein'),
2497
            /* I18N: Name of a country or state */
2498
            'LKA' => I18N::translate('Sri Lanka'),
2499
            /* I18N: Name of a country or state */
2500
            'LSO' => I18N::translate('Lesotho'),
2501
            /* I18N: Name of a country or state */
2502
            'LTU' => I18N::translate('Lithuania'),
2503
            /* I18N: Name of a country or state */
2504
            'LUX' => I18N::translate('Luxembourg'),
2505
            /* I18N: Name of a country or state */
2506
            'LVA' => I18N::translate('Latvia'),
2507
            /* I18N: Name of a country or state */
2508
            'MAC' => I18N::translate('Macau'),
2509
            // MAF => Saint Martin
2510
            /* I18N: Name of a country or state */
2511
            'MAR' => I18N::translate('Morocco'),
2512
            /* I18N: Name of a country or state */
2513
            'MCO' => I18N::translate('Monaco'),
2514
            /* I18N: Name of a country or state */
2515
            'MDA' => I18N::translate('Moldova'),
2516
            /* I18N: Name of a country or state */
2517
            'MDG' => I18N::translate('Madagascar'),
2518
            /* I18N: Name of a country or state */
2519
            'MDV' => I18N::translate('Maldives'),
2520
            /* I18N: Name of a country or state */
2521
            'MEX' => I18N::translate('Mexico'),
2522
            /* I18N: Name of a country or state */
2523
            'MHL' => I18N::translate('Marshall Islands'),
2524
            /* I18N: Name of a country or state */
2525
            'MKD' => I18N::translate('Macedonia'),
2526
            /* I18N: Name of a country or state */
2527
            'MLI' => I18N::translate('Mali'),
2528
            /* I18N: Name of a country or state */
2529
            'MLT' => I18N::translate('Malta'),
2530
            /* I18N: Name of a country or state */
2531
            'MMR' => I18N::translate('Myanmar'),
2532
            /* I18N: Name of a country or state */
2533
            'MNG' => I18N::translate('Mongolia'),
2534
            /* I18N: Name of a country or state */
2535
            'MNP' => I18N::translate('Northern Mariana Islands'),
2536
            /* I18N: Name of a country or state */
2537
            'MNT' => I18N::translate('Montenegro'),
2538
            /* I18N: Name of a country or state */
2539
            'MOZ' => I18N::translate('Mozambique'),
2540
            /* I18N: Name of a country or state */
2541
            'MRT' => I18N::translate('Mauritania'),
2542
            /* I18N: Name of a country or state */
2543
            'MSR' => I18N::translate('Montserrat'),
2544
            /* I18N: Name of a country or state */
2545
            'MTQ' => I18N::translate('Martinique'),
2546
            /* I18N: Name of a country or state */
2547
            'MUS' => I18N::translate('Mauritius'),
2548
            /* I18N: Name of a country or state */
2549
            'MWI' => I18N::translate('Malawi'),
2550
            /* I18N: Name of a country or state */
2551
            'MYS' => I18N::translate('Malaysia'),
2552
            /* I18N: Name of a country or state */
2553
            'MYT' => I18N::translate('Mayotte'),
2554
            /* I18N: Name of a country or state */
2555
            'NAM' => I18N::translate('Namibia'),
2556
            /* I18N: Name of a country or state */
2557
            'NCL' => I18N::translate('New Caledonia'),
2558
            /* I18N: Name of a country or state */
2559
            'NER' => I18N::translate('Niger'),
2560
            /* I18N: Name of a country or state */
2561
            'NFK' => I18N::translate('Norfolk Island'),
2562
            /* I18N: Name of a country or state */
2563
            'NGA' => I18N::translate('Nigeria'),
2564
            /* I18N: Name of a country or state */
2565
            'NIC' => I18N::translate('Nicaragua'),
2566
            /* I18N: Name of a country or state */
2567
            'NIR' => I18N::translate('Northern Ireland'),
2568
            /* I18N: Name of a country or state */
2569
            'NIU' => I18N::translate('Niue'),
2570
            /* I18N: Name of a country or state */
2571
            'NLD' => I18N::translate('Netherlands'),
2572
            /* I18N: Name of a country or state */
2573
            'NOR' => I18N::translate('Norway'),
2574
            /* I18N: Name of a country or state */
2575
            'NPL' => I18N::translate('Nepal'),
2576
            /* I18N: Name of a country or state */
2577
            'NRU' => I18N::translate('Nauru'),
2578
            /* I18N: Name of a country or state */
2579
            'NZL' => I18N::translate('New Zealand'),
2580
            /* I18N: Name of a country or state */
2581
            'OMN' => I18N::translate('Oman'),
2582
            /* I18N: Name of a country or state */
2583
            'PAK' => I18N::translate('Pakistan'),
2584
            /* I18N: Name of a country or state */
2585
            'PAN' => I18N::translate('Panama'),
2586
            /* I18N: Name of a country or state */
2587
            'PCN' => I18N::translate('Pitcairn'),
2588
            /* I18N: Name of a country or state */
2589
            'PER' => I18N::translate('Peru'),
2590
            /* I18N: Name of a country or state */
2591
            'PHL' => I18N::translate('Philippines'),
2592
            /* I18N: Name of a country or state */
2593
            'PLW' => I18N::translate('Palau'),
2594
            /* I18N: Name of a country or state */
2595
            'PNG' => I18N::translate('Papua New Guinea'),
2596
            /* I18N: Name of a country or state */
2597
            'POL' => I18N::translate('Poland'),
2598
            /* I18N: Name of a country or state */
2599
            'PRI' => I18N::translate('Puerto Rico'),
2600
            /* I18N: Name of a country or state */
2601
            'PRK' => I18N::translate('North Korea'),
2602
            /* I18N: Name of a country or state */
2603
            'PRT' => I18N::translate('Portugal'),
2604
            /* I18N: Name of a country or state */
2605
            'PRY' => I18N::translate('Paraguay'),
2606
            /* I18N: Name of a country or state */
2607
            'PSE' => I18N::translate('Occupied Palestinian Territory'),
2608
            /* I18N: Name of a country or state */
2609
            'PYF' => I18N::translate('French Polynesia'),
2610
            /* I18N: Name of a country or state */
2611
            'QAT' => I18N::translate('Qatar'),
2612
            /* I18N: Name of a country or state */
2613
            'REU' => I18N::translate('Réunion'),
2614
            /* I18N: Name of a country or state */
2615
            'ROM' => I18N::translate('Romania'),
2616
            /* I18N: Name of a country or state */
2617
            'RUS' => I18N::translate('Russia'),
2618
            /* I18N: Name of a country or state */
2619
            'RWA' => I18N::translate('Rwanda'),
2620
            /* I18N: Name of a country or state */
2621
            'SAU' => I18N::translate('Saudi Arabia'),
2622
            /* I18N: Name of a country or state */
2623
            'SCT' => I18N::translate('Scotland'),
2624
            /* I18N: Name of a country or state */
2625
            'SDN' => I18N::translate('Sudan'),
2626
            /* I18N: Name of a country or state */
2627
            'SEA' => I18N::translate('At sea'),
2628
            /* I18N: Name of a country or state */
2629
            'SEN' => I18N::translate('Senegal'),
2630
            /* I18N: Name of a country or state */
2631
            'SER' => I18N::translate('Serbia'),
2632
            /* I18N: Name of a country or state */
2633
            'SGP' => I18N::translate('Singapore'),
2634
            /* I18N: Name of a country or state */
2635
            'SGS' => I18N::translate('South Georgia and the South Sandwich Islands'),
2636
            /* I18N: Name of a country or state */
2637
            'SHN' => I18N::translate('Saint Helena'),
2638
            /* I18N: Name of a country or state */
2639
            'SJM' => I18N::translate('Svalbard and Jan Mayen'),
2640
            /* I18N: Name of a country or state */
2641
            'SLB' => I18N::translate('Solomon Islands'),
2642
            /* I18N: Name of a country or state */
2643
            'SLE' => I18N::translate('Sierra Leone'),
2644
            /* I18N: Name of a country or state */
2645
            'SLV' => I18N::translate('El Salvador'),
2646
            /* I18N: Name of a country or state */
2647
            'SMR' => I18N::translate('San Marino'),
2648
            /* I18N: Name of a country or state */
2649
            'SOM' => I18N::translate('Somalia'),
2650
            /* I18N: Name of a country or state */
2651
            'SPM' => I18N::translate('Saint Pierre and Miquelon'),
2652
            /* I18N: Name of a country or state */
2653
            'SSD' => I18N::translate('South Sudan'),
2654
            /* I18N: Name of a country or state */
2655
            'STP' => I18N::translate('Sao Tome and Principe'),
2656
            /* I18N: Name of a country or state */
2657
            'SUR' => I18N::translate('Suriname'),
2658
            /* I18N: Name of a country or state */
2659
            'SVK' => I18N::translate('Slovakia'),
2660
            /* I18N: Name of a country or state */
2661
            'SVN' => I18N::translate('Slovenia'),
2662
            /* I18N: Name of a country or state */
2663
            'SWE' => I18N::translate('Sweden'),
2664
            /* I18N: Name of a country or state */
2665
            'SWZ' => I18N::translate('Swaziland'),
2666
            // SXM => Sint Maarten
2667
            /* I18N: Name of a country or state */
2668
            'SYC' => I18N::translate('Seychelles'),
2669
            /* I18N: Name of a country or state */
2670
            'SYR' => I18N::translate('Syria'),
2671
            /* I18N: Name of a country or state */
2672
            'TCA' => I18N::translate('Turks and Caicos Islands'),
2673
            /* I18N: Name of a country or state */
2674
            'TCD' => I18N::translate('Chad'),
2675
            /* I18N: Name of a country or state */
2676
            'TGO' => I18N::translate('Togo'),
2677
            /* I18N: Name of a country or state */
2678
            'THA' => I18N::translate('Thailand'),
2679
            /* I18N: Name of a country or state */
2680
            'TJK' => I18N::translate('Tajikistan'),
2681
            /* I18N: Name of a country or state */
2682
            'TKL' => I18N::translate('Tokelau'),
2683
            /* I18N: Name of a country or state */
2684
            'TKM' => I18N::translate('Turkmenistan'),
2685
            /* I18N: Name of a country or state */
2686
            'TLS' => I18N::translate('Timor-Leste'),
2687
            /* I18N: Name of a country or state */
2688
            'TON' => I18N::translate('Tonga'),
2689
            /* I18N: Name of a country or state */
2690
            'TTO' => I18N::translate('Trinidad and Tobago'),
2691
            /* I18N: Name of a country or state */
2692
            'TUN' => I18N::translate('Tunisia'),
2693
            /* I18N: Name of a country or state */
2694
            'TUR' => I18N::translate('Turkey'),
2695
            /* I18N: Name of a country or state */
2696
            'TUV' => I18N::translate('Tuvalu'),
2697
            /* I18N: Name of a country or state */
2698
            'TWN' => I18N::translate('Taiwan'),
2699
            /* I18N: Name of a country or state */
2700
            'TZA' => I18N::translate('Tanzania'),
2701
            /* I18N: Name of a country or state */
2702
            'UGA' => I18N::translate('Uganda'),
2703
            /* I18N: Name of a country or state */
2704
            'UKR' => I18N::translate('Ukraine'),
2705
            /* I18N: Name of a country or state */
2706
            'UMI' => I18N::translate('US Minor Outlying Islands'),
2707
            /* I18N: Name of a country or state */
2708
            'URY' => I18N::translate('Uruguay'),
2709
            /* I18N: Name of a country or state */
2710
            'USA' => I18N::translate('United States'),
2711
            /* I18N: Name of a country or state */
2712
            'UZB' => I18N::translate('Uzbekistan'),
2713
            /* I18N: Name of a country or state */
2714
            'VAT' => I18N::translate('Vatican City'),
2715
            /* I18N: Name of a country or state */
2716
            'VCT' => I18N::translate('Saint Vincent and the Grenadines'),
2717
            /* I18N: Name of a country or state */
2718
            'VEN' => I18N::translate('Venezuela'),
2719
            /* I18N: Name of a country or state */
2720
            'VGB' => I18N::translate('British Virgin Islands'),
2721
            /* I18N: Name of a country or state */
2722
            'VIR' => I18N::translate('US Virgin Islands'),
2723
            /* I18N: Name of a country or state */
2724
            'VNM' => I18N::translate('Vietnam'),
2725
            /* I18N: Name of a country or state */
2726
            'VUT' => I18N::translate('Vanuatu'),
2727
            /* I18N: Name of a country or state */
2728
            'WLF' => I18N::translate('Wallis and Futuna'),
2729
            /* I18N: Name of a country or state */
2730
            'WLS' => I18N::translate('Wales'),
2731
            /* I18N: Name of a country or state */
2732
            'WSM' => I18N::translate('Samoa'),
2733
            /* I18N: Name of a country or state */
2734
            'YEM' => I18N::translate('Yemen'),
2735
            /* I18N: Name of a country or state */
2736
            'ZAF' => I18N::translate('South Africa'),
2737
            /* I18N: Name of a country or state */
2738
            'ZMB' => I18N::translate('Zambia'),
2739
            /* I18N: Name of a country or state */
2740
            'ZWE' => I18N::translate('Zimbabwe'),
2741
        ];
2742
    }
2743
2744
    /**
2745
     * ISO3166 3 letter codes, with their 2 letter equivalent.
2746
     * NOTE: this is not 1:1. ENG/SCO/WAL/NIR => GB
2747
     * NOTE: this also includes chapman codes and others. Should it?
2748
     *
2749
     * @return array<string>
2750
     */
2751
    private function iso3166(): array
2752
    {
2753
        return [
2754
            'GBR' => 'GB', // Must come before ENG, NIR, SCT and WLS
2755
            'ABW' => 'AW',
2756
            'AFG' => 'AF',
2757
            'AGO' => 'AO',
2758
            'AIA' => 'AI',
2759
            'ALA' => 'AX',
2760
            'ALB' => 'AL',
2761
            'AND' => 'AD',
2762
            'ARE' => 'AE',
2763
            'ARG' => 'AR',
2764
            'ARM' => 'AM',
2765
            'ASM' => 'AS',
2766
            'ATA' => 'AQ',
2767
            'ATF' => 'TF',
2768
            'ATG' => 'AG',
2769
            'AUS' => 'AU',
2770
            'AUT' => 'AT',
2771
            'AZE' => 'AZ',
2772
            'BDI' => 'BI',
2773
            'BEL' => 'BE',
2774
            'BEN' => 'BJ',
2775
            'BFA' => 'BF',
2776
            'BGD' => 'BD',
2777
            'BGR' => 'BG',
2778
            'BHR' => 'BH',
2779
            'BHS' => 'BS',
2780
            'BIH' => 'BA',
2781
            'BLR' => 'BY',
2782
            'BLZ' => 'BZ',
2783
            'BMU' => 'BM',
2784
            'BOL' => 'BO',
2785
            'BRA' => 'BR',
2786
            'BRB' => 'BB',
2787
            'BRN' => 'BN',
2788
            'BTN' => 'BT',
2789
            'BVT' => 'BV',
2790
            'BWA' => 'BW',
2791
            'CAF' => 'CF',
2792
            'CAN' => 'CA',
2793
            'CCK' => 'CC',
2794
            'CHE' => 'CH',
2795
            'CHL' => 'CL',
2796
            'CHN' => 'CN',
2797
            'CIV' => 'CI',
2798
            'CMR' => 'CM',
2799
            'COD' => 'CD',
2800
            'COG' => 'CG',
2801
            'COK' => 'CK',
2802
            'COL' => 'CO',
2803
            'COM' => 'KM',
2804
            'CPV' => 'CV',
2805
            'CRI' => 'CR',
2806
            'CUB' => 'CU',
2807
            'CXR' => 'CX',
2808
            'CYM' => 'KY',
2809
            'CYP' => 'CY',
2810
            'CZE' => 'CZ',
2811
            'DEU' => 'DE',
2812
            'DJI' => 'DJ',
2813
            'DMA' => 'DM',
2814
            'DNK' => 'DK',
2815
            'DOM' => 'DO',
2816
            'DZA' => 'DZ',
2817
            'ECU' => 'EC',
2818
            'EGY' => 'EG',
2819
            'ENG' => 'GB',
2820
            'ERI' => 'ER',
2821
            'ESH' => 'EH',
2822
            'ESP' => 'ES',
2823
            'EST' => 'EE',
2824
            'ETH' => 'ET',
2825
            'FIN' => 'FI',
2826
            'FJI' => 'FJ',
2827
            'FLK' => 'FK',
2828
            'FRA' => 'FR',
2829
            'FRO' => 'FO',
2830
            'FSM' => 'FM',
2831
            'GAB' => 'GA',
2832
            'GEO' => 'GE',
2833
            'GHA' => 'GH',
2834
            'GIB' => 'GI',
2835
            'GIN' => 'GN',
2836
            'GLP' => 'GP',
2837
            'GMB' => 'GM',
2838
            'GNB' => 'GW',
2839
            'GNQ' => 'GQ',
2840
            'GRC' => 'GR',
2841
            'GRD' => 'GD',
2842
            'GRL' => 'GL',
2843
            'GTM' => 'GT',
2844
            'GUF' => 'GF',
2845
            'GUM' => 'GU',
2846
            'GUY' => 'GY',
2847
            'HKG' => 'HK',
2848
            'HMD' => 'HM',
2849
            'HND' => 'HN',
2850
            'HRV' => 'HR',
2851
            'HTI' => 'HT',
2852
            'HUN' => 'HU',
2853
            'IDN' => 'ID',
2854
            'IND' => 'IN',
2855
            'IOT' => 'IO',
2856
            'IRL' => 'IE',
2857
            'IRN' => 'IR',
2858
            'IRQ' => 'IQ',
2859
            'ISL' => 'IS',
2860
            'ISR' => 'IL',
2861
            'ITA' => 'IT',
2862
            'JAM' => 'JM',
2863
            'JOR' => 'JO',
2864
            'JPN' => 'JP',
2865
            'KAZ' => 'KZ',
2866
            'KEN' => 'KE',
2867
            'KGZ' => 'KG',
2868
            'KHM' => 'KH',
2869
            'KIR' => 'KI',
2870
            'KNA' => 'KN',
2871
            'KOR' => 'KO',
2872
            'KWT' => 'KW',
2873
            'LAO' => 'LA',
2874
            'LBN' => 'LB',
2875
            'LBR' => 'LR',
2876
            'LBY' => 'LY',
2877
            'LCA' => 'LC',
2878
            'LIE' => 'LI',
2879
            'LKA' => 'LK',
2880
            'LSO' => 'LS',
2881
            'LTU' => 'LT',
2882
            'LUX' => 'LU',
2883
            'LVA' => 'LV',
2884
            'MAC' => 'MO',
2885
            'MAR' => 'MA',
2886
            'MCO' => 'MC',
2887
            'MDA' => 'MD',
2888
            'MDG' => 'MG',
2889
            'MDV' => 'MV',
2890
            'MEX' => 'MX',
2891
            'MHL' => 'MH',
2892
            'MKD' => 'MK',
2893
            'MLI' => 'ML',
2894
            'MLT' => 'MT',
2895
            'MMR' => 'MM',
2896
            'MNG' => 'MN',
2897
            'MNP' => 'MP',
2898
            'MNT' => 'ME',
2899
            'MOZ' => 'MZ',
2900
            'MRT' => 'MR',
2901
            'MSR' => 'MS',
2902
            'MTQ' => 'MQ',
2903
            'MUS' => 'MU',
2904
            'MWI' => 'MW',
2905
            'MYS' => 'MY',
2906
            'MYT' => 'YT',
2907
            'NAM' => 'NA',
2908
            'NCL' => 'NC',
2909
            'NER' => 'NE',
2910
            'NFK' => 'NF',
2911
            'NGA' => 'NG',
2912
            'NIC' => 'NI',
2913
            'NIR' => 'GB',
2914
            'NIU' => 'NU',
2915
            'NLD' => 'NL',
2916
            'NOR' => 'NO',
2917
            'NPL' => 'NP',
2918
            'NRU' => 'NR',
2919
            'NZL' => 'NZ',
2920
            'OMN' => 'OM',
2921
            'PAK' => 'PK',
2922
            'PAN' => 'PA',
2923
            'PCN' => 'PN',
2924
            'PER' => 'PE',
2925
            'PHL' => 'PH',
2926
            'PLW' => 'PW',
2927
            'PNG' => 'PG',
2928
            'POL' => 'PL',
2929
            'PRI' => 'PR',
2930
            'PRK' => 'KP',
2931
            'PRT' => 'PT',
2932
            'PRY' => 'PY',
2933
            'PSE' => 'PS',
2934
            'PYF' => 'PF',
2935
            'QAT' => 'QA',
2936
            'REU' => 'RE',
2937
            'ROM' => 'RO',
2938
            'RUS' => 'RU',
2939
            'RWA' => 'RW',
2940
            'SAU' => 'SA',
2941
            'SCT' => 'GB',
2942
            'SDN' => 'SD',
2943
            'SEN' => 'SN',
2944
            'SER' => 'RS',
2945
            'SGP' => 'SG',
2946
            'SGS' => 'GS',
2947
            'SHN' => 'SH',
2948
            'SJM' => 'SJ',
2949
            'SLB' => 'SB',
2950
            'SLE' => 'SL',
2951
            'SLV' => 'SV',
2952
            'SMR' => 'SM',
2953
            'SOM' => 'SO',
2954
            'SPM' => 'PM',
2955
            'STP' => 'ST',
2956
            'SUR' => 'SR',
2957
            'SVK' => 'SK',
2958
            'SVN' => 'SI',
2959
            'SWE' => 'SE',
2960
            'SWZ' => 'SZ',
2961
            'SYC' => 'SC',
2962
            'SYR' => 'SY',
2963
            'TCA' => 'TC',
2964
            'TCD' => 'TD',
2965
            'TGO' => 'TG',
2966
            'THA' => 'TH',
2967
            'TJK' => 'TJ',
2968
            'TKL' => 'TK',
2969
            'TKM' => 'TM',
2970
            'TLS' => 'TL',
2971
            'TON' => 'TO',
2972
            'TTO' => 'TT',
2973
            'TUN' => 'TN',
2974
            'TUR' => 'TR',
2975
            'TUV' => 'TV',
2976
            'TWN' => 'TW',
2977
            'TZA' => 'TZ',
2978
            'UGA' => 'UG',
2979
            'UKR' => 'UA',
2980
            'UMI' => 'UM',
2981
            'URY' => 'UY',
2982
            'USA' => 'US',
2983
            'UZB' => 'UZ',
2984
            'VAT' => 'VA',
2985
            'VCT' => 'VC',
2986
            'VEN' => 'VE',
2987
            'VGB' => 'VG',
2988
            'VIR' => 'VI',
2989
            'VNM' => 'VN',
2990
            'VUT' => 'VU',
2991
            'WLF' => 'WF',
2992
            'WLS' => 'GB',
2993
            'WSM' => 'WS',
2994
            'YEM' => 'YE',
2995
            'ZAF' => 'ZA',
2996
            'ZMB' => 'ZM',
2997
            'ZWE' => 'ZW',
2998
        ];
2999
    }
3000
3001
    /**
3002
     * Returns the translated country name based on the given two letter country code.
3003
     */
3004
    private function mapTwoLetterToName(string $twoLetterCode): string
3005
    {
3006
        $threeLetterCode = array_search($twoLetterCode, $this->iso3166(), true);
3007
        $threeLetterCode = $threeLetterCode ?: '???';
3008
3009
        return $this->getAllCountries()[$threeLetterCode];
3010
    }
3011
}
3012