. */ declare(strict_types=1); namespace Fisharebest\Webtrees\Http\RequestHandlers; use Fisharebest\Webtrees\Gedcom; use Fisharebest\Webtrees\GedcomTag; use Fisharebest\Webtrees\Header; use Fisharebest\Webtrees\Http\ViewResponseTrait; use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\Tree; use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Query\Expression; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use stdClass; use function array_key_exists; use function assert; use function e; use function in_array; use function preg_match; use function preg_match_all; use function route; use function strtoupper; use const PREG_SET_ORDER; /** * Check a tree for errors. */ class CheckTree implements RequestHandlerInterface { use ViewResponseTrait; /** * @param ServerRequestInterface $request * * @return ResponseInterface */ public function handle(ServerRequestInterface $request): ResponseInterface { $this->layout = 'layouts/administration'; $tree = $request->getAttribute('tree'); assert($tree instanceof Tree); // We need to work with raw GEDCOM data, as we are looking for errors // which may prevent the GedcomRecord objects from working. $q1 = DB::table('individuals') ->where('i_file', '=', $tree->id()) ->select(['i_id AS xref', 'i_gedcom AS gedcom', new Expression("'INDI' AS type")]); $q2 = DB::table('families') ->where('f_file', '=', $tree->id()) ->select(['f_id AS xref', 'f_gedcom AS gedcom', new Expression("'FAM' AS type")]); $q3 = DB::table('media') ->where('m_file', '=', $tree->id()) ->select(['m_id AS xref', 'm_gedcom AS gedcom', new Expression("'OBJE' AS type")]); $q4 = DB::table('sources') ->where('s_file', '=', $tree->id()) ->select(['s_id AS xref', 's_gedcom AS gedcom', new Expression("'SOUR' AS type")]); $q5 = DB::table('other') ->where('o_file', '=', $tree->id()) ->whereNotIn('o_type', [Header::RECORD_TYPE, 'TRLR']) ->select(['o_id AS xref', 'o_gedcom AS gedcom', 'o_type']); $q6 = DB::table('change') ->where('gedcom_id', '=', $tree->id()) ->where('status', '=', 'pending') ->orderBy('change_id') ->select(['xref', 'new_gedcom AS gedcom', new Expression("'' AS type")]); $rows = $q1 ->unionAll($q2) ->unionAll($q3) ->unionAll($q4) ->unionAll($q5) ->unionAll($q6) ->get() ->map(static function (stdClass $row): stdClass { // Extract type for pending record if ($row->type === '' && preg_match('/^0 @[^@]*@ ([_A-Z0-9]+)/', $row->gedcom, $match)) { $row->type = $match[1]; } return $row; }); $records = []; foreach ($rows as $row) { if ($row->gedcom !== '') { // existing or updated record $records[$row->xref] = $row; } else { // deleted record unset($records[$row->xref]); } } // LOOK FOR BROKEN LINKS $XREF_LINKS = [ 'NOTE' => 'NOTE', 'SOUR' => 'SOUR', 'REPO' => 'REPO', 'OBJE' => 'OBJE', 'SUBM' => 'SUBM', 'FAMC' => 'FAM', 'FAMS' => 'FAM', //'ADOP'=>'FAM', // Need to handle this case specially. We may have both ADOP and FAMC links to the same FAM, but only store one. 'HUSB' => 'INDI', 'WIFE' => 'INDI', 'CHIL' => 'INDI', 'ASSO' => 'INDI', '_ASSO' => 'INDI', // A webtrees extension 'ALIA' => 'INDI', 'AUTH' => 'INDI', // A webtrees extension 'ANCI' => 'SUBM', 'DESI' => 'SUBM', '_WT_OBJE_SORT' => 'OBJE', '_LOC' => '_LOC', ]; $RECORD_LINKS = [ 'INDI' => [ 'NOTE', 'OBJE', 'SOUR', 'SUBM', 'ASSO', '_ASSO', 'FAMC', 'FAMS', 'ALIA', '_WT_OBJE_SORT', '_LOC', ], 'FAM' => [ 'NOTE', 'OBJE', 'SOUR', 'SUBM', 'ASSO', '_ASSO', 'HUSB', 'WIFE', 'CHIL', '_LOC', ], 'SOUR' => [ 'NOTE', 'OBJE', 'REPO', 'AUTH', ], 'REPO' => ['NOTE'], 'OBJE' => ['NOTE'], // The spec also allows SOUR, but we treat this as a warning 'NOTE' => [], // The spec also allows SOUR, but we treat this as a warning 'SUBM' => [ 'NOTE', 'OBJE', ], 'SUBN' => ['SUBM'], '_LOC' => [ 'SOUR', 'OBJE', '_LOC', 'NOTE', ], ]; $errors = []; $warnings = []; // Generate lists of all links $all_links = []; $upper_links = []; foreach ($records as $record) { $all_links[$record->xref] = []; $upper_links[strtoupper($record->xref)] = $record->xref; preg_match_all('/\n\d (' . Gedcom::REGEX_TAG . ') @([^#@\n][^\n@]*)@/', $record->gedcom, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $all_links[$record->xref][$match[2]] = $match[1]; } } foreach ($all_links as $xref1 => $links) { // PHP converts array keys to integers. $xref1 = (string) $xref1; $type1 = $records[$xref1]->type; foreach ($links as $xref2 => $type2) { // PHP converts array keys to integers. $xref2 = (string) $xref2; $type3 = isset($records[$xref2]) ? $records[$xref2]->type : ''; if (!array_key_exists($xref2, $all_links)) { if (array_key_exists(strtoupper($xref2), $upper_links)) { $warnings[] = $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' . /* I18N: placeholders are GEDCOM XREFs, such as R123 */ I18N::translate('%1$s does not exist. Did you mean %2$s?', $this->checkLink($tree, $xref2), $this->checkLink($tree, $upper_links[strtoupper($xref2)])); } else { /* I18N: placeholders are GEDCOM XREFs, such as R123 */ $errors[] = $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' . I18N::translate('%1$s does not exist.', $this->checkLink($tree, $xref2)); } } elseif ($type2 === 'SOUR' && $type1 === 'NOTE') { // Notes are intended to add explanations and comments to other records. They should not have their own sources. } elseif ($type2 === 'SOUR' && $type1 === 'OBJE') { // Media objects are intended to illustrate other records, facts, and source/citations. They should not have their own sources. } elseif ($type2 === 'OBJE' && $type1 === 'REPO') { $warnings[] = $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' . I18N::translate('This type of link is not allowed here.'); } elseif (!array_key_exists($type1, $RECORD_LINKS) || !in_array($type2, $RECORD_LINKS[$type1], true) || !array_key_exists($type2, $XREF_LINKS)) { $errors[] = $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' . I18N::translate('This type of link is not allowed here.'); } elseif ($XREF_LINKS[$type2] !== $type3) { // Target XREF does exist - but is invalid $errors[] = $this->checkLinkMessage($tree, $type1, $xref1, $type2, $xref2) . ' ' . /* I18N: %1$s is an internal ID number such as R123. %2$s and %3$s are record types, such as INDI or SOUR */ I18N::translate('%1$s is a %2$s but a %3$s is expected.', $this->checkLink($tree, $xref2), $this->formatType($type3), $this->formatType($type2)); } elseif ( $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'FAMC', ['CHIL']) || $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'FAMS', ['HUSB', 'WIFE']) || $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'CHIL', ['FAMC']) || $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'HUSB', ['FAMS']) || $this->checkReverseLink($type2, $all_links, $xref1, $xref2, 'WIFE', ['FAMS']) ) { /* I18N: %1$s and %2$s are internal ID numbers such as R123 */ $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)); } } } $title = I18N::translate('Check for errors') . ' — ' . e($tree->title()); return $this->viewResponse('admin/trees-check', [ 'errors' => $errors, 'title' => $title, 'tree' => $tree, 'warnings' => $warnings, ]); } /** * @param string $type * @param string[][] $links * @param string $xref1 * @param string $xref2 * @param string $link * @param string[] $reciprocal * * @return bool */ private function checkReverseLink(string $type, array $links, string $xref1, string $xref2, string $link, array $reciprocal): bool { return $type === $link && (!array_key_exists($xref1, $links[$xref2]) || !in_array($links[$xref2][$xref1], $reciprocal, true)); } /** * Create a message linking one record to another. * * @param Tree $tree * @param string $type1 * @param string $xref1 * @param string $type2 * @param string $xref2 * * @return string */ private function checkLinkMessage(Tree $tree, string $type1, string $xref1, string $type2, $xref2): string { /* I18N: The placeholders are GEDCOM XREFs and tags. e.g. “INDI I123 contains a FAMC link to F234.” */ return I18N::translate( '%1$s %2$s has a %3$s link to %4$s.', $this->formatType($type1), $this->checkLink($tree, $xref1), $this->formatType($type2), $this->checkLink($tree, $xref2) ); } /** * Format a link to a record. * * @param Tree $tree * @param string $xref * * @return string */ private function checkLink(Tree $tree, string $xref): string { return '' . $xref . ''; } /** * Format a record type. * * @param string $type * * @return string */ private function formatType(string $type): string { return '' . $type . ''; } }