Passed
Push — dbal ( 2c143b...62ba6c )
by Greg
22:11 queued 07:07
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
    public function description(): string
101
    {
102
        /* I18N: Description of the “Branches” module */
103
        return I18N::translate('A list of branches of a family.');
104
    }
105
106
    /**
107
     * CSS class for the URL.
108
     *
109
     * @return string
110
     */
111
    public function listMenuClass(): string
112
    {
113
        return 'menu-branches';
114
    }
115
116
    /**
117
     * @param Tree                                      $tree
118
     * @param array<bool|int|string|array<string>|null> $parameters
119
     *
120
     * @return string
121
     */
122
    public function listUrl(Tree $tree, array $parameters = []): string
123
    {
124
        $request = Registry::container()->get(ServerRequestInterface::class);
125
        $xref    = Validator::attributes($request)->isXref()->string('xref', '');
126
127
        if ($xref !== '') {
128
            $individual = Registry::individualFactory()->make($xref, $tree);
129
130
            if ($individual instanceof Individual && $individual->canShow()) {
131
                $parameters['surname'] ??= $individual->getAllNames()[0]['surn'] ?? null;
132
            }
133
        }
134
135
        $parameters['tree'] = $tree->name();
136
137
        return route(static::class, $parameters);
138
    }
139
140
    /**
141
     * @return array<string>
142
     */
143
    public function listUrlAttributes(): array
144
    {
145
        return [];
146
    }
147
148
    /**
149
     * @param ServerRequestInterface $request
150
     *
151
     * @return ResponseInterface
152
     */
153
    public function handle(ServerRequestInterface $request): ResponseInterface
154
    {
155
        $tree = Validator::attributes($request)->tree();
156
        $user = Validator::attributes($request)->user();
157
158
        Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user);
159
160
        // Convert POST requests into GET requests for pretty URLs.
161
        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
162
            return redirect($this->listUrl($tree, [
163
                'soundex_dm'  => Validator::parsedBody($request)->boolean('soundex_dm', false),
164
                'soundex_std' => Validator::parsedBody($request)->boolean('soundex_std', false),
165
                'surname'     => Validator::parsedBody($request)->string('surname'),
166
            ]));
167
        }
168
169
        $surname     = Validator::attributes($request)->string('surname', '');
170
        $soundex_std = Validator::queryParams($request)->boolean('soundex_std', false);
171
        $soundex_dm  = Validator::queryParams($request)->boolean('soundex_dm', false);
172
        $ajax        = Validator::queryParams($request)->boolean('ajax', false);
173
174
        if ($ajax) {
175
            $this->layout = 'layouts/ajax';
176
177
            // Highlight direct-line ancestors of this individual.
178
            $xref = $tree->getUserPreference($user, UserInterface::PREF_TREE_ACCOUNT_XREF);
179
            $self = Registry::individualFactory()->make($xref, $tree);
180
181
            if ($surname !== '') {
182
                $individuals = $this->loadIndividuals($tree, $surname, $soundex_dm, $soundex_std);
183
            } else {
184
                $individuals = [];
185
            }
186
187
            if ($self instanceof Individual) {
188
                $ancestors = $this->allAncestors($self);
189
            } else {
190
                $ancestors = [];
191
            }
192
193
            return $this->viewResponse('modules/branches/list', [
194
                'branches' => $this->getPatriarchsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std),
195
            ]);
196
        }
197
198
        if ($surname !== '') {
199
            /* I18N: %s is a surname */
200
            $title = I18N::translate('Branches of the %s family', e($surname));
201
202
            $ajax_url = $this->listUrl($tree, [
203
                'ajax'        => true,
204
                'soundex_dm'  => $soundex_dm,
205
                'soundex_std' => $soundex_std,
206
                'surname'     => $surname,
207
            ]);
208
        } else {
209
            /* I18N: Branches of a family tree */
210
            $title = I18N::translate('Branches');
211
212
            $ajax_url = '';
213
        }
214
215
        return $this->viewResponse('branches-page', [
216
            'ajax_url'    => $ajax_url,
217
            'soundex_dm'  => $soundex_dm,
218
            'soundex_std' => $soundex_std,
219
            'surname'     => $surname,
220
            'title'       => $title,
221
            'tree'        => $tree,
222
        ]);
223
    }
224
225
    /**
226
     * Find all ancestors of an individual, indexed by the Sosa-Stradonitz number.
227
     *
228
     * @param Individual $individual
229
     *
230
     * @return array<Individual>
231
     */
232
    private function allAncestors(Individual $individual): array
