Commit bde5e8f5 authored by Sybille Peters's avatar Sybille Peters 🙋 Committed by Susanne Moog
Browse files

[FEATURE] Recheck for broken links after editing record

If someone returns to the list of broken links after
editing a record, the record must be checked again to
refresh the list of broken links.

Resolves: #83847
Releases: master
Change-Id: Ica70f900093fdf5697569f85cea299d923723d13
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/57131

Tested-by: Andreas Fernandez's avatarAndreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Jonas Eberle's avatarJonas Eberle <flightvision@googlemail.com>
Tested-by: Susanne Moog's avatarSusanne Moog <look@susi.dev>
Reviewed-by: Andreas Fernandez's avatarAndreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Susanne Moog's avatarSusanne Moog <look@susi.dev>
parent 376d9199
.. include:: ../../Includes.txt
=============================================================================
Feature: #83847 - Remove repaired links from Linkvalidator list after editing
=============================================================================
See :issue:`83847`
Description
===========
In the list of broken links provided by Linkvalidator, it is possible to click
on the edit icon for a broken link in order to edit the record directly.
If the record was edited, the list of broken links may no longer be correct.
There are now 2 possibilities, depending on how :ref:`actionAfterEditRecord <actionAfterEditRecord>`
is configured:
recheck (default):
The field is rechecked. (Warning: an RTE field may contain a number
of links, rechecking may lead to delays.)
setNeedsRecheck:
The entries in the list are marked as needing a recheck
Prior to this feature, fixed broken links were not removed which made fixing
several links at a time confusing and tedious because you either had to
remember which links were already fixed or switch back and forth between
the *Report* and the *Check Links* tab to recheck for broken links.
Impact
======
This feature improves the workflow of fixing broken links.
If the recheck option is selected, this may lead to some delays when
rechecking for broken links, especially if external links are involved.
.. index:: Backend, ext:linkvalidator
......@@ -20,9 +20,11 @@ use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\Html\HtmlParser;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Linkvalidator\Repository\BrokenLinkRepository;
/**
* This class provides Processing plugin implementation
......@@ -59,17 +61,10 @@ class LinkAnalyzer
*/
protected $brokenLinkCounts = [];
/**
* Array of tables and records containing broken links
*
* @var array
*/
protected $recordsWithBrokenLinks = [];
/**
* Array for hooks for own checks
*
* @var \TYPO3\CMS\Linkvalidator\Linktype\AbstractLinktype[]
* @var Linktype\AbstractLinktype[]
*/
protected $hookObjectsArr = [];
......@@ -99,9 +94,15 @@ class LinkAnalyzer
*/
protected $eventDispatcher;
public function __construct(EventDispatcherInterface $eventDispatcher)
/**
* @var BrokenLinkRepository
*/
protected $brokenLinkRepository;
public function __construct(EventDispatcherInterface $eventDispatcher, BrokenLinkRepository $brokenLinkRepository)
{
$this->eventDispatcher = $eventDispatcher;
$this->brokenLinkRepository = $brokenLinkRepository;
$this->getLanguageService()->includeLLFile('EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf');
}
......@@ -140,38 +141,10 @@ class LinkAnalyzer
return;
}
$checkKeys = array_keys($checkOptions);
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_linkvalidator_link');
$queryBuilder->delete('tx_linkvalidator_link')
->where(
$queryBuilder->expr()->orX(
$queryBuilder->expr()->andX(
$queryBuilder->expr()->in(
'record_uid',
$queryBuilder->createNamedParameter($this->pids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq('table_name', $queryBuilder->createNamedParameter('pages'))
),
$queryBuilder->expr()->andX(
$queryBuilder->expr()->in(
'record_pid',
$queryBuilder->createNamedParameter($this->pids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->neq(
'table_name',
$queryBuilder->createNamedParameter('pages')
)
)
),
$queryBuilder->expr()->in(
'link_type',
$queryBuilder->createNamedParameter($checkKeys, Connection::PARAM_STR_ARRAY)
)
)
->execute();
$this->brokenLinkRepository->removeAllBrokenLinksOfRecordsOnPageIds(
$this->pids,
array_keys($checkOptions)
);
// Traverse all configured tables
foreach ($this->searchFields as $table => $fields) {
......@@ -207,14 +180,18 @@ class LinkAnalyzer
$this->analyzeRecord($results, $table, $fields, $row);
}
}
$this->checkLinks($results, $checkOptions);
}
protected function checkLinks(array $links, array $checkOptions)
{
foreach ($this->hookObjectsArr as $key => $hookObj) {
if (!is_array($results[$key]) || (!empty($checkOptions) && !$checkOptions[$key])) {
if (!is_array($links[$key]) || (!empty($checkOptions) && !$checkOptions[$key])) {
continue;
}
// Check them
foreach ($results[$key] as $entryKey => $entryValue) {
foreach ($links[$key] as $entryKey => $entryValue) {
$table = $entryValue['table'];
$record = [];
$record['headline'] = BackendUtility::getRecordTitle($table, $entryValue['row']);
......@@ -260,6 +237,79 @@ class LinkAnalyzer
}
}
/**
* Set that a recheck is necessary for recordUid / table combination in list
* of broken links.
*
* @param string $recordUid
* @param string $table
*/
public function setNeedsRecheck(string $recordUid, string $table): void
{
$this->brokenLinkRepository->setNeedsRecheckForRecord($table, (int)$recordUid);
}
/**
* Recheck for broken links for one field in table for record.
*
* @param array $checkOptions
* @param string $recordUid uid of record to check
* @param string $table
* @param string $field
* @param int $timestamp - only recheck if timestamp changed
* @param bool $considerHidden
*/
public function recheckLinks(
array $checkOptions,
string $recordUid,
string $table,
string $field,
int $timestamp,
bool $considerHidden = true
): void {
// If table is not configured, assume the extension is not installed
// and therefore no need to check it
if (!is_array($GLOBALS['TCA'][$table])) {
return;
}
// get all links for $record / $table / $field combination
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($table);
if ($considerHidden) {
$queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class);
}
$row = $queryBuilder->select('uid', 'pid', $GLOBALS['TCA'][$table]['ctrl']['label'], $field)
->from($table)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($recordUid, Connection::PARAM_INT)
)
)
->execute()
->fetch();
if (!$row) {
// missing record: remove existing links
$this->brokenLinkRepository->removeBrokenLinksForRecord($table, $recordUid);
return;
}
if ($timestamp === (int)$row['timestamp']) {
// timestamp has not changed: no need to recheck
return;
}
$resultsLinks = [];
$this->analyzeRecord($resultsLinks, $table, [$field], $row);
if ($resultsLinks) {
// remove existing broken links from table
$this->brokenLinkRepository->removeBrokenLinksForRecord($table, $recordUid);
// find all broken links for list of links
$this->checkLinks($resultsLinks, $checkOptions);
}
}
/**
* Find all supported broken links for a specific record
*
......@@ -439,44 +489,16 @@ class LinkAnalyzer
/**
* Fill a marker array with the number of links found in a list of pages
*
* @param string $curPage Comma separated list of page uids
* @return array Marker array with the number of links found
* @return array array with the number of links found
*/
public function getLinkCounts($curPage)
public function getLinkCounts()
{
$markerArray = [];
$groupedResult = $this->brokenLinkRepository->getNumberOfBrokenLinksForRecordsOnPages($this->pids);
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_linkvalidator_link');
$queryBuilder->getRestrictions()->removeAll();
$result = $queryBuilder->select('link_type')
->addSelectLiteral($queryBuilder->expr()->count('uid', 'nbBrokenLinks'))
->from('tx_linkvalidator_link')
->where(
$queryBuilder->expr()->orX(
$queryBuilder->expr()->andX(
$queryBuilder->expr()->in(
'record_uid',
$queryBuilder->createNamedParameter($this->pids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq('table_name', $queryBuilder->createNamedParameter('pages'))
),
$queryBuilder->expr()->andX(
$queryBuilder->expr()->in(
'record_pid',
$queryBuilder->createNamedParameter($this->pids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->neq('table_name', $queryBuilder->createNamedParameter('pages'))
)
)
)
->groupBy('link_type')
->execute();
while ($row = $result->fetch()) {
$markerArray[$row['link_type']] = $row['nbBrokenLinks'];
$markerArray['brokenlinkCount'] += $row['nbBrokenLinks'];
$markerArray = [];
foreach ($groupedResult as $linkType => $amount) {
$markerArray[$linkType] = $amount;
$markerArray['brokenlinkCount'] += $amount;
}
return $markerArray;
}
......
......@@ -108,6 +108,16 @@ class LinkValidatorReport
*/
protected $checkOptionsHtml = ['report' => [], 'check' => []];
/**
* Information for last edited record
* @var array
*/
protected $lastEditedRecord = [
'uid' => 0,
'table' => '',
'field' => ''
];
/**
* Complete content (html) to be displayed
*
......@@ -150,6 +160,16 @@ class LinkValidatorReport
*/
protected $pObj;
/**
* @var array
*/
protected $searchFields;
/**
* @var string
*/
protected $pidList;
/**
* Init, called from parent object
*
......@@ -179,6 +199,12 @@ class LinkValidatorReport
$other = 'check';
}
// get information for last edited record
$this->lastEditedRecord['uid'] = GeneralUtility::_GP('last_edited_record_uid') ?? 0;
$this->lastEditedRecord['table'] = GeneralUtility::_GP('last_edited_record_table') ?? '';
$this->lastEditedRecord['field'] = GeneralUtility::_GP('last_edited_record_field') ?? '';
$this->lastEditedRecord['timestamp'] = GeneralUtility::_GP('last_edited_record_timestamp') ?? '';
// get searchLevel (number of levels of pages to check / show results)
$this->searchLevel[$prefix] = GeneralUtility::_GP($prefix . '_search_levels');
if (isset($this->id)) {
......@@ -245,9 +271,35 @@ class LinkValidatorReport
. htmlspecialchars($this->getLanguageService()->getLL('label_refresh-link-list'))
. '"/>';
$this->linkAnalyzer = GeneralUtility::makeInstance(LinkAnalyzer::class);
$this->updateBrokenLinks();
$this->initializeLinkAnalyzer();
$updateLinkList = GeneralUtility::_GP('updateLinkList') ?? '';
if ($updateLinkList) {
$this->updateBrokenLinks();
} else {
if ($this->lastEditedRecord['uid']) {
if ($this->modTS['actionAfterEditRecord'] === 'recheck') {
// recheck broken links for last edited reccord
$this->linkAnalyzer->recheckLinks(
$this->checkOpt['check'],
$this->lastEditedRecord['uid'],
$this->lastEditedRecord['table'],
$this->lastEditedRecord['field'],
(int)($this->lastEditedRecord['timestamp']),
true
);
} else {
// mark broken links for last edited record as needing a recheck
$this->linkAnalyzer->setNeedsRecheck(
$this->lastEditedRecord['uid'],
$this->lastEditedRecord['table']
);
}
}
}
$brokenLinkOverView = $this->linkAnalyzer->getLinkCounts($this->id);
$this->checkOptionsHtml['report'] = $this->getCheckOptions($brokenLinkOverView, 'report');
$this->checkOptionsHtml['check'] = $this->getCheckOptions($brokenLinkOverView, 'check');
$this->render();
......@@ -319,15 +371,19 @@ class LinkValidatorReport
/**
* Updates the table of stored broken links
*/
protected function updateBrokenLinks()
protected function initializeLinkAnalyzer()
{
$searchFields = [];
$this->searchFields = [];
// Get the searchFields from TypoScript
foreach ($this->modTS['searchFields.'] as $table => $fieldList) {
$fields = GeneralUtility::trimExplode(',', $fieldList, true);
foreach ($fields as $field) {
if (!$searchFields || !is_array($searchFields[$table]) || !in_array($field, $searchFields[$table], true)) {
$searchFields[$table][] = $field;
if (!$this->searchFields || !is_array($this->searchFields[$table]) || !in_array(
$field,
$this->searchFields[$table],
true
)) {
$this->searchFields[$table][] = $field;
}
}
}
......@@ -335,7 +391,7 @@ class LinkValidatorReport
if (!$rootLineHidden || $this->modTS['checkhidden'] == 1) {
$permsClause = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
// Get children pages
$pageList = $this->linkAnalyzer->extGetTreeList(
$this->pageList = $this->linkAnalyzer->extGetTreeList(
$this->id,
$this->searchLevel['check'],
0,
......@@ -343,20 +399,22 @@ class LinkValidatorReport
$this->modTS['checkhidden']
);
if ($this->pObj->pageinfo['hidden'] == 0 || $this->modTS['checkhidden']) {
$pageList .= $this->id;
$pageList = $this->addPageTranslationsToPageList($pageList, $permsClause);
$this->pageList .= $this->id;
$this->pageList = $this->addPageTranslationsToPageList($this->pageList, $permsClause);
}
$this->linkAnalyzer->init($searchFields, $pageList, $this->modTS);
// Check if button press
$update = GeneralUtility::_GP('updateLinkList');
if (!empty($update)) {
$this->linkAnalyzer->getLinkStatistics($this->checkOpt['check'], $this->modTS['checkhidden']);
}
$this->linkAnalyzer->init($this->searchFields, $this->pageList, $this->modTS);
}
}
/**
* Check for broken links
*/
protected function updateBrokenLinks()
{
$this->linkAnalyzer->getLinkStatistics($this->checkOpt['check'], $this->modTS['checkhidden']);
}
/**
* Renders the content of the module
*/
......@@ -626,7 +684,13 @@ class LinkValidatorReport
// Construct link to edit the content element
$requestUri = GeneralUtility::getIndpEnv('REQUEST_URI') .
'&id=' . $this->id .
'&search_levels=' . $this->searchLevel['report'];
'&search_levels=' . $this->searchLevel['report'] .
// add record_uid as query parameter for rechecking after edit
'&last_edited_record_uid=' . $row['record_uid'] .
'&last_edited_record_table=' . $row['table_name'] .
'&last_edited_record_field=' . $row['field'] .
'&last_edited_record_timestamp=' . $row['timestamp'];
/** @var \TYPO3\CMS\Backend\Routing\UriBuilder $uriBuilder */
$uriBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Routing\UriBuilder::class);
$url = (string)$uriBuilder->buildUriFromRoute('record_edit', [
......@@ -692,6 +756,9 @@ class LinkValidatorReport
$lastRunDate = date($GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'], $row['last_check']);
$lastRunTime = date($GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], $row['last_check']);
$markerArray['lastcheck'] = htmlspecialchars(sprintf($languageService->getLL('list.msg.lastRun'), $lastRunDate, $lastRunTime));
if ($row['needs_recheck']) {
$markerArray['lastcheck'] .= '<br/><span class="error"> (' . htmlspecialchars($languageService->getLL('needs-recheck')) . ')</span>';
}
// Return the table html code as string
return $this->templateService->substituteMarkerArray($brokenLinksItemTemplate, $markerArray, '###|###', true, true);
......
......@@ -16,6 +16,7 @@ namespace TYPO3\CMS\Linkvalidator\Repository;
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -24,6 +25,8 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*/
class BrokenLinkRepository
{
protected const TABLE = 'tx_linkvalidator_link';
/**
* Check if linkTarget is in list of broken links.
*
......@@ -35,10 +38,10 @@ class BrokenLinkRepository
{
try {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_linkvalidator_link');
->getQueryBuilderForTable(static::TABLE);
$queryBuilder
->count('uid')
->from('tx_linkvalidator_link')
->from(static::TABLE)
->where(
$queryBuilder->expr()->eq('url', $queryBuilder->createNamedParameter($linkTarget))
);
......@@ -49,4 +52,121 @@ class BrokenLinkRepository
return 0;
}
}
/**
* Returns all broken links found on the page record and all records on a page (or multiple pages)
* grouped by the link_type.
*
* @param array $pageIds
* @return array
*/
public function getNumberOfBrokenLinksForRecordsOnPages(array $pageIds): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable(static::TABLE);
$queryBuilder->getRestrictions()->removeAll();
$statement = $queryBuilder->select('link_type')
->addSelectLiteral($queryBuilder->expr()->count('uid', 'amount'))
->from(static::TABLE)
->where(
$queryBuilder->expr()->orX(
$queryBuilder->expr()->andX(
$queryBuilder->expr()->in(
'record_uid',
$queryBuilder->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq('table_name', $queryBuilder->createNamedParameter('pages'))
),
$queryBuilder->expr()->andX(
$queryBuilder->expr()->in(
'record_pid',
$queryBuilder->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->neq('table_name', $queryBuilder->createNamedParameter('pages'))
)
)
)
->groupBy('link_type')
->execute();
$result = [];
while ($row = $statement->fetch()) {
$result[$row['link_type']] = $result['amount'];
}
return $result;
}
public function setNeedsRecheckForRecord(int $recordUid, string $tableName): void
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable(static::TABLE);
$queryBuilder->update(static::TABLE)
->where(
$queryBuilder->expr()->eq(
'record_uid',
$queryBuilder->createNamedParameter($recordUid, \PDO::PARAM_INT)
),
$queryBuilder->expr()->eq(
'table_name',
$queryBuilder->createNamedParameter($tableName)
)
)
->set('needs_recheck', 1)
->execute();
}
public function removeBrokenLinksForRecord(string $tableName, int $recordUid): void
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable(static::TABLE);
$queryBuilder->delete(static::TABLE)
->where(
$queryBuilder->expr()->eq(
'record_uid',
$queryBuilder->createNamedParameter($recordUid, \PDO::PARAM_INT)
),
$queryBuilder->expr()->eq(
'table_name',
$queryBuilder->createNamedParameter($tableName)
)
)
->execute();
}
public function removeAllBrokenLinksOfRecordsOnPageIds(array $pageIds, array $linkTypes): void
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable(static::TABLE);
$queryBuilder->delete(static::TABLE)
->where(
$queryBuilder->expr()->orX(
$queryBuilder->expr()->andX(
$queryBuilder->expr()->in(
'record_uid',
$queryBuilder->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY)