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

BranchesListModule   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 428
Duplicated Lines 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 174
c 1
b 1
f 0
dl 0
loc 428
rs 4.08
wmc 59

14 Methods

Rating   Name   Duplication   Size   Complexity  
A listUrlAttributes() 0 3 1
A title() 0 4 1
A description() 0 4 1
A listUrl() 0 16 4
A __construct() 0 3 1
A listMenuClass() 0 3 1
A boot() 0 5 1
A allAncestors() 0 22 5
B loadIndividuals() 0 43 7
A sosaGeneration() 0 5 1
F getDescendantsHtml() 0 91 19
A getPatriarchsHtml() 0 15 5
A surnamesMatch() 0 12 6
B handle() 0 69 6

How to fix   Complexity   

Complex Class

Complex classes like BranchesListModule often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BranchesListModule, and based on these observations, apply Extract Interface, too.

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