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\Module; |
||
21 | |||
22 | use Closure; |
||
23 | use Fig\Http\Message\RequestMethodInterface; |
||
24 | use Fisharebest\Algorithm\Dijkstra; |
||
25 | use Fisharebest\Webtrees\Auth; |
||
26 | use Fisharebest\Webtrees\Contracts\UserInterface; |
||
27 | use Fisharebest\Webtrees\DB; |
||
28 | use Fisharebest\Webtrees\FlashMessages; |
||
29 | use Fisharebest\Webtrees\GedcomRecord; |
||
30 | use Fisharebest\Webtrees\I18N; |
||
31 | use Fisharebest\Webtrees\Individual; |
||
32 | use Fisharebest\Webtrees\Menu; |
||
33 | use Fisharebest\Webtrees\Registry; |
||
34 | use Fisharebest\Webtrees\Services\RelationshipService; |
||
35 | use Fisharebest\Webtrees\Services\TreeService; |
||
36 | use Fisharebest\Webtrees\Tree; |
||
37 | use Fisharebest\Webtrees\Validator; |
||
38 | use Illuminate\Database\Query\JoinClause; |
||
39 | use Illuminate\Support\Collection; |
||
40 | use Psr\Http\Message\ResponseInterface; |
||
41 | use Psr\Http\Message\ServerRequestInterface; |
||
42 | use Psr\Http\Server\RequestHandlerInterface; |
||
43 | |||
44 | use function array_map; |
||
45 | use function asset; |
||
46 | use function count; |
||
47 | use function current; |
||
48 | use function e; |
||
49 | use function implode; |
||
50 | use function in_array; |
||
51 | use function max; |
||
52 | use function min; |
||
53 | use function next; |
||
54 | use function ob_get_clean; |
||
55 | use function ob_start; |
||
56 | use function preg_match; |
||
57 | use function redirect; |
||
58 | use function response; |
||
59 | use function route; |
||
60 | use function sort; |
||
61 | use function view; |
||
62 | |||
63 | class RelationshipsChartModule extends AbstractModule implements ModuleChartInterface, ModuleConfigInterface, RequestHandlerInterface |
||
64 | { |
||
65 | use ModuleChartTrait; |
||
66 | use ModuleConfigTrait; |
||
67 | |||
68 | protected const string ROUTE_URL = '/tree/{tree}/relationships-{ancestors}-{recursion}/{xref}{/xref2}'; |
||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||
69 | |||
70 | /** It would be more correct to use PHP_INT_MAX, but this isn't friendly in URLs */ |
||
71 | public const int UNLIMITED_RECURSION = 99; |
||
72 | |||
73 | /** By default new trees allow unlimited recursion */ |
||
74 | public const string DEFAULT_RECURSION = '99'; |
||
75 | |||
76 | /** By default new trees search for all relationships (not via ancestors) */ |
||
77 | public const string DEFAULT_ANCESTORS = '0'; |
||
78 | public const array DEFAULT_PARAMETERS = [ |
||
79 | 'ancestors' => self::DEFAULT_ANCESTORS, |
||
80 | 'recursion' => self::DEFAULT_RECURSION, |
||
81 | ]; |
||
82 | |||
83 | private TreeService $tree_service; |
||
84 | |||
85 | private RelationshipService $relationship_service; |
||
86 | |||
87 | /** |
||
88 | * @param RelationshipService $relationship_service |
||
89 | * @param TreeService $tree_service |
||
90 | */ |
||
91 | public function __construct(RelationshipService $relationship_service, TreeService $tree_service) |
||
92 | { |
||
93 | $this->relationship_service = $relationship_service; |
||
94 | $this->tree_service = $tree_service; |
||
95 | } |
||
96 | |||
97 | /** |
||
98 | * Initialization. |
||
99 | * |
||
100 | * @return void |
||
101 | */ |
||
102 | public function boot(): void |
||
103 | { |
||
104 | Registry::routeFactory()->routeMap() |
||
105 | ->get(static::class, static::ROUTE_URL, $this) |
||
106 | ->allows(RequestMethodInterface::METHOD_POST) |
||
107 | ->tokens([ |
||
108 | 'ancestors' => '\d+', |
||
109 | 'recursion' => '\d+', |
||
110 | ]); |
||
111 | } |
||
112 | |||
113 | public function description(): string |
||
114 | { |
||
115 | /* I18N: Description of the “RelationshipsChart” module */ |
||
116 | return I18N::translate('A chart displaying relationships between two individuals.'); |
||
117 | } |
||
118 | |||
119 | /** |
||
120 | * Return a menu item for this chart - for use in individual boxes. |
||
121 | * |
||
122 | * @param Individual $individual |
||
123 | * |
||
124 | * @return Menu|null |
||
125 | */ |
||
126 | public function chartBoxMenu(Individual $individual): Menu|null |
||
127 | { |
||
128 | return $this->chartMenu($individual); |
||
129 | } |
||
130 | |||
131 | /** |
||
132 | * A main menu item for this chart. |
||
133 | * |
||
134 | * @param Individual $individual |
||
135 | * |
||
136 | * @return Menu |
||
137 | */ |
||
138 | public function chartMenu(Individual $individual): Menu |
||
139 | { |
||
140 | $my_xref = $individual->tree()->getUserPreference(Auth::user(), UserInterface::PREF_TREE_ACCOUNT_XREF); |
||
141 | |||
142 | if ($my_xref !== '' && $my_xref !== $individual->xref()) { |
||
143 | $my_record = Registry::individualFactory()->make($my_xref, $individual->tree()); |
||
144 | |||
145 | if ($my_record instanceof Individual) { |
||
146 | return new Menu( |
||
147 | I18N::translate('Relationship to me'), |
||
148 | $this->chartUrl($my_record, ['xref2' => $individual->xref()]), |
||
149 | $this->chartMenuClass(), |
||
150 | $this->chartUrlAttributes() |
||
151 | ); |
||
152 | } |
||
153 | } |
||
154 | |||
155 | return new Menu( |
||
156 | $this->title(), |
||
157 | $this->chartUrl($individual), |
||
158 | $this->chartMenuClass(), |
||
159 | $this->chartUrlAttributes() |
||
160 | ); |
||
161 | } |
||
162 | |||
163 | /** |
||
164 | * CSS class for the URL. |
||
165 | * |
||
166 | * @return string |
||
167 | */ |
||
168 | public function chartMenuClass(): string |
||
169 | { |
||
170 | return 'menu-chart-relationship'; |
||
171 | } |
||
172 | |||
173 | public function title(): string |
||
174 | { |
||
175 | /* I18N: Name of a module/chart */ |
||
176 | return I18N::translate('Relationships'); |
||
177 | } |
||
178 | |||
179 | /** |
||
180 | * The URL for a page showing chart options. |
||
181 | * |
||
182 | * @param Individual $individual |
||
183 | * @param array<bool|int|string|array<string>|null> $parameters |
||
184 | * |
||
185 | * @return string |
||
186 | */ |
||
187 | public function chartUrl(Individual $individual, array $parameters = []): string |
||
188 | { |
||
189 | $tree = $individual->tree(); |
||
190 | $ancestors_only = (int) $tree->getPreference('RELATIONSHIP_ANCESTORS', static::DEFAULT_ANCESTORS); |
||
191 | $max_recursion = (int) $tree->getPreference('RELATIONSHIP_RECURSION', static::DEFAULT_RECURSION); |
||
192 | |||
193 | |||
194 | return route(static::class, [ |
||
195 | 'xref' => $individual->xref(), |
||
196 | 'tree' => $individual->tree()->name(), |
||
197 | 'ancestors' => $ancestors_only, |
||
198 | 'recursion' => $max_recursion, |
||
199 | ] + $parameters); |
||
200 | } |
||
201 | |||
202 | /** |
||
203 | * @param ServerRequestInterface $request |
||
204 | * |
||
205 | * @return ResponseInterface |
||
206 | */ |
||
207 | public function handle(ServerRequestInterface $request): ResponseInterface |
||
208 | { |
||
209 | $tree = Validator::attributes($request)->tree(); |
||
210 | $xref = Validator::attributes($request)->isXref()->string('xref'); |
||
211 | $xref2 = Validator::attributes($request)->isXref()->string('xref2', ''); |
||
212 | $ajax = Validator::queryParams($request)->boolean('ajax', false); |
||
213 | $ancestors = (int) $request->getAttribute('ancestors'); |
||
214 | $recursion = (int) $request->getAttribute('recursion'); |
||
215 | $user = Validator::attributes($request)->user(); |
||
216 | |||
217 | // Convert POST requests into GET requests for pretty URLs. |
||
218 | if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { |
||
219 | return redirect(route(static::class, [ |
||
220 | 'tree' => $tree->name(), |
||
221 | 'ancestors' => Validator::parsedBody($request)->string('ancestors', ''), |
||
222 | 'recursion' => Validator::parsedBody($request)->string('recursion', ''), |
||
223 | 'xref' => Validator::parsedBody($request)->string('xref', ''), |
||
224 | 'xref2' => Validator::parsedBody($request)->string('xref2', ''), |
||
225 | ])); |
||
226 | } |
||
227 | |||
228 | $individual1 = Registry::individualFactory()->make($xref, $tree); |
||
229 | $individual2 = Registry::individualFactory()->make($xref2, $tree); |
||
230 | |||
231 | $ancestors_only = (int) $tree->getPreference('RELATIONSHIP_ANCESTORS', static::DEFAULT_ANCESTORS); |
||
232 | $max_recursion = (int) $tree->getPreference('RELATIONSHIP_RECURSION', static::DEFAULT_RECURSION); |
||
233 | |||
234 | $recursion = min($recursion, $max_recursion); |
||
235 | |||
236 | Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); |
||
237 | |||
238 | if ($individual1 instanceof Individual) { |
||
239 | $individual1 = Auth::checkIndividualAccess($individual1, false, true); |
||
240 | } |
||
241 | |||
242 | if ($individual2 instanceof Individual) { |
||
243 | $individual2 = Auth::checkIndividualAccess($individual2, false, true); |
||
244 | } |
||
245 | |||
246 | if ($individual1 instanceof Individual && $individual2 instanceof Individual) { |
||
247 | if ($ajax) { |
||
248 | return $this->chart($individual1, $individual2, $recursion, $ancestors); |
||
249 | } |
||
250 | |||
251 | /* I18N: %s are individual’s names */ |
||
252 | $title = I18N::translate('Relationships between %1$s and %2$s', $individual1->fullName(), $individual2->fullName()); |
||
253 | $ajax_url = $this->chartUrl($individual1, [ |
||
254 | 'ajax' => true, |
||
255 | 'ancestors' => $ancestors, |
||
256 | 'recursion' => $recursion, |
||
257 | 'xref2' => $individual2->xref(), |
||
258 | ]); |
||
259 | } else { |
||
260 | $title = I18N::translate('Relationships'); |
||
261 | $ajax_url = ''; |
||
262 | } |
||
263 | |||
264 | return $this->viewResponse('modules/relationships-chart/page', [ |
||
265 | 'ajax_url' => $ajax_url, |
||
266 | 'ancestors' => $ancestors, |
||
267 | 'ancestors_only' => $ancestors_only, |
||
268 | 'ancestors_options' => $this->ancestorsOptions(), |
||
269 | 'individual1' => $individual1, |
||
270 | 'individual2' => $individual2, |
||
271 | 'max_recursion' => $max_recursion, |
||
272 | 'module' => $this->name(), |
||
273 | 'recursion' => $recursion, |
||
274 | 'recursion_options' => $this->recursionOptions($max_recursion), |
||
275 | 'title' => $title, |
||
276 | 'tree' => $tree, |
||
277 | ]); |
||
278 | } |
||
279 | |||
280 | /** |
||
281 | * @param Individual $individual1 |
||
282 | * @param Individual $individual2 |
||
283 | * @param int $recursion |
||
284 | * @param int $ancestors |
||
285 | * |
||
286 | * @return ResponseInterface |
||
287 | */ |
||
288 | public function chart(Individual $individual1, Individual $individual2, int $recursion, int $ancestors): ResponseInterface |
||
289 | { |
||
290 | $tree = $individual1->tree(); |
||
291 | |||
292 | $max_recursion = (int) $tree->getPreference('RELATIONSHIP_RECURSION', static::DEFAULT_RECURSION); |
||
293 | |||
294 | $recursion = min($recursion, $max_recursion); |
||
295 | |||
296 | $paths = $this->calculateRelationships($individual1, $individual2, $recursion, (bool) $ancestors); |
||
297 | |||
298 | ob_start(); |
||
299 | if (I18N::direction() === 'ltr') { |
||
300 | $diagonal1 = asset('css/images/dline.png'); |
||
301 | $diagonal2 = asset('css/images/dline2.png'); |
||
302 | } else { |
||
303 | $diagonal1 = asset('css/images/dline2.png'); |
||
304 | $diagonal2 = asset('css/images/dline.png'); |
||
305 | } |
||
306 | |||
307 | $num_paths = 0; |
||
308 | foreach ($paths as $path) { |
||
309 | // Extract the relationship names between pairs of individuals |
||
310 | $relationships = $this->oldStyleRelationshipPath($tree, $path); |
||
311 | if ($relationships === []) { |
||
312 | // Cannot see one of the families/individuals, due to privacy; |
||
313 | continue; |
||
314 | } |
||
315 | |||
316 | $nodes = Collection::make($path) |
||
317 | ->map(static function (string $xref, int $key) use ($tree): GedcomRecord { |
||
318 | if ($key % 2 === 0) { |
||
319 | return Registry::individualFactory()->make($xref, $tree); |
||
320 | } |
||
321 | |||
322 | return Registry::familyFactory()->make($xref, $tree); |
||
323 | }); |
||
324 | |||
325 | $relationship = $this->relationship_service->nameFromPath($nodes->all(), I18N::language()); |
||
326 | |||
327 | echo '<h3>', I18N::translate('Relationship: %s', $relationship), '</h3>'; |
||
328 | |||
329 | $num_paths++; |
||
330 | |||
331 | // Use a table/grid for layout. |
||
332 | $table = []; |
||
333 | // Current position in the grid. |
||
334 | $x = 0; |
||
335 | $y = 0; |
||
336 | // Extent of the grid. |
||
337 | $min_y = 0; |
||
338 | $max_y = 0; |
||
339 | $max_x = 0; |
||
340 | // For each node in the path. |
||
341 | foreach ($path as $n => $xref) { |
||
342 | if ($n % 2 === 1) { |
||
343 | switch ($relationships[$n]) { |
||
344 | case 'hus': |
||
345 | case 'wif': |
||
346 | case 'spo': |
||
347 | case 'bro': |
||
348 | case 'sis': |
||
349 | case 'sib': |
||
350 | $table[$x + 1][$y] = '<div style="background:url(' . e(asset('css/images/hline.png')) . ') repeat-x center; width: 94px; text-align: center"><div style="height: 32px;">' . $this->relationship_service->legacyNameAlgorithm($relationships[$n], Registry::individualFactory()->make($path[$n - 1], $tree), Registry::individualFactory()->make($path[$n + 1], $tree)) . '</div><div style="height: 32px;">' . view('icons/arrow-right') . '</div></div>'; |
||
351 | $x += 2; |
||
352 | break; |
||
353 | case 'son': |
||
354 | case 'dau': |
||
355 | case 'chi': |
||
356 | if ($n > 2 && preg_match('/fat|mot|par/', $relationships[$n - 2])) { |
||
357 | $table[$x + 1][$y - 1] = '<div style="background:url(' . $diagonal2 . '); width: 64px; height: 64px; text-align: center;"><div style="height: 32px; text-align: end;">' . $this->relationship_service->legacyNameAlgorithm($relationships[$n], Registry::individualFactory()->make($path[$n - 1], $tree), Registry::individualFactory()->make($path[$n + 1], $tree)) . '</div><div style="height: 32px; text-align: start;">' . view('icons/arrow-down') . '</div></div>'; |
||
358 | $x += 2; |
||
359 | } else { |
||
360 | $table[$x][$y - 1] = '<div style="background:url(' . e('"' . asset('css/images/vline.png') . '"') . ') repeat-y center; height: 64px; text-align: center;"><div style="display: inline-block; width:50%; line-height: 64px;">' . $this->relationship_service->legacyNameAlgorithm($relationships[$n], Registry::individualFactory()->make($path[$n - 1], $tree), Registry::individualFactory()->make($path[$n + 1], $tree)) . '</div><div style="display: inline-block; width:50%; line-height: 64px;">' . view('icons/arrow-down') . '</div></div>'; |
||
361 | } |
||
362 | $y -= 2; |
||
363 | break; |
||
364 | case 'fat': |
||
365 | case 'mot': |
||
366 | case 'par': |
||
367 | if ($n > 2 && preg_match('/son|dau|chi/', $relationships[$n - 2])) { |
||
368 | $table[$x + 1][$y + 1] = '<div style="background:url(' . $diagonal1 . '); background-position: top right; width: 64px; height: 64px; text-align: center;"><div style="height: 32px; text-align: start;">' . $this->relationship_service->legacyNameAlgorithm($relationships[$n], Registry::individualFactory()->make($path[$n - 1], $tree), Registry::individualFactory()->make($path[$n + 1], $tree)) . '</div><div style="height: 32px; text-align: end;">' . view('icons/arrow-down') . '</div></div>'; |
||
369 | $x += 2; |
||
370 | } else { |
||
371 | $table[$x][$y + 1] = '<div style="background:url(' . e('"' . asset('css/images/vline.png') . '"') . ') repeat-y center; height: 64px; text-align:center; "><div style="display: inline-block; width: 50%; line-height: 64px;">' . $this->relationship_service->legacyNameAlgorithm($relationships[$n], Registry::individualFactory()->make($path[$n - 1], $tree), Registry::individualFactory()->make($path[$n + 1], $tree)) . '</div><div style="display: inline-block; width: 50%; line-height: 32px">' . view('icons/arrow-up') . '</div></div>'; |
||
372 | } |
||
373 | $y += 2; |
||
374 | break; |
||
375 | } |
||
376 | $max_x = max($max_x, $x); |
||
377 | $min_y = min($min_y, $y); |
||
378 | $max_y = max($max_y, $y); |
||
379 | } else { |
||
380 | $individual = Registry::individualFactory()->make($xref, $tree); |
||
381 | $table[$x][$y] = view('chart-box', ['individual' => $individual]); |
||
382 | } |
||
383 | } |
||
384 | echo '<div class="wt-chart wt-chart-relationships">'; |
||
385 | echo '<table style="border-collapse: collapse; margin: 20px 50px;">'; |
||
386 | for ($y = $max_y; $y >= $min_y; --$y) { |
||
387 | echo '<tr>'; |
||
388 | for ($x = 0; $x <= $max_x; ++$x) { |
||
389 | echo '<td style="padding: 0;">'; |
||
390 | if (isset($table[$x][$y])) { |
||
391 | echo $table[$x][$y]; |
||
392 | } |
||
393 | echo '</td>'; |
||
394 | } |
||
395 | echo '</tr>'; |
||
396 | } |
||
397 | echo '</table>'; |
||
398 | echo '</div>'; |
||
399 | } |
||
400 | |||
401 | if (!$num_paths) { |
||
402 | echo '<p>', I18N::translate('No link between the two individuals could be found.'), '</p>'; |
||
403 | } |
||
404 | |||
405 | $html = ob_get_clean(); |
||
406 | |||
407 | return response($html); |
||
408 | } |
||
409 | |||
410 | /** |
||
411 | * @param ServerRequestInterface $request |
||
412 | * |
||
413 | * @return ResponseInterface |
||
414 | */ |
||
415 | public function getAdminAction(ServerRequestInterface $request): ResponseInterface |
||
416 | { |
||
417 | $this->layout = 'layouts/administration'; |
||
418 | |||
419 | return $this->viewResponse('modules/relationships-chart/config', [ |
||
420 | 'all_trees' => $this->tree_service->all(), |
||
421 | 'ancestors_options' => $this->ancestorsOptions(), |
||
422 | 'default_ancestors' => self::DEFAULT_ANCESTORS, |
||
423 | 'default_recursion' => self::DEFAULT_RECURSION, |
||
424 | 'recursion_options' => $this->recursionConfigOptions(), |
||
425 | 'title' => I18N::translate('Chart preferences') . ' — ' . $this->title(), |
||
426 | ]); |
||
427 | } |
||
428 | |||
429 | /** |
||
430 | * @param ServerRequestInterface $request |
||
431 | * |
||
432 | * @return ResponseInterface |
||
433 | */ |
||
434 | public function postAdminAction(ServerRequestInterface $request): ResponseInterface |
||
435 | { |
||
436 | foreach ($this->tree_service->all() as $tree) { |
||
437 | $recursion = Validator::parsedBody($request)->integer('relationship-recursion-' . $tree->id()); |
||
438 | $ancestors = Validator::parsedBody($request)->string('relationship-ancestors-' . $tree->id()); |
||
439 | |||
440 | $tree->setPreference('RELATIONSHIP_RECURSION', (string) $recursion); |
||
441 | $tree->setPreference('RELATIONSHIP_ANCESTORS', $ancestors); |
||
442 | } |
||
443 | |||
444 | FlashMessages::addMessage(I18N::translate('The preferences for the module “%s” have been updated.', $this->title()), 'success'); |
||
445 | |||
446 | return redirect($this->getConfigLink()); |
||
447 | } |
||
448 | |||
449 | /** |
||
450 | * Possible options for the ancestors option |
||
451 | * |
||
452 | * @return array<int,string> |
||
453 | */ |
||
454 | private function ancestorsOptions(): array |
||
455 | { |
||
456 | return [ |
||
457 | 0 => I18N::translate('Find any relationship'), |
||
458 | 1 => I18N::translate('Find relationships via ancestors'), |
||
459 | ]; |
||
460 | } |
||
461 | |||
462 | /** |
||
463 | * Possible options for the recursion option |
||
464 | * |
||
465 | * @return array<int,string> |
||
466 | */ |
||
467 | private function recursionConfigOptions(): array |
||
468 | { |
||
469 | return [ |
||
470 | 0 => I18N::translate('none'), |
||
471 | 1 => I18N::number(1), |
||
472 | 2 => I18N::number(2), |
||
473 | 3 => I18N::number(3), |
||
474 | self::UNLIMITED_RECURSION => I18N::translate('unlimited'), |
||
475 | ]; |
||
476 | } |
||
477 | |||
478 | /** |
||
479 | * Calculate the shortest paths - or all paths - between two individuals. |
||
480 | * |
||
481 | * @param Individual $individual1 |
||
482 | * @param Individual $individual2 |
||
483 | * @param int $recursion How many levels of recursion to use |
||
484 | * @param bool $ancestor Restrict to relationships via a common ancestor |
||
485 | * |
||
486 | * @return array<array<string>> |
||
487 | */ |
||
488 | private function calculateRelationships( |
||
489 | Individual $individual1, |
||
490 | Individual $individual2, |
||
491 | int $recursion, |
||
492 | bool $ancestor = false |
||
493 | ): array { |
||
494 | $tree = $individual1->tree(); |
||
495 | |||
496 | $rows = DB::table('link') |
||
497 | ->where('l_file', '=', $tree->id()) |
||
498 | ->whereIn('l_type', ['FAMS', 'FAMC']) |
||
499 | ->select(['l_from', 'l_to']) |
||
500 | ->get(); |
||
501 | |||
502 | // Optionally restrict the graph to the ancestors of the individuals. |
||
503 | if ($ancestor) { |
||
504 | $ancestors = $this->allAncestors($individual1->xref(), $individual2->xref(), $tree->id()); |
||
505 | $exclude = $this->excludeFamilies($individual1->xref(), $individual2->xref(), $tree->id()); |
||
506 | } else { |
||
507 | $ancestors = []; |
||
508 | $exclude = []; |
||
509 | } |
||
510 | |||
511 | $graph = []; |
||
512 | |||
513 | foreach ($rows as $row) { |
||
514 | if ($ancestors === [] || in_array($row->l_from, $ancestors, true) && !in_array($row->l_to, $exclude, true)) { |
||
515 | $graph[$row->l_from][$row->l_to] = 1; |
||
516 | $graph[$row->l_to][$row->l_from] = 1; |
||
517 | } |
||
518 | } |
||
519 | |||
520 | $xref1 = $individual1->xref(); |
||
521 | $xref2 = $individual2->xref(); |
||
522 | $dijkstra = new Dijkstra($graph); |
||
523 | $paths = $dijkstra->shortestPaths($xref1, $xref2); |
||
524 | |||
525 | // Only process each exclusion list once; |
||
526 | $excluded = []; |
||
527 | |||
528 | $queue = []; |
||
529 | foreach ($paths as $path) { |
||
530 | // Insert the paths into the queue, with an exclusion list. |
||
531 | $queue[] = [ |
||
532 | 'path' => $path, |
||
533 | 'exclude' => [], |
||
534 | ]; |
||
535 | // While there are un-extended paths |
||
536 | for ($next = current($queue); $next !== false; $next = next($queue)) { |
||
537 | // For each family on the path |
||
538 | for ($n = count($next['path']) - 2; $n >= 1; $n -= 2) { |
||
539 | $exclude = $next['exclude']; |
||
540 | if (count($exclude) >= $recursion) { |
||
541 | continue; |
||
542 | } |
||
543 | $exclude[] = $next['path'][$n]; |
||
544 | sort($exclude); |
||
545 | $tmp = implode('-', $exclude); |
||
546 | if (in_array($tmp, $excluded, true)) { |
||
547 | continue; |
||
548 | } |
||
549 | |||
550 | $excluded[] = $tmp; |
||
551 | // Add any new path to the queue |
||
552 | foreach ($dijkstra->shortestPaths($xref1, $xref2, $exclude) as $new_path) { |
||
553 | $queue[] = [ |
||
554 | 'path' => $new_path, |
||
555 | 'exclude' => $exclude, |
||
556 | ]; |
||
557 | } |
||
558 | } |
||
559 | } |
||
560 | } |
||
561 | // Extract the paths from the queue. |
||
562 | $paths = []; |
||
563 | foreach ($queue as $next) { |
||
564 | // The Dijkstra library does not use strict types, and converts |
||
565 | // numeric array keys (XREFs) from strings to integers; |
||
566 | $path = array_map($this->stringMapper(), $next['path']); |
||
567 | |||
568 | // Remove duplicates |
||
569 | $paths[implode('-', $next['path'])] = $path; |
||
570 | } |
||
571 | |||
572 | return $paths; |
||
573 | } |
||
574 | |||
575 | /** |
||
576 | * Convert numeric values to strings |
||
577 | * |
||
578 | * @return Closure(int|string):string |
||
579 | */ |
||
580 | private function stringMapper(): Closure |
||
581 | { |
||
582 | return static fn ($xref) => (string) $xref; |
||
583 | } |
||
584 | |||
585 | /** |
||
586 | * Find all ancestors of a list of individuals |
||
587 | * |
||
588 | * @param string $xref1 |
||
589 | * @param string $xref2 |
||
590 | * @param int $tree_id |
||
591 | * |
||
592 | * @return array<string> |
||
593 | */ |
||
594 | private function allAncestors(string $xref1, string $xref2, int $tree_id): array |
||
595 | { |
||
596 | $ancestors = [ |
||
597 | $xref1, |
||
598 | $xref2, |
||
599 | ]; |
||
600 | |||
601 | $queue = [ |
||
602 | $xref1, |
||
603 | $xref2, |
||
604 | ]; |
||
605 | while ($queue !== []) { |
||
606 | $parents = DB::table('link AS l1') |
||
607 | ->join('link AS l2', static function (JoinClause $join): void { |
||
608 | $join |
||
609 | ->on('l1.l_to', '=', 'l2.l_to') |
||
610 | ->on('l1.l_file', '=', 'l2.l_file'); |
||
611 | }) |
||
612 | ->where('l1.l_file', '=', $tree_id) |
||
613 | ->where('l1.l_type', '=', 'FAMC') |
||
614 | ->where('l2.l_type', '=', 'FAMS') |
||
615 | ->whereIn('l1.l_from', $queue) |
||
616 | ->pluck('l2.l_from'); |
||
617 | |||
618 | $queue = []; |
||
619 | foreach ($parents as $parent) { |
||
620 | if (!in_array($parent, $ancestors, true)) { |
||
621 | $ancestors[] = $parent; |
||
622 | $queue[] = $parent; |
||
623 | } |
||
624 | } |
||
625 | } |
||
626 | |||
627 | return $ancestors; |
||
628 | } |
||
629 | |||
630 | /** |
||
631 | * Find all families of two individuals |
||
632 | * |
||
633 | * @param string $xref1 |
||
634 | * @param string $xref2 |
||
635 | * @param int $tree_id |
||
636 | * |
||
637 | * @return array<string> |
||
638 | */ |
||
639 | private function excludeFamilies(string $xref1, string $xref2, int $tree_id): array |
||
640 | { |
||
641 | return DB::table('link AS l1') |
||
642 | ->join('link AS l2', static function (JoinClause $join): void { |
||
643 | $join |
||
644 | ->on('l1.l_to', '=', 'l2.l_to') |
||
645 | ->on('l1.l_type', '=', 'l2.l_type') |
||
646 | ->on('l1.l_file', '=', 'l2.l_file'); |
||
647 | }) |
||
648 | ->where('l1.l_file', '=', $tree_id) |
||
649 | ->where('l1.l_type', '=', 'FAMS') |
||
650 | ->where('l1.l_from', '=', $xref1) |
||
651 | ->where('l2.l_from', '=', $xref2) |
||
652 | ->pluck('l1.l_to') |
||
653 | ->all(); |
||
654 | } |
||
655 | |||
656 | /** |
||
657 | * Convert a path (list of XREFs) to an "old-style" string of relationships. |
||
658 | * Return an empty array, if privacy rules prevent us viewing any node. |
||
659 | * |
||
660 | * @param Tree $tree |
||
661 | * @param array<string> $path Alternately Individual / Family |
||
662 | * |
||
663 | * @return array<string> |
||
664 | */ |
||
665 | private function oldStyleRelationshipPath(Tree $tree, array $path): array |
||
666 | { |
||
667 | $spouse_codes = [ |
||
668 | 'M' => 'hus', |
||
669 | 'F' => 'wif', |
||
670 | 'U' => 'spo', |
||
671 | ]; |
||
672 | $parent_codes = [ |
||
673 | 'M' => 'fat', |
||
674 | 'F' => 'mot', |
||
675 | 'U' => 'par', |
||
676 | ]; |
||
677 | $child_codes = [ |
||
678 | 'M' => 'son', |
||
679 | 'F' => 'dau', |
||
680 | 'U' => 'chi', |
||
681 | ]; |
||
682 | $sibling_codes = [ |
||
683 | 'M' => 'bro', |
||
684 | 'F' => 'sis', |
||
685 | 'U' => 'sib', |
||
686 | ]; |
||
687 | $relationships = []; |
||
688 | |||
689 | for ($i = 1, $count = count($path); $i < $count; $i += 2) { |
||
690 | $family = Registry::familyFactory()->make($path[$i], $tree); |
||
691 | $prev = Registry::individualFactory()->make($path[$i - 1], $tree); |
||
692 | $next = Registry::individualFactory()->make($path[$i + 1], $tree); |
||
693 | if (preg_match('/\n\d (HUSB|WIFE|CHIL) @' . $prev->xref() . '@/', $family->gedcom(), $match)) { |
||
694 | $rel1 = $match[1]; |
||
695 | } else { |
||
696 | return []; |
||
697 | } |
||
698 | if (preg_match('/\n\d (HUSB|WIFE|CHIL) @' . $next->xref() . '@/', $family->gedcom(), $match)) { |
||
699 | $rel2 = $match[1]; |
||
700 | } else { |
||
701 | return []; |
||
702 | } |
||
703 | if (($rel1 === 'HUSB' || $rel1 === 'WIFE') && ($rel2 === 'HUSB' || $rel2 === 'WIFE')) { |
||
704 | $relationships[$i] = $spouse_codes[$next->sex()] ?? $spouse_codes['U']; |
||
705 | } elseif (($rel1 === 'HUSB' || $rel1 === 'WIFE') && $rel2 === 'CHIL') { |
||
706 | $relationships[$i] = $child_codes[$next->sex()] ?? $child_codes['U']; |
||
707 | } elseif ($rel1 === 'CHIL' && ($rel2 === 'HUSB' || $rel2 === 'WIFE')) { |
||
708 | $relationships[$i] = $parent_codes[$next->sex()] ?? $parent_codes['U']; |
||
709 | } elseif ($rel1 === 'CHIL' && $rel2 === 'CHIL') { |
||
710 | $relationships[$i] = $sibling_codes[$next->sex()] ?? $sibling_codes['U']; |
||
711 | } |
||
712 | } |
||
713 | |||
714 | return $relationships; |
||
715 | } |
||
716 | |||
717 | /** |
||
718 | * Possible options for the recursion option |
||
719 | * |
||
720 | * @param int $max_recursion |
||
721 | * |
||
722 | * @return array<string> |
||
723 | */ |
||
724 | private function recursionOptions(int $max_recursion): array |
||
725 | { |
||
726 | if ($max_recursion === static::UNLIMITED_RECURSION) { |
||
727 | $text = I18N::translate('Find all possible relationships'); |
||
728 | } else { |
||
729 | $text = I18N::translate('Find other relationships'); |
||
730 | } |
||
731 | |||
732 | return [ |
||
733 | '0' => I18N::translate('Find the closest relationships'), |
||
734 | $max_recursion => $text, |
||
735 | ]; |
||
736 | } |
||
737 | } |
||
738 |