233
    {
234
        $ancestors = [
235
            1 => $individual,
236
        ];
237
238
        do {
239
            $sosa = key($ancestors);
240
241
            $family = $ancestors[$sosa]->childFamilies()->first();
242
243
            if ($family !== null) {
244
                if ($family->husband() !== null) {
245
                    $ancestors[$sosa * 2] = $family->husband();
246
                }
247
                if ($family->wife() !== null) {
248
                    $ancestors[$sosa * 2 + 1] = $family->wife();
249
                }
250
            }
251
        } while (next($ancestors));
252
253
        return $ancestors;
254
    }
255
256
    /**
257
     * Fetch all individuals with a matching surname
258
     *
259
     * @param Tree   $tree
260
     * @param string $surname
261
     * @param bool   $soundex_dm
262
     * @param bool   $soundex_std
263
     *
264
     * @return array<Individual>
265
     */
266
    private function loadIndividuals(Tree $tree, string $surname, bool $soundex_dm, bool $soundex_std): array
267
    {
268
        $individuals = DB::table('individuals')
269
            ->join('name', static function (JoinClause $join): void {
270
                $join
271
                    ->on('name.n_file', '=', 'individuals.i_file')
272
                    ->on('name.n_id', '=', 'individuals.i_id');
273
            })
274
            ->where('i_file', '=', $tree->id())
275
            ->where('n_type', '<>', '_MARNM')
276
            ->where(static function (Builder $query) use ($surname, $soundex_dm, $soundex_std): void {
277
                $query
278
                    ->where('n_surn', '=', $surname)
279
                    ->orWhere('n_surname', '=', $surname);
280
281
                if ($soundex_std) {
282
                    $sdx = Soundex::russell($surname);
283
                    if ($sdx !== '') {
284
                        foreach (explode(':', $sdx) as $value) {
285
                            $query->orWhere('n_soundex_surn_std', 'LIKE', '%' . $value . '%');
286
                        }
287
                    }
288
                }
289
290
                if ($soundex_dm) {
291
                    $sdx = Soundex::daitchMokotoff($surname);
292
                    if ($sdx !== '') {
293
                        foreach (explode(':', $sdx) as $value) {
294
                            $query->orWhere('n_soundex_surn_dm', 'LIKE', '%' . $value . '%');
295
                        }
296
                    }
297
                }
298
            })
299
            ->distinct()
300
            ->select(['individuals.*'])
301
            ->get()
302
            ->map(Registry::individualFactory()->mapper($tree))
303
            ->filter(GedcomRecord::accessFilter())
304
            ->all();
305
306
        usort($individuals, Individual::birthDateComparator());
307
308
        return $individuals;
309
    }
310
311
    /**
312
     * For each individual with no ancestors, list their descendants.
313
     *
314
     * @param Tree              $tree
315
     * @param array<Individual> $individuals
316
     * @param array<Individual> $ancestors
317
     * @param string            $surname
318
     * @param bool              $soundex_dm
319
     * @param bool              $soundex_std
320
     *
321
     * @return string
322
     */
323
    private function getPatriarchsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std): string
324
    {
325
        $html = '';
326
        foreach ($individuals as $individual) {
327
            foreach ($individual->childFamilies() as $family) {
328
                foreach ($family->spouses() as $parent) {
329
                    if (in_array($parent, $individuals, true)) {
330
                        continue 3;
331
                    }
332
                }
333
            }
334
            $html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $individual, null);
335
        }
336
337
        return $html;
338
    }
339
340
    /**
341
     * Generate a recursive list of descendants of an individual.
342
     * If parents are specified, we can also show the pedigree (adopted, etc.).
343
     *
344
     * @param Tree              $tree
345
     * @param array<Individual> $individuals
346
     * @param array<Individual> $ancestors
347
     * @param string            $surname
348
     * @param bool              $soundex_dm
349
     * @param bool              $soundex_std
350
     * @param Individual        $individual
351
     * @param Family|null       $parents
352
     *
353
     * @return string
354
     */
355
    private function getDescendantsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std, Individual $individual, Family|null $parents = null): string
