Passed
Push — main ( 0408cc...5ac16c )
by Greg
07:40
created

BranchesListModule::getDescendantsHtml()   F

Complexity

Conditions 19
Paths 483

Size

Total Lines 91
Code Lines 56

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 19
eloc 56
nc 483
nop 8
dl 0
loc 91
rs 1.068
c 0
b 0
f 0

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2023 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Module;
21
22
use Fig\Http\Message\RequestMethodInterface;
23
use Fisharebest\Webtrees\Auth;
24
use Fisharebest\Webtrees\Contracts\UserInterface;
25
use Fisharebest\Webtrees\DB;
26
use Fisharebest\Webtrees\Elements\PedigreeLinkageType;
27
use Fisharebest\Webtrees\Family;
28
use Fisharebest\Webtrees\GedcomRecord;
29
use Fisharebest\Webtrees\I18N;
30
use Fisharebest\Webtrees\Individual;
31
use Fisharebest\Webtrees\Registry;
32
use Fisharebest\Webtrees\Services\ModuleService;
33
use Fisharebest\Webtrees\Soundex;
34
use Fisharebest\Webtrees\Tree;
35
use Fisharebest\Webtrees\Validator;
36
use Illuminate\Database\Query\Builder;
37
use Illuminate\Database\Query\JoinClause;
38
use Psr\Http\Message\ResponseInterface;
39
use Psr\Http\Message\ServerRequestInterface;
40
use Psr\Http\Server\RequestHandlerInterface;
41
42
use function array_search;
43
use function e;
44
use function explode;
45
use function in_array;
46
use function is_int;
47
use function key;
48
use function log;
49
use function next;
50
use function redirect;
51
use function route;
52
use function strip_tags;
53
use function stripos;
54
use function strtolower;
55
use function usort;
56
use function view;
57
58
/**
59
 * Class BranchesListModule
60
 */
61
class BranchesListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface
62
{
63
    use ModuleListTrait;
64
65
    protected const ROUTE_URL = '/tree/{tree}/branches{/surname}';
66
67
    private ModuleService $module_service;
68
69
    /**
70
     * @param ModuleService $module_service
71
     */
72
    public function __construct(ModuleService $module_service)
73
    {
74
        $this->module_service = $module_service;
75
    }
76
77
    /**
78
     * Initialization.
79
     *
80
     * @return void
81
     */
82
    public function boot(): void
83
    {
84
        Registry::routeFactory()->routeMap()
85
            ->get(static::class, static::ROUTE_URL, $this)
86
            ->allows(RequestMethodInterface::METHOD_POST);
87
    }
88
89
    /**
90
     * How should this module be identified in the control panel, etc.?
91
     *
92
     * @return string
93
     */
94
    public function title(): string
95
    {
96
        /* I18N: Name of a module/list */
97
        return I18N::translate('Branches');
98
    }
99
100
    /**
101
     * A sentence describing what this module does.
102
     *
103
     * @return string
104
     */
105
    public function description(): string
106
    {
107
        /* I18N: Description of the “Branches” module */
108
        return I18N::translate('A list of branches of a family.');
109
    }
110
111
    /**
112
     * CSS class for the URL.
113
     *
114
     * @return string
115
     */
116
    public function listMenuClass(): string
117
    {
118
        return 'menu-branches';
119
    }
120
121
    /**
122
     * @param Tree                                      $tree
123
     * @param array<bool|int|string|array<string>|null> $parameters
124
     *
125
     * @return string
126
     */
127
    public function listUrl(Tree $tree, array $parameters = []): string
128
    {
129
        $request = Registry::container()->get(ServerRequestInterface::class);
130
        $xref    = Validator::attributes($request)->isXref()->string('xref', '');
131
132
        if ($xref !== '') {
133
            $individual = Registry::individualFactory()->make($xref, $tree);
134
135
            if ($individual instanceof Individual && $individual->canShow()) {
136
                $parameters['surname'] ??= $individual->getAllNames()[0]['surn'] ?? null;
137
            }
138
        }
139
140
        $parameters['tree'] = $tree->name();
141
142
        return route(static::class, $parameters);
143
    }
144
145
    /**
146
     * @return array<string>
147
     */
148
    public function listUrlAttributes(): array
149
    {
150
        return [];
151
    }
152
153
    /**
154
     * @param ServerRequestInterface $request
155
     *
156
     * @return ResponseInterface
157
     */
158
    public function handle(ServerRequestInterface $request): ResponseInterface
159
    {
160
        $tree = Validator::attributes($request)->tree();
161
        $user = Validator::attributes($request)->user();
162
163
        Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user);
164
165
        // Convert POST requests into GET requests for pretty URLs.
166
        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
167
            return redirect($this->listUrl($tree, [
168
                'soundex_dm'  => Validator::parsedBody($request)->boolean('soundex_dm', false),
169
                'soundex_std' => Validator::parsedBody($request)->boolean('soundex_std', false),
170
                'surname'     => Validator::parsedBody($request)->string('surname'),
171
            ]));
172
        }
