Passed
Push — master ( aabcb6...83d280 )
by Greg
05:15
created

BranchesController::surnamesMatch()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 5
nc 4
nop 4
dl 0
loc 12
c 1
b 0
f 0
cc 6
rs 9.2222
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
16
 */
17
declare(strict_types=1);
18
19
namespace Fisharebest\Webtrees\Http\Controllers;
20
21
use Fisharebest\Webtrees\Auth;
22
use Fisharebest\Webtrees\Family;
23
use Fisharebest\Webtrees\GedcomCode\GedcomCodePedi;
24
use Fisharebest\Webtrees\GedcomRecord;
25
use Fisharebest\Webtrees\I18N;
26
use Fisharebest\Webtrees\Individual;
27
use Fisharebest\Webtrees\Module\ModuleChartInterface;
28
use Fisharebest\Webtrees\Module\ModuleInterface;
29
use Fisharebest\Webtrees\Module\RelationshipsChartModule;
30
use Fisharebest\Webtrees\Services\ModuleService;
31
use Fisharebest\Webtrees\Soundex;
32
use Fisharebest\Webtrees\Tree;
33
use Illuminate\Database\Capsule\Manager as DB;
34
use Illuminate\Database\Query\Builder;
35
use Illuminate\Database\Query\JoinClause;
36
use Psr\Http\Message\ResponseInterface;
37
use Psr\Http\Message\ServerRequestInterface;
38
39
use function stripos;
40
use function view;
41
42
/**
43
 * Find all branches of families with a given surname.
44
 */
45
class BranchesController extends AbstractBaseController
46
{
47
    /** @var ModuleService */
48
    protected $module_service;
49
50
    /**
51
     * BranchesController constructor.
52
     *
53
     * @param ModuleService $module_service
54
     */
55
    public function __construct(ModuleService $module_service)
56
    {
57
        $this->module_service = $module_service;
58
    }
59
60
    /**
61
     * A form to request the page parameters.
62
     *
63
     * @param ServerRequestInterface $request
64
     *
65
     * @return ResponseInterface
66
     */
67
    public function page(ServerRequestInterface $request): ResponseInterface
68
    {
69
        $module = $request->getAttribute('module');
70
        $action = $request->getAttribute('action');
71
72
        $surname     = $request->getQueryParams()['surname'] ?? '';
73
        $soundex_std = (bool) ($request->getQueryParams()['soundex_std'] ?? false);
74
        $soundex_dm  = (bool) ($request->getQueryParams()['soundex_dm'] ?? false);
75
76
        if ($surname !== '') {
77
            /* I18N: %s is a surname */
78
            $title = I18N::translate('Branches of the %s family', e($surname));
79
        } else {
80
            /* I18N: Branches of a family tree */
81
            $title = I18N::translate('Branches');
82
        }
83
84
        return $this->viewResponse('branches-page', [
85
            'soundex_dm'  => $soundex_dm,
86
            'soundex_std' => $soundex_std,
87
            'surname'     => $surname,
88
            'title'       => $title,
89
            'module'      => $module,
90
            'action'      => $action,
91
        ]);
92
    }
93
94
    /**
95
     * @param ServerRequestInterface $request
96
     *
97
     * @return ResponseInterface
98
     */
99
    public function list(ServerRequestInterface $request): ResponseInterface
100
    {
101
        $tree        = $request->getAttribute('tree');
102
        $user        = $request->getAttribute('user');
103
        $params      = $request->getQueryParams();
104
        $surname     = $params['surname'];
105
        $soundex_std = (bool) ($params['soundex_std'] ?? false);
106
        $soundex_dm  = (bool) ($params['soundex_dm'] ?? false);
107
108
        // Highlight direct-line ancestors of this individual.
109
        $self = Individual::getInstance($tree->getUserPreference($user, 'gedcomid'), $tree);
110
111
        if ($surname !== '') {
112
            $individuals = $this->loadIndividuals($tree, $surname, $soundex_dm, $soundex_std);
113
        } else {
114
            $individuals = [];
115
        }
116
117
        if ($self !== null) {
118
            $ancestors = $this->allAncestors($self);
119
        } else {
120
            $ancestors = [];
121
        }
122
123
        // @TODO - convert this to use views
124
        $html = view('branches-list', [
125
            'branches' => $this->getPatriarchsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std),
126
        ]);
127
128
        return response($html);
129
    }
130
131
    /**
132
     * Find all ancestors of an individual, indexed by the Sosa-Stradonitz number.
133
     *
134
     * @param Individual $individual
135
     *
136
     * @return Individual[]
137
     */
138
    protected function allAncestors(Individual $individual): array
139
    {
140
        /** @var Individual[] $ancestors */
141
        $ancestors = [
142
            1 => $individual,
143
        ];
144
145
        do {
146
            $sosa = key($ancestors);
147
148
            $family = $ancestors[$sosa]->primaryChildFamily();
149
150
            if ($family !== null) {
151
                if ($family->husband() !== null) {
152
                    $ancestors[$sosa * 2] = $family->husband();
153
                }
154
                if ($family->wife() !== null) {
155
                    $ancestors[$sosa * 2 + 1] = $family->wife();
156
                }
157
            }
158
        } while (next($ancestors));
159
160
        return $ancestors;
161
    }
162
163
    /**
164
     * Fetch all individuals with a matching surname
165
     *
166
     * @param Tree   $tree
167
     * @param string $surname
168
     * @param bool   $soundex_dm
169
     * @param bool   $soundex_std
170
     *
171
     * @return Individual[]
172
     */
173
    private function loadIndividuals(Tree $tree, string $surname, bool $soundex_dm, bool $soundex_std): array
174
    {
175
        $individuals = DB::table('individuals')
176
            ->join('name', static function (JoinClause $join): void {
177
                $join
178
                    ->on('name.n_file', '=', 'individuals.i_file')
179
                    ->on('name.n_id', '=', 'individuals.i_id');
180
            })
181
            ->where('i_file', '=', $tree->id())
182
            ->where('n_type', '<>', '_MARNM')
183
            ->where(static function (Builder $query) use ($surname, $soundex_dm, $soundex_std): void {
184
                $query
185
                    ->where('n_surn', '=', $surname)
186
                    ->orWhere('n_surname', '=', $surname);
187
188
                if ($soundex_std) {
189
                    $sdx = Soundex::russell($surname);
190
                    if ($sdx !== '') {
191
                        foreach (explode(':', $sdx) as $value) {
192
                            $query->whereContains('n_soundex_surn_std', $value, 'or');
193
                        }
194
                    }
195
                }
196
197
                if ($soundex_dm) {
198
                    $sdx = Soundex::daitchMokotoff($surname);
199
                    if ($sdx !== '') {
200
                        foreach (explode(':', $sdx) as $value) {
201
                            $query->whereContains('n_soundex_surn_dm', $value, 'or');
202
                        }
203
                    }
204
                }
205
            })
206
            ->select(['individuals.*'])
207
            ->distinct()
208
            ->get()
209
            ->map(Individual::rowMapper())
210
            ->filter(GedcomRecord::accessFilter())
211
            ->all();
212
213
        usort($individuals, Individual::birthDateComparator());
214
215
        return $individuals;
216
    }
217
218
    /**
219
     * For each individual with no ancestors, list their descendants.
220
     *
221
     * @param Tree         $tree
222
     * @param Individual[] $individuals
223
     * @param Individual[] $ancestors
224
     * @param string       $surname
225
     * @param bool         $soundex_dm
226
     * @param bool         $soundex_std
227
     *
228
     * @return string
229
     */
230
    public function getPatriarchsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std): string
231
    {
232
        $html = '';
233
        foreach ($individuals as $individual) {
234
            foreach ($individual->childFamilies() as $family) {
235
                foreach ($family->spouses() as $parent) {
236
                    if (in_array($parent, $individuals, true)) {
237
                        continue 3;
238
                    }
239
                }
240
            }
241
            $html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $individual, null);
242
        }
243
244
        return $html;
245
    }
246
247
    /**
248
     * Generate a recursive list of descendants of an individual.
249
     * If parents are specified, we can also show the pedigree (adopted, etc.).
250
     *
251
     * @param Tree         $tree
252
     * @param Individual[] $individuals
253
     * @param Individual[] $ancestors
254
     * @param string       $surname
255
     * @param bool         $soundex_dm
256
     * @param bool         $soundex_std
257
     * @param Individual   $individual
258
     * @param Family|null  $parents
259
     *
260
     * @return string
261
     */
262
    private function getDescendantsHtml(Tree $tree, array $individuals, array $ancestors, string $surname, bool $soundex_dm, bool $soundex_std, Individual $individual, Family $parents = null): string
