xref: /webtrees/app/Http/RequestHandlers/CheckTree.php (revision 6930e9b42b9925bfc3a874fc2aaa59aabd0d2418)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2022 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
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Http\RequestHandlers;
21
22use Fisharebest\Webtrees\Gedcom;
23use Fisharebest\Webtrees\Header;
24use Fisharebest\Webtrees\Http\ViewResponseTrait;
25use Fisharebest\Webtrees\I18N;
26use Fisharebest\Webtrees\Tree;
27use Fisharebest\Webtrees\Validator;
28use Illuminate\Database\Capsule\Manager as DB;
29use Illuminate\Database\Query\Expression;
30use Psr\Http\Message\ResponseInterface;
31use Psr\Http\Message\ServerRequestInterface;
32use Psr\Http\Server\RequestHandlerInterface;
33
34use function array_key_exists;
35use function e;
36use function in_array;
37use function preg_match;
38use function preg_match_all;
39use function route;
40use function strtoupper;
41
42use const PREG_SET_ORDER;
43
44/**
45 * Check a tree for errors.
46 */
47class CheckTree implements RequestHandlerInterface
48{
49    use ViewResponseTrait;
50
51    /**
52     * @param ServerRequestInterface $request
53     *
54     * @return ResponseInterface
55     */
56    public function handle(ServerRequestInterface $request): ResponseInterface
57    {
58        $this->layout = 'layouts/administration';
59
60        $tree = Validator::attributes($request)->tree();
61
62        // We need to work with raw GEDCOM data, as we are looking for errors
63        // which may prevent the GedcomRecord objects from working.
64
65        $q1 = DB::table('individuals')
66            ->where('i_file', '=', $tree->id())
67            ->select(['i_id AS xref', 'i_gedcom AS gedcom', new Expression("'INDI' AS type")]);
68        $q2 = DB::table('families')
69            ->where('f_file', '=', $tree->id())
70            ->select(['f_id AS xref', 'f_gedcom AS gedcom', new Expression("'FAM' AS type")]);
71        $q3 = DB::table('media')
72            ->where('m_file', '=', $tree->id())
73            ->select(['m_id AS xref', 'm_gedcom AS gedcom', new Expression("'OBJE' AS type")]);
74        $q4 = DB::table('sources')
75            ->where('s_file', '=', $tree->id())
76            ->select(['s_id AS xref', 's_gedcom AS gedcom', new Expression("'SOUR' AS type")]);
77        $q5 = DB::table('other')
78            ->where('o_file', '=', $tree->id())
79            ->whereNotIn('o_type', [Header::RECORD_TYPE, 'TRLR'])
80            ->select(['o_id AS xref', 'o_gedcom AS gedcom', 'o_type']);
81        $q6 = DB::table('change')
82            ->where('gedcom_id', '=', $tree->id())
83            ->where('status', '=', 'pending')
84            ->orderBy('change_id')
85            ->select(['xref', 'new_gedcom AS gedcom', new Expression("'' AS type")]);
86
87        $rows = $q1
88            ->unionAll($q2)
89            ->unionAll($q3)
90            ->unionAll($q4)
91            ->unionAll($q5)
92            ->unionAll($q6)
93            ->get()
94            ->map(static function (object $row): object {
95                // Extract type for pending record
96                if ($row->type === '' && preg_match('/^0 @[^@]*@ ([_A-Z0-9]+)/', $row->gedcom, $match)) {
97                    $row->type = $match[1];
98                }
99
100                return $row;
101            });
102
103        $records = [];
104
105        foreach ($rows as $row) {
106            if ($row->gedcom !== '') {
107                // existing or updated record
108                $records[$row->xref] = $row;
109            } else {
110                // deleted record
111                unset($records[$row->xref]);
112            }
113        }
114
115        // LOOK FOR BROKEN LINKS
116        $XREF_LINKS = [
117            'NOTE'          => 'NOTE',
118            'SOUR'          => 'SOUR',
119            'REPO'          => 'REPO',
120            'OBJE'          => 'OBJE',
121            'SUBM'          => 'SUBM',
122            'FAMC'          => 'FAM',
123            'FAMS'          => 'FAM',
124            //'ADOP'=>'FAM', // Need to handle this case specially. We may have both ADOP and FAMC links to the same FAM, but only store one.
125            'HUSB'          => 'INDI',
126            'WIFE'          => 'INDI',
127            'CHIL'          => 'INDI',
128            'ASSO'          => 'INDI',
129            '_ASSO'         => 'INDI',
130            // A webtrees extension
131            'ALIA'          => 'INDI',
132            'AUTH'          => 'INDI',
133            // A webtrees extension
134            'ANCI'          => 'SUBM',
135            'DESI'          => 'SUBM',
136            '_WT_OBJE_SORT' => 'OBJE',
137            '_LOC'          => '_LOC',
138        ];
139
140        $RECORD_LINKS = [
141            'INDI' => [
142                'NOTE',
143                'OBJE',
144                'SOUR',
145                'SUBM',
146                'ASSO',
147                '_ASSO',
148                'FAMC',
149                'FAMS',
150                'ALIA',
151                '_WT_OBJE_SORT',
152                '_LOC',
153            ],
154            'FAM'  => [
155                'NOTE',
156                'OBJE',
157                'SOUR',
158                'SUBM',
159                'ASSO',
160                '_ASSO',
161                'HUSB',
162                'WIFE',
163                'CHIL',
164                '_LOC',
165            ],
166            'SOUR' => [
167                'NOTE',
168                'OBJE',
169                'REPO',
170                'AUTH',
171                '_LOC',
172            ],
173            'REPO' => ['NOTE'],
174            'OBJE' => ['NOTE'],
175            // The spec also allows SOUR, but we treat this as a warning
176            'NOTE' => [],
177            // The spec also allows SOUR, but we treat this as a warning
178            'SUBM' => [
179                'NOTE',
180                'OBJE',
181            ],
182            'SUBN' => ['SUBM'],
183            '_LOC' => [
184                'SOUR',
185                'OBJE',
186                '_LOC',
187                'NOTE',
188            ],
189        ];
190
191        $errors   = [];
192        $warnings = [];
193
194        // Generate lists of all links
195        $all_links   = [];
196        $upper_links = [];
197        foreach ($records as $record) {
198            $all_links[$record->xref]               = [];
199            $upper_links[strtoupper($record->xref)] = $record->xref;
200            preg_match_all('/\n\d (' . Gedcom::REGEX_TAG . ') @([^#@\n][^\n@]*)@/', $record->gedcom, $matches, PREG_SET_ORDER);
201            foreach ($matches as $match) {
202                $all_links[$record->xref][$match[2]] = $match[1];
203            }
204        }
205
206        foreach ($all_links as $xref1 => $links) {
207            // PHP converts array keys to integers.
208            $xref1 = (string) $xref1;
209
210            $type1 = $records[$xref1]->type;
211            foreach ($links as $xref2 => $type2) {
212                // PHP converts array keys to integers.
213                $xref2 = (string) $xref2;
214
215                $type3 = isset($records[$xref2]) ? $records[$xref2]->type : '';
216                if (!array_key_exists($xref2, $all_links)) {
217                    if (array_key_exists(strtoupper($xref2), $upper_links)) {
218                        $warnings[] =
219                            $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' .
220                            /* I18N: placeholders are GEDCOM XREFs, such as R123 */
221                            I18N::translate('%1$s does not exist. Did you mean %2$s?', $this->checkLink($tree, $xref2), $this->checkLink($tree, $upper_links[strtoupper($xref2)]));
222                    } else {
223                        /* I18N: placeholders are GEDCOM XREFs, such as R123 */
224                        $errors[] = $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' . I18N::translate('%s does not exist.', $this->checkLink($tree, $xref2));
225                    }
226                } elseif ($type2 === 'SOUR' && $type1 === 'NOTE') {
227                    // Notes are intended to add explanations and comments to other records. They should not have their own sources.
228                } elseif ($type2 === 'SOUR' && $type1 === 'OBJE') {
229                    // Media objects are intended to illustrate other records, facts, and source/citations. They should not have their own sources.
230                } elseif ($type2 === 'OBJE' && $type1 === 'REPO') {
231                    $warnings[] =
232                        $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) .
233                        ' ' .
234                        I18N::translate('This type of link is not allowed here.');
235                } elseif (!array_key_exists($type1, $RECORD_LINKS) || !in_array($type2, $RECORD_LINKS[$type1], true) || !array_key_exists($type2, $XREF_LINKS)) {
236                    $errors[] =
237                        $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) .
238                        ' ' .
239                        I18N::translate('This type of link is not allowed here.');
240                } elseif ($XREF_LINKS[$type2] !== $type3) {
241                    // Target XREF does exist - but is invalid
242                    $errors[] =
243                        $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' .
244                        /* I18N: %1$s is an internal ID number such as R123. %2$s and %3$s are record types, such as INDI or SOUR */
245                        I18N::translate('%1$s is a %2$s but a %3$s is expected.', $this->checkLink($tree, $xref2), $this->formatType($type3), $this->formatType($type2));
246                } elseif (
247                    $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'FAMC', ['CHIL']) ||
248                    $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'FAMS', ['HUSB', 'WIFE']) ||
249                    $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'CHIL', ['FAMC']) ||
250                    $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'HUSB', ['FAMS']) ||
251                    $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'WIFE', ['FAMS'])
252                ) {
253                    /* I18N: %1$s and %2$s are internal ID numbers such as R123 */
254                    $errors[] = $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' . I18N::translate('%1$s does not have a link back to %2$s.', $this->checkLink($tree, $xref2), $this->checkLink($tree, $xref1));
255                }
256            }
257        }
258
259        $title = I18N::translate('Check for errors') . ' — ' . e($tree->title());
260
261        return $this->viewResponse('admin/trees-check', [
262            'errors'   => $errors,
263            'title'    => $title,
264            'tree'     => $tree,
265            'warnings' => $warnings,
266        ]);
267    }
268
269    /**
270     * @param string               $type
271     * @param array<array<string>> $links
272     * @param string               $xref1
273     * @param string               $xref2
274     * @param string               $link
275     * @param array<string>        $reciprocal
276     *
277     * @return bool
278     */
279    private function checkReverseLink(string $type, array $links, string $xref1, string $xref2, string $link, array $reciprocal): bool
280    {
281        return $type === $link && (!array_key_exists($xref1, $links[$xref2]) || !in_array($links[$xref2][$xref1], $reciprocal, true));
282    }
283
284    /**
285     * Create a message linking one record to another.
286     *
287     * @param Tree   $tree
288     * @param string $type1
289     * @param string $xref1
290     * @param string $type2
291     * @param string $xref2
292     *
293     * @return string
294     */
295    private function checkLinkMessage(Tree $tree, string $type1, string $xref1, string $type2, string $xref2): string
296    {
297        /* I18N: The placeholders are GEDCOM XREFs and tags. e.g. “INDI I123 contains a FAMC link to F234.” */
298        return I18N::translate(
299            '%1$s %2$s has a %3$s link to %4$s.',
300            $this->formatType($type1),
301            $this->checkLink($tree, $xref1),
302            $this->formatType($type2),
303            $this->checkLink($tree, $xref2)
304        );
305    }
306
307    /**
308     * Format a link to a record.
309     *
310     * @param Tree   $tree
311     * @param string $xref
312     *
313     * @return string
314     */
315    private function checkLink(Tree $tree, string $xref): string
316    {
317        return '<b><a href="' . e(route(GedcomRecordPage::class, [
318                'xref' => $xref,
319                'tree' => $tree->name(),
320            ])) . '">' . $xref . '</a></b>';
321    }
322
323    /**
324     * Format a record type.
325     *
326     * @param string $type
327     *
328     * @return string
329     */
330    private function formatType(string $type): string
331    {
332        return '<b>' . $type . '</b>';
333    }
334}
335