173
174
        $surname     = Validator::attributes($request)->string('surname', '');
175
        $soundex_std = Validator::queryParams($request)->boolean('soundex_std', false);
176
        $soundex_dm  = Validator::queryParams($request)->boolean('soundex_dm', false);
177
        $ajax        = Validator::queryParams($request)->boolean('ajax', false);
178
179
        if ($ajax) {
180
            $this->layout = 'layouts/ajax';
181
182
            // Highlight direct-line ancestors of this individual.
183
            $xref = $tree->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF);
184
            $self = Registry::individualFactory()->make($xref, $tree);
185
186
            if ($surname !== '') {
187
                $individuals = $this->loadIndividuals($tree, $surname, $soundex_dm, $soundex_std);
188
            } else {
189
                $individuals = [];
190
            }
191
192
            if ($self instanceof Individual) {
193
                $ancestors = $this->allAncestors($self);
194
            } else {
195
                $ancestors = [];
196
            }
197
198
            return $this->viewResponse('modules/branches/list', [
199
                'branches' => $this->getPatriarchsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std),
200
            ]);
201
        }
202
203
        if ($surname !== '') {
204
            /* I18N: %s is a surname */
205
            $title = I18N::translate('Branches of the %s family', e($surname));
206
207
            $ajax_url = $this->listUrl($tree, [
208
                'ajax'        => true,
209
                'soundex_dm'  => $soundex_dm,
210
                'soundex_std' => $soundex_std,
211
                'surname'     => $surname,
212
            ]);
213
        } else {
214
            /* I18N: Branches of a family tree */
215
            $title = I18N::translate('Branches');
216
217
            $ajax_url = '';
218
        }
219
220
        return $this->viewResponse('branches-page', [
221
            'ajax_url'    => $ajax_url,
222
            'soundex_dm'  => $soundex_dm,
223
            'soundex_std' => $soundex_std,
224
            'surname'     => $surname,
225
            'title'       => $title,
226
            'tree'        => $tree,
227
        ]);
228
    }
229
230
    /**
231
     * Find all ancestors of an individual, indexed by the Sosa-Stradonitz number.
232
     *
233
     * @param Individual $individual
234
     *
235
     * @return array<Individual>
236
     */
237
    private function allAncestors(Individual $individual): array
238
    {
239
        $ancestors = [
240
            1 => $individual,
241
        ];
242
243
        do {
244
            $sosa = key($ancestors);
245
246
            $family = $ancestors[$sosa]->childFamilies()->first();
247
248
            if ($family !== null) {
249
                if ($family->husband() !== null) {
250
                    $ancestors[$sosa * 2] = $family->husband();
251
                }
252
                if ($family->wife() !== null) {
253
                    $ancestors[$sosa * 2 + 1] = $family->wife();
254
                }
255
            }
256
        } while (next($ancestors));
257
258
        return $ancestors;
259
    }
260
261
    /**
262
     * Fetch all individuals with a matching surname
263
     *
264
     * @param Tree   $tree
265
     * @param string $surname
266
     * @param bool   $soundex_dm
267
     * @param bool   $soundex_std
268
     *
269
     * @return array<Individual>
270
     */
271
    private function loadIndividuals(Tree $tree, string $surname, bool $soundex_dm, bool $soundex_std): array