263
    {
264
        $module = $this->module_service->findByComponent(ModuleChartInterface::class, $tree, Auth::user())->first(static function (ModuleInterface $module) {
265
            return $module instanceof RelationshipsChartModule;
266
        });
267
268
        // A person has many names. Select the one that matches the searched surname
269
        $person_name = '';
270
        foreach ($individual->getAllNames() as $name) {
271
            [$surn1] = explode(',', $name['sort']);
272
            if ($this->surnamesMatch($surn1, $surname, $soundex_std, $soundex_dm)) {
273
                $person_name = $name['full'];
274
                break;
275
            }
276
        }
277
278
        // No matching name? Typically children with a different surname. The branch stops here.
279
        if (!$person_name) {
280
            return '<li title="' . strip_tags($individual->fullName()) . '"><small>' . view('icons/sex-' . $individual->sex()) . '…</small></li>';
281
        }
282
283
        // Is this individual one of our ancestors?
284
        $sosa = array_search($individual, $ancestors, true);
285
        if (is_int($sosa) && $module instanceof RelationshipsChartModule) {
286
            $sosa_class = 'search_hit';
287
            $sosa_html  = '<a class="details1 ' . $individual->getBoxStyle() . '" href="' . e($module->chartUrl($individual, ['xref2' => $individuals[1]->xref()])) . '" rel="nofollow" title="' . I18N::translate('Relationships') . '">' . I18N::number($sosa) . '</a>' . self::sosaGeneration($sosa);
288
        } else {
289
            $sosa_class = '';
290
            $sosa_html  = '';
291
        }
292
293
        // Generate HTML for this individual, and all their descendants
294
        $indi_html = '<small>' . view('icons/sex-' . $individual->sex()) . '</small><a class="' . $sosa_class . '" href="' . e($individual->url()) . '">' . $person_name . '</a> ' . $individual->getLifeSpan() . $sosa_html;
295
296
        // If this is not a birth pedigree (e.g. an adoption), highlight it
297
        if ($parents) {
298
            $pedi = '';
299
            foreach ($individual->facts(['FAMC']) as $fact) {
300
                if ($fact->target() === $parents) {
301
                    $pedi = $fact->attribute('PEDI');
302
                    break;
303
                }
304
            }
305
            if ($pedi !== '' && $pedi !== 'birth') {
306
                $indi_html = '<span class="red">' . GedcomCodePedi::getValue($pedi, $individual) . '</span> ' . $indi_html;
307
            }
308
        }
309
310
        // spouses and children
311
        $spouse_families = $individual->spouseFamilies()
312
            ->sort(Family::marriageDateComparator());
313
314
        if ($spouse_families->isNotEmpty()) {
315
            $fam_html = '';
316
            foreach ($spouse_families as $family) {
317
                $fam_html .= $indi_html; // Repeat the individual details for each spouse.
318
319
                $spouse = $family->spouse($individual);
320
                if ($spouse instanceof Individual) {
321
                    $sosa = array_search($spouse, $ancestors, true);
322
                    if (is_int($sosa) && $module instanceof RelationshipsChartModule) {
323
                        $sosa_class = 'search_hit';
324
                        $sosa_html  = '<a class="details1 ' . $spouse->getBoxStyle() . '" href="' . e($module->chartUrl($individual, ['xref2' => $individuals[1]->xref()])) . '" rel="nofollow" title="' . I18N::translate('Relationships') . '">' . I18N::number($sosa) . '</a>' . self::sosaGeneration($sosa);
325
                    } else {
326
                        $sosa_class = '';
327
                        $sosa_html  = '';
328
                    }
329
                    $marriage_year = $family->getMarriageYear();
330
                    if ($marriage_year) {
331
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . strip_tags($family->getMarriageDate()->display()) . '"><i class="icon-rings"></i>' . $marriage_year . '</a>';
332
                    } elseif ($family->facts(['MARR'])->first()) {
333
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Marriage') . '"><i class="icon-rings"></i></a>';
334
                    } else {
335
                        $fam_html .= ' <a href="' . e($family->url()) . '" title="' . I18N::translate('Not married') . '"><i class="icon-rings"></i></a>';
336
                    }
337
                    $fam_html .= ' <small>' . view('icons/sex-' . $spouse->sex()) . '</small><a class="' . $sosa_class . '" href="' . e($spouse->url()) . '">' . $spouse->fullName() . '</a> ' . $spouse->getLifeSpan() . ' ' . $sosa_html;
338
                }
339
340
                $fam_html .= '<ol>';
341
                foreach ($family->children() as $child) {
342
                    $fam_html .= $this->getDescendantsHtml($tree, $individuals, $ancestors, $surname, $soundex_dm, $soundex_std, $child, $family);
343
                }
344
                $fam_html .= '</ol>';
345
            }
346
347
            return '<li>' . $fam_html . '</li>';
348
        }
349
350
        // No spouses - just show the individual
351
        return '<li>' . $indi_html . '</li>';
352
    }
353
354
    /**
355
     * Do two surnames match?
356
     *
357
     * @param string $surname1
358
     * @param string $surname2
359
     * @param bool   $soundex_std
360
     * @param bool   $soundex_dm
361
     *
362
     * @return bool
363
     */
364
    private function surnamesMatch(string $surname1, string $surname2, bool $soundex_std, bool $soundex_dm): bool
365
    {
366
        // One name sounds like another?
367
        if ($soundex_std && Soundex::compare(Soundex::russell($surname1), Soundex::russell($surname2))) {
368
            return true;
369
        }
370
        if ($soundex_dm && Soundex::compare(Soundex::daitchMokotoff($surname1), Soundex::daitchMokotoff($surname2))) {
371
            return true;
372
        }
373
374
        // One is a substring of the other.  e.g. Halen / Van Halen
375
        return stripos($surname1, $surname2) !== false || stripos($surname2, $surname1) !== false;
376
    }
377
378
    /**
379
     * Convert a SOSA number into a generation number. e.g. 8 = great-grandfather = 3 generations
380
     *
381
     * @param int $sosa
382
     *
383
     * @return string
384
     */
385
    private static function sosaGeneration($sosa): string
386
    {
387
        $generation = (int) log($sosa, 2) + 1;
388
389
        return '<sup title="' . I18N::translate('Generation') . '">' . $generation . '</sup>';
390
    }
391
}
392