356
    {
357
        $module = $this->module_service
358
            ->findByComponent(ModuleChartInterface::class, $tree, Auth::user())
359
            ->first(static fn (ModuleInterface $module) => $module instanceof RelationshipsChartModule);
360
361
        // A person has many names. Select the one that matches the searched surname
362
        $person_name = '';
363
        foreach ($individual->getAllNames() as $name) {
364
            [$surn1] = explode(',', $name['sort']);
365
            if ($this->surnamesMatch($surn1, $surname, $soundex_std, $soundex_dm)) {
366
                $person_name = $name['full'];
367
                break;
368
            }
369
        }
370
371
        // No matching name? Typically children with a different surname. The branch stops here.
372
        if ($person_name === '') {
373
            return '<li title="' . strip_tags($individual->fullName()) . '" class="wt-branch-split"><small>' . view('icons/sex', ['sex' => $individual->sex()]) . '</small>…</li>';
374
        }
375
376
        // Is this individual one of our ancestors?
377
        $sosa = array_search($individual, $ancestors, true);
378
        if (is_int($sosa) && $module instanceof RelationshipsChartModule) {
379
            $sosa_class = 'search_hit';
380
            $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);
381
        } else {
382
            $sosa_class = '';
383
            $sosa_html  = '';
384
        }
385
386
        // Generate HTML for this individual, and all their descendants
387
        $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;
388
389
        // If this is not a birth pedigree (e.g. an adoption), highlight it
390
        if ($parents instanceof Family) {
391
            foreach ($individual->facts(['FAMC']) as $fact) {
392
                if ($fact->target() === $parents) {
393
                    $pedi = $fact->attribute('PEDI');
394
395
                    if ($pedi !== '' && $pedi !== PedigreeLinkageType::VALUE_BIRTH) {
396
                        $pedigree  = Registry::elementFactory()->make('INDI:FAMC:PEDI')->value($pedi, $tree);
397
                        $indi_html = '<span class="red">' . $pedigree . '</span> ' . $indi_html;
398
                    }
399
                    break;
400
                }
401
            }
402
        }
403
404
        // spouses and children
405
        $spouse_families = $individual->spouseFamilies()
406
            ->sort(Family::marriageDateComparator());
407
408
        if ($spouse_families->isNotEmpty()) {
409
            $fam_html = '';
410
            foreach ($spouse_families as $family) {
411
                $fam_html .= $indi_html; // Repeat the individual details for each spouse.
412
413
                $spouse = $family->spouse($individual);
414
                if ($spouse instanceof Individual) {
415
                    $sosa = array_search($spouse, $ancestors, true);
416
                    if (is_int($sosa) && $module instanceof RelationshipsChartModule) {
417
                        $sosa_class = 'search_hit';
418
                        $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);
419
                    } else {
420
                        $sosa_class = '';
421
                        $sosa_html  = '';
422
                    }
423
                    $marriage_year = $family->getMarriageYear();
424
                    if ($marriage_year) {
425
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . strip_tags($family->getMarriageDate()->display()) . '"><i class="icon-rings"></i>' . $marriage_year . '</a>';
426
                    } elseif ($family->facts(['MARR'])->isNotEmpty()) {
427
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Marriage') . '"><i class="icon-rings"></i></a>';
428
                    } else {
429
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Not married') . '"><i class="icon-rings"></i></a>';
430
                    }
431
                    $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;
432
                }
433
434
                $fam_html .= '<ol>';
435
                foreach ($family->children() as $child) {
436
                    $fam_html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $child, $family);
437
                }
438
                $fam_html .= '</ol>';
439
            }
440
441
            return '<li>' . $fam_html . '</li>';
442
        }
443
444
        // No spouses - just show the individual
445
        return '<li>' . $indi_html . '</li>';
446
    }
447
448
    /**
449
     * Do two surnames match?
450
     *
451
     * @param string $surname1
452
     * @param string $surname2
453
     * @param bool   $soundex_std
454
     * @param bool   $soundex_dm
455
     *
456
     * @return bool
457
     */
458
    private function surnamesMatch(string $surname1, string $surname2, bool $soundex_std, bool $soundex_dm): bool
459
    {
460
        // One name sounds like another?
461
        if ($soundex_std && Soundex::compare(Soundex::russell($surname1), Soundex::russell($surname2))) {
462
            return true;
463
        }
464
        if ($soundex_dm && Soundex::compare(Soundex::daitchMokotoff($surname1), Soundex::daitchMokotoff($surname2))) {
465
            return true;
466
        }
467
468
        // One is a substring of the other.  e.g. Halen / Van Halen
469
        return stripos($surname1, $surname2) !== false || stripos($surname2, $surname1) !== false;
470
    }
471
472
    /**
473
     * Convert a SOSA number into a generation number. e.g. 8 = great-grandfather = 3 generations
474
     *
475
     * @param int $sosa
476
     *
477
     * @return string
478
     */
479
    private static function sosaGeneration(int $sosa): string
480
    {
481
        $generation = (int) log($sosa, 2) + 1;
482
483
        return '<sup title="' . I18N::translate('Generation') . '">' . $generation . '</sup>';
484
    }
485
}
486