272
    {
273
        $individuals = DB::table('individuals')
274
            ->join('name', static function (JoinClause $join): void {
275
                $join
276
                    ->on('name.n_file', '=', 'individuals.i_file')
277
                    ->on('name.n_id', '=', 'individuals.i_id');
278
            })
279
            ->where('i_file', '=', $tree->id())
280
            ->where('n_type', '<>', '_MARNM')
281
            ->where(static function (Builder $query) use ($surname, $soundex_dm, $soundex_std): void {
282
                $query
283
                    ->where('n_surn', '=', $surname)
284
                    ->orWhere('n_surname', '=', $surname);
285
286
                if ($soundex_std) {
287
                    $sdx = Soundex::russell($surname);
288
                    if ($sdx !== '') {
289
                        foreach (explode(':', $sdx) as $value) {
290
                            $query->orWhere('n_soundex_surn_std', 'LIKE', '%' . $value . '%');
291
                        }
292
                    }
293
                }
294
295
                if ($soundex_dm) {
296
                    $sdx = Soundex::daitchMokotoff($surname);
297
                    if ($sdx !== '') {
298
                        foreach (explode(':', $sdx) as $value) {
299
                            $query->orWhere('n_soundex_surn_dm', 'LIKE', '%' . $value . '%');
300
                        }
301
                    }
302
                }
303
            })
304
            ->distinct()
305
            ->select(['individuals.*'])
306
            ->get()
307
            ->map(Registry::individualFactory()->mapper($tree))
308
            ->filter(GedcomRecord::accessFilter())
309
            ->all();
310
311
        usort($individuals, Individual::birthDateComparator());
312
313
        return $individuals;
314
    }
315
316
    /**
317
     * For each individual with no ancestors, list their descendants.
318
     *
319
     * @param Tree              $tree
320
     * @param array<Individual> $individuals
321
     * @param array<Individual> $ancestors
322
     * @param string            $surname
323
     * @param bool              $soundex_dm
324
     * @param bool              $soundex_std
325
     *
326
     * @return string
327
     */
328
    private function getPatriarchsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std): string
329
    {
330
        $html = '';
331
        foreach ($individuals as $individual) {
332
            foreach ($individual->childFamilies() as $family) {
333
                foreach ($family->spouses() as $parent) {
334
                    if (in_array($parent, $individuals, true)) {
335
                        continue 3;
336
                    }
337
                }
338
            }
339
            $html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $individual, null);
340
        }
341
342
        return $html;
343
    }
344
345
    /**
346
     * Generate a recursive list of descendants of an individual.
347
     * If parents are specified, we can also show the pedigree (adopted, etc.).
348
     *
349
     * @param Tree              $tree
350
     * @param array<Individual> $individuals
351
     * @param array<Individual> $ancestors
352
     * @param string            $surname
353
     * @param bool              $soundex_dm
354
     * @param bool              $soundex_std
355
     * @param Individual        $individual
356
     * @param Family|null       $parents
357
     *
358
     * @return string
359
     */
360
    private function getDescendantsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std, Individual $individual, Family|null $parents = null): string
361
    {
362
        $module = $this->module_service
363
            ->findByComponent(ModuleChartInterface::class, $tree, Auth::user())
364
            ->first(static fn (ModuleInterface $module) => $module instanceof RelationshipsChartModule);
365
366
        // A person has many names. Select the one that matches the searched surname
367
        $person_name = '';
368
        foreach ($individual->getAllNames() as $name) {
369
            [$surn1] = explode(',', $name['sort']);
370
            if ($this->surnamesMatch($surn1, $surname, $soundex_std, $soundex_dm)) {
371
                $person_name = $name['full'];
372
                break;
373
            }
374
        }
375
376
        // No matching name? Typically children with a different surname. The branch stops here.
377
        if ($person_name === '') {
378
            return '<li title="' . strip_tags($individual->fullName()) . '" class="wt-branch-split"><small>' . view('icons/sex', ['sex' => $individual->sex()]) . '</small>…</li>';
379
        }
380
381
        // Is this individual one of our ancestors?
382
        $sosa = array_search($individual, $ancestors, true);
383
        if (is_int($sosa) && $module instanceof RelationshipsChartModule) {
384
            $sosa_class = 'search_hit';
385
            $sosa_html  = ' <a class="small wt-chart-box-' . strtolower($individual->sex()) . '" href="' . e($module->chartUrl($individual, ['xref2' => $ancestors[1]->xref()])) . '" rel="nofollow" title="' . I18N::translate('Relationship') . '">' . I18N::number($sosa) . '</a>' . self::sosaGeneration($sosa);
386
        } else {
387
            $sosa_class = '';
388
            $sosa_html  = '';
389
        }
390
391
        // Generate HTML for this individual, and all their descendants
392
        $indi_html = '<small>' . view('icons/sex', ['sex' => $individual->sex()]) . '</small><a class="' . $sosa_class . '" href="' . e($individual->url()) . '">' . $person_name . '</a> ' . $individual->lifespan() . $sosa_html;
393
394
        // If this is not a birth pedigree (e.g. an adoption), highlight it
395
        if ($parents instanceof Family) {
396
            foreach ($individual->facts(['FAMC']) as $fact) {
397
                if ($fact->target() === $parents) {
398
                    $pedi = $fact->attribute('PEDI');
399
400
                    if ($pedi !== '' && $pedi !== PedigreeLinkageType::VALUE_BIRTH) {
401
                        $pedigree  = Registry::elementFactory()->make('INDI:FAMC:PEDI')->value($pedi, $tree);
402
                        $indi_html = '<span class="red">' . $pedigree . '</span> ' . $indi_html;
403
                    }
404
                    break;
405
                }
406
            }
407
        }
408
409
        // spouses and children
410
        $spouse_families = $individual->spouseFamilies()
411
            ->sort(Family::marriageDateComparator());
412
413
        if ($spouse_families->isNotEmpty()) {
414
            $fam_html = '';
415
            foreach ($spouse_families as $family) {
416
                $fam_html .= $indi_html; // Repeat the individual details for each spouse.
417
418
                $spouse = $family->spouse($individual);
419
                if ($spouse instanceof Individual) {
420
                    $sosa = array_search($spouse, $ancestors, true);
421
                    if (is_int($sosa) && $module instanceof RelationshipsChartModule) {
422
                        $sosa_class = 'search_hit';
423
                        $sosa_html  = ' <a class="small wt-chart-box-' . strtolower($spouse->sex()) . '" href="' . e($module->chartUrl($spouse, ['xref2' => $ancestors[1]->xref()])) . '" rel="nofollow" title="' . I18N::translate('Relationship') . '">' . I18N::number($sosa) . '</a>' . self::sosaGeneration($sosa);
424
                    } else {
425
                        $sosa_class = '';
426
                        $sosa_html  = '';
427
                    }
428
                    $marriage_year = $family->getMarriageYear();
429
                    if ($marriage_year) {
430
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . strip_tags($family->getMarriageDate()->display()) . '"><i class="icon-rings"></i>' . $marriage_year . '</a>';
431
                    } elseif ($family->facts(['MARR'])->isNotEmpty()) {
432
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Marriage') . '"><i class="icon-rings"></i></a>';
433
                    } else {
434
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Not married') . '"><i class="icon-rings"></i></a>';
435
                    }
436
                    $fam_html .= ' <small>' . view('icons/sex', ['sex' => $spouse->sex()]) . '</small><a class="' . $sosa_class . '" href="' . e($spouse->url()) . '">' . $spouse->fullName() . '</a> ' . $spouse->lifespan() . ' ' . $sosa_html;
437
                }
438
439
                $fam_html .= '<ol>';
440
                foreach ($family->children() as $child) {
441
                    $fam_html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $child, $family);
442
                }
443
                $fam_html .= '</ol>';
444
            }
445
446
            return '<li>' . $fam_html . '</li>';
447
        }
448
449
        // No spouses - just show the individual
450
        return '<li>' . $indi_html . '</li>';
451
    }
452
453
    /**
454
     * Do two surnames match?
455
     *
456
     * @param string $surname1
457
     * @param string $surname2
458
     * @param bool   $soundex_std
459
     * @param bool   $soundex_dm
460
     *
461
     * @return bool
462
     */
463
    private function surnamesMatch(string $surname1, string $surname2, bool $soundex_std, bool $soundex_dm): bool
464
    {
465
        // One name sounds like another?
466
        if ($soundex_std && Soundex::compare(Soundex::russell($surname1), Soundex::russell($surname2))) {
467
            return true;
468
        }
469
        if ($soundex_dm && Soundex::compare(Soundex::daitchMokotoff($surname1), Soundex::daitchMokotoff($surname2))) {
470
            return true;
471
        }
472
473
        // One is a substring of the other.  e.g. Halen / Van Halen
474
        return stripos($surname1, $surname2) !== false || stripos($surname2, $surname1) !== false;
475
    }
476
477
    /**
478
     * Convert a SOSA number into a generation number. e.g. 8 = great-grandfather = 3 generations
479
     *
480
     * @param int $sosa
481
     *
482
     * @return string
483
     */
484
    private static function sosaGeneration(int $sosa): string
485
    {
486
        $generation = (int) log($sosa, 2) + 1;
487
488
        return '<sup title="' . I18N::translate('Generation') . '">' . $generation . '</sup>';
489
    }
490
}
491