Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 441 |
|
0.00% |
0 / 19 |
CRAP | |
0.00% |
0 / 1 |
| SpecialWhatLinksHere | |
0.00% |
0 / 440 |
|
0.00% |
0 / 19 |
9900 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| setParameter | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| onSuccess | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
2 | |||
| parseOffsetAndDir | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
42 | |||
| showIndirectLinks | |
0.00% |
0 / 215 |
|
0.00% |
0 / 1 |
3080 | |||
| listStart | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| listItem | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
110 | |||
| listEnd | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| wlhLink | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
| getPrevNext | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
30 | |||
| getFormFields | |
0.00% |
0 / 66 |
|
0.00% |
0 / 1 |
12 | |||
| alterForm | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
| getShowAlways | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getSubpageField | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onSubmit | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| requiresPost | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getDisplayFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| prefixSearchSubpages | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Specials; |
| 8 | |
| 9 | use MediaWiki\Cache\LinkBatchFactory; |
| 10 | use MediaWiki\Content\IContentHandlerFactory; |
| 11 | use MediaWiki\Deferred\LinksUpdate\ImageLinksTable; |
| 12 | use MediaWiki\Deferred\LinksUpdate\PageLinksTable; |
| 13 | use MediaWiki\Deferred\LinksUpdate\TemplateLinksTable; |
| 14 | use MediaWiki\Html\Html; |
| 15 | use MediaWiki\HTMLForm\HTMLForm; |
| 16 | use MediaWiki\Linker\LinksMigration; |
| 17 | use MediaWiki\Linker\LinkTarget; |
| 18 | use MediaWiki\MainConfigNames; |
| 19 | use MediaWiki\Message\Message; |
| 20 | use MediaWiki\Navigation\PagerNavigationBuilder; |
| 21 | use MediaWiki\Page\PageIdentity; |
| 22 | use MediaWiki\SpecialPage\FormSpecialPage; |
| 23 | use MediaWiki\Title\NamespaceInfo; |
| 24 | use MediaWiki\Title\Title; |
| 25 | use MediaWiki\Title\TitleFactory; |
| 26 | use SearchEngineFactory; |
| 27 | use stdClass; |
| 28 | use Wikimedia\Rdbms\IConnectionProvider; |
| 29 | use Wikimedia\Rdbms\IReadableDatabase; |
| 30 | use Wikimedia\Rdbms\SelectQueryBuilder; |
| 31 | |
| 32 | /** |
| 33 | * Implements Special:Whatlinkshere |
| 34 | * |
| 35 | * @ingroup SpecialPage |
| 36 | */ |
| 37 | class SpecialWhatLinksHere extends FormSpecialPage { |
| 38 | /** @var Title */ |
| 39 | protected $target; |
| 40 | |
| 41 | /** |
| 42 | * Submitted parameters as processed by `HTMLForm`, |
| 43 | * including those for any fields added in the |
| 44 | * `SpecialPageBeforeFormDisplay` hook; unset until |
| 45 | * the form is processed (or if no form was submitted). |
| 46 | */ |
| 47 | private array $formData; |
| 48 | private IConnectionProvider $dbProvider; |
| 49 | private LinkBatchFactory $linkBatchFactory; |
| 50 | private IContentHandlerFactory $contentHandlerFactory; |
| 51 | private SearchEngineFactory $searchEngineFactory; |
| 52 | private NamespaceInfo $namespaceInfo; |
| 53 | private TitleFactory $titleFactory; |
| 54 | private LinksMigration $linksMigration; |
| 55 | |
| 56 | private const LIMITS = [ 20, 50, 100, 250, 500 ]; |
| 57 | |
| 58 | public function __construct( |
| 59 | IConnectionProvider $dbProvider, |
| 60 | LinkBatchFactory $linkBatchFactory, |
| 61 | IContentHandlerFactory $contentHandlerFactory, |
| 62 | SearchEngineFactory $searchEngineFactory, |
| 63 | NamespaceInfo $namespaceInfo, |
| 64 | TitleFactory $titleFactory, |
| 65 | LinksMigration $linksMigration |
| 66 | ) { |
| 67 | parent::__construct( 'Whatlinkshere' ); |
| 68 | $this->mIncludable = true; |
| 69 | $this->dbProvider = $dbProvider; |
| 70 | $this->linkBatchFactory = $linkBatchFactory; |
| 71 | $this->contentHandlerFactory = $contentHandlerFactory; |
| 72 | $this->searchEngineFactory = $searchEngineFactory; |
| 73 | $this->namespaceInfo = $namespaceInfo; |
| 74 | $this->titleFactory = $titleFactory; |
| 75 | $this->linksMigration = $linksMigration; |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Get a better-looking target title from the subpage syntax. |
| 80 | * @param string|null $par |
| 81 | */ |
| 82 | protected function setParameter( $par ) { |
| 83 | if ( $par ) { |
| 84 | // The only difference that subpage syntax can have is the underscore. |
| 85 | $par = str_replace( '_', ' ', $par ); |
| 86 | } |
| 87 | parent::setParameter( $par ); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * We want the result displayed after the form, so we use this instead of onSubmit() |
| 92 | */ |
| 93 | public function onSuccess() { |
| 94 | $this->getSkin()->setRelevantTitle( $this->target ); |
| 95 | |
| 96 | $out = $this->getOutput(); |
| 97 | $out->setPageTitleMsg( |
| 98 | $this->msg( 'whatlinkshere-title' )->plaintextParams( $this->target->getPrefixedText() ) |
| 99 | ); |
| 100 | $out->addBacklinkSubtitle( $this->target ); |
| 101 | |
| 102 | [ $offsetNamespace, $offsetPageID, $dir ] = $this->parseOffsetAndDir(); |
| 103 | |
| 104 | $this->showIndirectLinks( |
| 105 | 0, |
| 106 | $this->target, |
| 107 | $this->formData['limit'], |
| 108 | $offsetNamespace, |
| 109 | $offsetPageID, |
| 110 | $dir |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Parse the offset and direction parameters. |
| 116 | * |
| 117 | * Three parameter kinds are supported: |
| 118 | * * from=123 (legacy), where page ID 123 is the first included one |
| 119 | * * offset=123&dir=next/prev (legacy), where page ID 123 is the last excluded one |
| 120 | * * offset=0|123&dir=next/prev (current), where namespace 0 page ID 123 is the last excluded one |
| 121 | * |
| 122 | * @return array |
| 123 | */ |
| 124 | private function parseOffsetAndDir(): array { |
| 125 | $from = (int)$this->formData['from']; |
| 126 | |
| 127 | if ( $from ) { |
| 128 | $dir = 'next'; |
| 129 | $offsetNamespace = null; |
| 130 | $offsetPageID = $from - 1; |
| 131 | } else { |
| 132 | $dir = $this->formData['dir'] ?? 'next'; |
| 133 | [ $offsetNamespaceString, $offsetPageIDString ] = explode( |
| 134 | '|', |
| 135 | $this->formData['offset'] . '|' |
| 136 | ); |
| 137 | if ( !$offsetPageIDString ) { |
| 138 | $offsetPageIDString = $offsetNamespaceString; |
| 139 | $offsetNamespaceString = ''; |
| 140 | } |
| 141 | if ( is_numeric( $offsetNamespaceString ) ) { |
| 142 | $offsetNamespace = (int)$offsetNamespaceString; |
| 143 | } else { |
| 144 | $offsetNamespace = null; |
| 145 | } |
| 146 | $offsetPageID = (int)$offsetPageIDString; |
| 147 | } |
| 148 | |
| 149 | if ( $offsetNamespace === null ) { |
| 150 | $offsetTitle = $this->titleFactory->newFromID( $offsetPageID ); |
| 151 | $offsetNamespace = $offsetTitle ? $offsetTitle->getNamespace() : NS_MAIN; |
| 152 | } |
| 153 | |
| 154 | return [ $offsetNamespace, $offsetPageID, $dir ]; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * @param int $level Recursion level |
| 159 | * @param LinkTarget $target Target title |
| 160 | * @param int $limit Number of entries to display |
| 161 | * @param int $offsetNamespace Display from this namespace number (included) |
| 162 | * @param int $offsetPageID Display from this article ID (excluded) |
| 163 | * @param string $dir 'next' or 'prev' |
| 164 | */ |
| 165 | private function showIndirectLinks( |
| 166 | $level, LinkTarget $target, $limit, $offsetNamespace = 0, $offsetPageID = 0, $dir = 'next' |
| 167 | ) { |
| 168 | $out = $this->getOutput(); |
| 169 | $dbr = $this->dbProvider->getReplicaDatabase(); |
| 170 | $hookRunner = $this->getHookRunner(); |
| 171 | |
| 172 | $hidelinks = $this->formData['hidelinks']; |
| 173 | $hideredirs = $this->formData['hideredirs']; |
| 174 | $hidetrans = $this->formData['hidetrans']; |
| 175 | $hideimages = $target->getNamespace() !== NS_FILE || ( $this->formData['hideimages'] ?? false ); |
| 176 | |
| 177 | // For historical reasons `pagelinks` always contains an entry for the redirect target. |
| 178 | // So we only need to query `redirect` if `pagelinks` isn't being queried. |
| 179 | $fetchredirs = $hidelinks && !$hideredirs; |
| 180 | |
| 181 | // Build query conds in concert for all four tables... |
| 182 | $conds = []; |
| 183 | $conds['redirect'] = [ |
| 184 | 'rd_namespace' => $target->getNamespace(), |
| 185 | 'rd_title' => $target->getDBkey(), |
| 186 | 'rd_interwiki' => '', |
| 187 | ]; |
| 188 | $conds['pagelinks'] = $this->linksMigration->getLinksConditions( 'pagelinks', $target ); |
| 189 | $conds['templatelinks'] = $this->linksMigration->getLinksConditions( 'templatelinks', $target ); |
| 190 | $conds['imagelinks'] = [ |
| 191 | 'il_to' => $target->getDBkey(), |
| 192 | ]; |
| 193 | |
| 194 | $namespace = $this->formData['namespace']; |
| 195 | if ( $namespace !== '' ) { |
| 196 | $invert = $this->formData['invert']; |
| 197 | if ( $invert ) { |
| 198 | // Select all namespaces except for the specified one. |
| 199 | // This allows the database to use the *_from_namespace index. (T241837) |
| 200 | $namespaces = array_diff( |
| 201 | $this->namespaceInfo->getValidNamespaces(), [ $namespace ] ); |
| 202 | } else { |
| 203 | $namespaces = $namespace; |
| 204 | } |
| 205 | } else { |
| 206 | // Select all namespaces. |
| 207 | // This allows the database to use the *_from_namespace index. (T297754) |
| 208 | $namespaces = $this->namespaceInfo->getValidNamespaces(); |
| 209 | } |
| 210 | $conds['redirect']['page_namespace'] = $namespaces; |
| 211 | $conds['pagelinks']['pl_from_namespace'] = $namespaces; |
| 212 | $conds['templatelinks']['tl_from_namespace'] = $namespaces; |
| 213 | $conds['imagelinks']['il_from_namespace'] = $namespaces; |
| 214 | |
| 215 | if ( $offsetPageID ) { |
| 216 | $op = $dir === 'prev' ? '<' : '>'; |
| 217 | $conds['redirect'][] = $dbr->buildComparison( $op, [ |
| 218 | 'rd_from' => $offsetPageID, |
| 219 | ] ); |
| 220 | $conds['templatelinks'][] = $dbr->buildComparison( $op, [ |
| 221 | 'tl_from_namespace' => $offsetNamespace, |
| 222 | 'tl_from' => $offsetPageID, |
| 223 | ] ); |
| 224 | $conds['pagelinks'][] = $dbr->buildComparison( $op, [ |
| 225 | 'pl_from_namespace' => $offsetNamespace, |
| 226 | 'pl_from' => $offsetPageID, |
| 227 | ] ); |
| 228 | $conds['imagelinks'][] = $dbr->buildComparison( $op, [ |
| 229 | 'il_from_namespace' => $offsetNamespace, |
| 230 | 'il_from' => $offsetPageID, |
| 231 | ] ); |
| 232 | } |
| 233 | |
| 234 | if ( $hideredirs ) { |
| 235 | // For historical reasons `pagelinks` always contains an entry for the redirect target. |
| 236 | // So we hide that link when $hideredirs is set. There's unfortunately no way to tell when a |
| 237 | // redirect's content also links to the target. |
| 238 | $conds['pagelinks']['rd_from'] = null; |
| 239 | } |
| 240 | |
| 241 | $sortDirection = $dir === 'prev' ? SelectQueryBuilder::SORT_DESC : SelectQueryBuilder::SORT_ASC; |
| 242 | |
| 243 | $fname = __METHOD__; |
| 244 | $queryFunc = function ( IReadableDatabase $dbr, $table, $fromCol ) use ( |
| 245 | $conds, $target, $limit, $sortDirection, $fname, $hookRunner |
| 246 | ) { |
| 247 | // Read an extra row as an at-end check |
| 248 | $queryLimit = $limit + 1; |
| 249 | $on = [ |
| 250 | "rd_from = $fromCol", |
| 251 | 'rd_title' => $target->getDBkey(), |
| 252 | 'rd_namespace' => $target->getNamespace(), |
| 253 | 'rd_interwiki' => '', |
| 254 | ]; |
| 255 | // Inner LIMIT is 2X in case of stale backlinks with wrong namespaces |
| 256 | $subQuery = $dbr->newSelectQueryBuilder() |
| 257 | ->table( $table ) |
| 258 | ->fields( [ $fromCol, 'rd_from', 'rd_fragment' ] ) |
| 259 | ->conds( $conds[$table] ) |
| 260 | ->orderBy( [ $fromCol . '_namespace', $fromCol ], $sortDirection ) |
| 261 | ->limit( 2 * $queryLimit ) |
| 262 | ->leftJoin( 'redirect', 'redirect', $on ); |
| 263 | |
| 264 | $queryBuilder = $dbr->newSelectQueryBuilder() |
| 265 | ->table( $subQuery, 'temp_backlink_range' ) |
| 266 | ->join( 'page', 'page', "$fromCol = page_id" ) |
| 267 | ->fields( [ 'page_id', 'page_namespace', 'page_title', |
| 268 | 'rd_from', 'rd_fragment', 'page_is_redirect' ] ) |
| 269 | ->orderBy( [ 'page_namespace', 'page_id' ], $sortDirection ) |
| 270 | ->limit( $queryLimit ); |
| 271 | |
| 272 | $hookRunner->onSpecialWhatLinksHereQuery( $table, $this->formData, $queryBuilder ); |
| 273 | |
| 274 | return $queryBuilder->caller( $fname )->fetchResultSet(); |
| 275 | }; |
| 276 | |
| 277 | if ( $fetchredirs ) { |
| 278 | $queryBuilder = $dbr->newSelectQueryBuilder() |
| 279 | ->table( 'redirect' ) |
| 280 | ->fields( [ 'page_id', 'page_namespace', 'page_title', 'rd_from', 'rd_fragment', 'page_is_redirect' ] ) |
| 281 | ->conds( $conds['redirect'] ) |
| 282 | ->orderBy( 'rd_from', $sortDirection ) |
| 283 | ->limit( $limit + 1 ) |
| 284 | ->join( 'page', 'page', 'rd_from = page_id' ); |
| 285 | |
| 286 | $hookRunner->onSpecialWhatLinksHereQuery( 'redirect', $this->formData, $queryBuilder ); |
| 287 | |
| 288 | $rdRes = $queryBuilder->caller( __METHOD__ )->fetchResultSet(); |
| 289 | } |
| 290 | |
| 291 | if ( !$hidelinks ) { |
| 292 | $plRes = $queryFunc( |
| 293 | $this->dbProvider->getReplicaDatabase( PageLinksTable::VIRTUAL_DOMAIN ), |
| 294 | 'pagelinks', |
| 295 | 'pl_from' |
| 296 | ); |
| 297 | } |
| 298 | |
| 299 | if ( !$hidetrans ) { |
| 300 | $tlRes = $queryFunc( |
| 301 | $this->dbProvider->getReplicaDatabase( TemplateLinksTable::VIRTUAL_DOMAIN ), |
| 302 | 'templatelinks', |
| 303 | 'tl_from' |
| 304 | ); |
| 305 | } |
| 306 | |
| 307 | if ( !$hideimages ) { |
| 308 | $ilRes = $queryFunc( |
| 309 | $this->dbProvider->getReplicaDatabase( ImageLinksTable::VIRTUAL_DOMAIN ), |
| 310 | 'imagelinks', |
| 311 | 'il_from' |
| 312 | ); |
| 313 | } |
| 314 | |
| 315 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $rdRes is declared when fetching redirs |
| 316 | if ( ( !$fetchredirs || !$rdRes->numRows() ) |
| 317 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $plRes is declared when fetching links |
| 318 | && ( $hidelinks || !$plRes->numRows() ) |
| 319 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $tlRes is declared when fetching trans |
| 320 | && ( $hidetrans || !$tlRes->numRows() ) |
| 321 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $ilRes is declared when fetching images |
| 322 | && ( $hideimages || !$ilRes->numRows() ) |
| 323 | ) { |
| 324 | if ( $level == 0 && !$this->including() ) { |
| 325 | if ( $hidelinks || $hidetrans || $hideredirs ) { |
| 326 | $msgKey = 'nolinkshere-filter'; |
| 327 | } elseif ( $namespace !== '' ) { |
| 328 | $msgKey = 'nolinkshere-ns'; |
| 329 | } else { |
| 330 | $msgKey = 'nolinkshere'; |
| 331 | } |
| 332 | $link = $this->getLinkRenderer()->makeLink( |
| 333 | $this->target, |
| 334 | null, |
| 335 | [], |
| 336 | $this->target->isRedirect() ? [ 'redirect' => 'no' ] : [] |
| 337 | ); |
| 338 | |
| 339 | $errMsg = $this->msg( $msgKey ) |
| 340 | ->params( $this->target->getPrefixedText() ) |
| 341 | ->rawParams( $link ) |
| 342 | ->parseAsBlock(); |
| 343 | $out->addHTML( $errMsg ); |
| 344 | $out->setStatusCode( 404 ); |
| 345 | } |
| 346 | |
| 347 | return; |
| 348 | } |
| 349 | |
| 350 | // Read the rows into an array and remove duplicates |
| 351 | // templatelinks comes third so that the templatelinks row overwrites the |
| 352 | // pagelinks/redirect row, so we get (inclusion) rather than nothing |
| 353 | $rows = []; |
| 354 | if ( $fetchredirs ) { |
| 355 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $rdRes is declared when fetching redirs |
| 356 | foreach ( $rdRes as $row ) { |
| 357 | $row->is_template = 0; |
| 358 | $row->is_image = 0; |
| 359 | $rows[$row->page_id] = $row; |
| 360 | } |
| 361 | } |
| 362 | if ( !$hidelinks ) { |
| 363 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $plRes is declared when fetching links |
| 364 | foreach ( $plRes as $row ) { |
| 365 | $row->is_template = 0; |
| 366 | $row->is_image = 0; |
| 367 | $rows[$row->page_id] = $row; |
| 368 | } |
| 369 | } |
| 370 | if ( !$hidetrans ) { |
| 371 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $tlRes is declared when fetching trans |
| 372 | foreach ( $tlRes as $row ) { |
| 373 | $row->is_template = 1; |
| 374 | $row->is_image = 0; |
| 375 | $rows[$row->page_id] = $row; |
| 376 | } |
| 377 | } |
| 378 | if ( !$hideimages ) { |
| 379 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $ilRes is declared when fetching images |
| 380 | foreach ( $ilRes as $row ) { |
| 381 | $row->is_template = 0; |
| 382 | $row->is_image = 1; |
| 383 | $rows[$row->page_id] = $row; |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | // Sort by namespace + page ID, changing the keys to 0-based indices |
| 388 | usort( $rows, static function ( $rowA, $rowB ) { |
| 389 | if ( $rowA->page_namespace !== $rowB->page_namespace ) { |
| 390 | return $rowA->page_namespace < $rowB->page_namespace ? -1 : 1; |
| 391 | } |
| 392 | if ( $rowA->page_id !== $rowB->page_id ) { |
| 393 | return $rowA->page_id < $rowB->page_id ? -1 : 1; |
| 394 | } |
| 395 | return 0; |
| 396 | } ); |
| 397 | |
| 398 | $numRows = count( $rows ); |
| 399 | |
| 400 | // Work out the start and end IDs, for prev/next links |
| 401 | if ( !$limit ) { // T289351 |
| 402 | $nextNamespace = $nextPageId = $prevNamespace = $prevPageId = false; |
| 403 | $rows = []; |
| 404 | } elseif ( $dir === 'prev' ) { |
| 405 | if ( $numRows > $limit ) { |
| 406 | // More rows available after these ones |
| 407 | // Get the next row from the last row in the result set |
| 408 | $nextNamespace = $rows[$limit]->page_namespace; |
| 409 | $nextPageId = $rows[$limit]->page_id; |
| 410 | // Remove undisplayed rows, for dir='prev' we need to discard first record after sorting |
| 411 | $rows = array_slice( $rows, 1, $limit ); |
| 412 | // Get the prev row from the first displayed row |
| 413 | $prevNamespace = $rows[0]->page_namespace; |
| 414 | $prevPageId = $rows[0]->page_id; |
| 415 | } else { |
| 416 | // Get the next row from the last displayed row |
| 417 | $nextNamespace = $rows[$numRows - 1]->page_namespace; |
| 418 | $nextPageId = $rows[$numRows - 1]->page_id; |
| 419 | $prevNamespace = false; |
| 420 | $prevPageId = false; |
| 421 | } |
| 422 | } else { |
| 423 | // If offset is not set disable prev link |
| 424 | $prevNamespace = $offsetPageID ? $rows[0]->page_namespace : false; |
| 425 | $prevPageId = $offsetPageID ? $rows[0]->page_id : false; |
| 426 | if ( $numRows > $limit ) { |
| 427 | // Get the next row from the last displayed row |
| 428 | $nextNamespace = $rows[$limit - 1]->page_namespace ?? false; |
| 429 | $nextPageId = $rows[$limit - 1]->page_id ?? false; |
| 430 | // Remove undisplayed rows |
| 431 | $rows = array_slice( $rows, 0, $limit ); |
| 432 | } else { |
| 433 | $nextNamespace = false; |
| 434 | $nextPageId = false; |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | // Optimization: Batch preload all Title data in one query |
| 439 | $lb = $this->linkBatchFactory->newLinkBatch()->setCaller( __METHOD__ ); |
| 440 | foreach ( $rows as $row ) { |
| 441 | $lb->add( $row->page_namespace, $row->page_title ); |
| 442 | } |
| 443 | $lb->execute(); |
| 444 | |
| 445 | if ( $level == 0 && !$this->including() ) { |
| 446 | $link = $this->getLinkRenderer()->makeLink( |
| 447 | $this->target, |
| 448 | null, |
| 449 | [], |
| 450 | $this->target->isRedirect() ? [ 'redirect' => 'no' ] : [] |
| 451 | ); |
| 452 | |
| 453 | $msg = $this->msg( 'linkshere' ) |
| 454 | ->params( $this->target->getPrefixedText() ) |
| 455 | ->rawParams( $link ) |
| 456 | ->parseAsBlock(); |
| 457 | $out->addHTML( $msg ); |
| 458 | |
| 459 | $out->addWikiMsg( 'whatlinkshere-count', Message::numParam( count( $rows ) ) ); |
| 460 | |
| 461 | $prevnext = $this->getPrevNext( $prevNamespace, $prevPageId, $nextNamespace, $nextPageId ); |
| 462 | $out->addHTML( $prevnext ); |
| 463 | } |
| 464 | $out->addHTML( $this->listStart( $level ) ); |
| 465 | foreach ( $rows as $row ) { |
| 466 | $nt = Title::makeTitle( $row->page_namespace, $row->page_title ); |
| 467 | |
| 468 | if ( $row->rd_from && $level < 2 ) { |
| 469 | $out->addHTML( $this->listItem( $row, $nt, $target, true ) ); |
| 470 | $this->showIndirectLinks( |
| 471 | $level + 1, |
| 472 | $nt, |
| 473 | $this->getConfig()->get( MainConfigNames::MaxRedirectLinksRetrieved ) |
| 474 | ); |
| 475 | $out->addHTML( Html::closeElement( 'li' ) ); |
| 476 | } else { |
| 477 | $out->addHTML( $this->listItem( $row, $nt, $target ) ); |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | $out->addHTML( $this->listEnd() ); |
| 482 | |
| 483 | if ( $level == 0 && !$this->including() ) { |
| 484 | // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable $prevnext is defined with $level is 0 |
| 485 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable prevnext is set when used |
| 486 | $out->addHTML( $prevnext ); |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | protected function listStart( int $level ): string { |
| 491 | return Html::openElement( 'ul', ( $level ? [] : [ 'id' => 'mw-whatlinkshere-list' ] ) ); |
| 492 | } |
| 493 | |
| 494 | private function listItem( stdClass $row, PageIdentity $nt, LinkTarget $target, bool $notClose = false ): string { |
| 495 | $legacyTitle = $this->titleFactory->newFromPageIdentity( $nt ); |
| 496 | |
| 497 | if ( $row->rd_from || $row->page_is_redirect ) { |
| 498 | $query = [ 'redirect' => 'no' ]; |
| 499 | } else { |
| 500 | $query = []; |
| 501 | } |
| 502 | |
| 503 | $dir = $this->getLanguage()->getDir(); |
| 504 | $link = Html::rawElement( 'bdi', [ 'dir' => $dir ], $this->getLinkRenderer()->makeKnownLink( |
| 505 | $nt, |
| 506 | null, |
| 507 | $row->page_is_redirect ? [ 'class' => 'mw-redirect' ] : [], |
| 508 | $query |
| 509 | ) ); |
| 510 | |
| 511 | // Display properties (redirect or template) |
| 512 | $propsText = ''; |
| 513 | $props = []; |
| 514 | if ( (string)$row->rd_fragment !== '' ) { |
| 515 | $props[] = $this->msg( 'whatlinkshere-sectionredir' ) |
| 516 | ->rawParams( $this->getLinkRenderer()->makeLink( |
| 517 | $target->createFragmentTarget( $row->rd_fragment ), |
| 518 | $row->rd_fragment |
| 519 | ) )->escaped(); |
| 520 | } elseif ( $row->rd_from ) { |
| 521 | $props[] = $this->msg( 'isredirect' )->escaped(); |
| 522 | } |
| 523 | if ( $row->is_template ) { |
| 524 | $props[] = $this->msg( 'istemplate' )->escaped(); |
| 525 | } |
| 526 | if ( $row->is_image ) { |
| 527 | $props[] = $this->msg( 'isimage' )->escaped(); |
| 528 | } |
| 529 | |
| 530 | $legacyTarget = $this->titleFactory->newFromLinkTarget( $target ); |
| 531 | $this->getHookRunner()->onWhatLinksHereProps( $row, $legacyTitle, $legacyTarget, $props ); |
| 532 | |
| 533 | if ( count( $props ) ) { |
| 534 | $propsText = $this->msg( 'parentheses' ) |
| 535 | ->rawParams( $this->getLanguage()->semicolonList( $props ) )->escaped(); |
| 536 | } |
| 537 | |
| 538 | # Space for utilities links, with a what-links-here link provided |
| 539 | $wlhLink = $this->wlhLink( |
| 540 | $legacyTitle, |
| 541 | $this->msg( 'whatlinkshere-links' )->text(), |
| 542 | $this->msg( 'editlink' )->text() |
| 543 | ); |
| 544 | $wlh = Html::rawElement( |
| 545 | 'span', |
| 546 | [ 'class' => 'mw-whatlinkshere-tools' ], |
| 547 | $this->msg( 'parentheses' )->rawParams( $wlhLink )->escaped() |
| 548 | ); |
| 549 | |
| 550 | return $notClose ? |
| 551 | Html::openElement( 'li' ) . "$link $propsText $wlh\n" : |
| 552 | Html::rawElement( 'li', [], "$link $propsText $wlh" ) . "\n"; |
| 553 | } |
| 554 | |
| 555 | protected function listEnd(): string { |
| 556 | return Html::closeElement( 'ul' ); |
| 557 | } |
| 558 | |
| 559 | protected function wlhLink( Title $target, string $text, string $editText ): string { |
| 560 | static $title = null; |
| 561 | $title ??= $this->getPageTitle(); |
| 562 | |
| 563 | $linkRenderer = $this->getLinkRenderer(); |
| 564 | |
| 565 | // always show a "<- Links" link |
| 566 | $links = [ |
| 567 | 'links' => $linkRenderer->makeKnownLink( |
| 568 | $title, |
| 569 | $text, |
| 570 | [], |
| 571 | [ 'target' => $target->getPrefixedText() ] |
| 572 | ), |
| 573 | ]; |
| 574 | |
| 575 | // if the page is editable, add an edit link |
| 576 | if ( |
| 577 | // check user permissions |
| 578 | $this->getAuthority()->isAllowed( 'edit' ) && |
| 579 | // check, if the content model is editable through action=edit |
| 580 | $this->contentHandlerFactory->getContentHandler( $target->getContentModel() ) |
| 581 | ->supportsDirectEditing() |
| 582 | ) { |
| 583 | $links['edit'] = $linkRenderer->makeKnownLink( |
| 584 | $target, |
| 585 | $editText, |
| 586 | [], |
| 587 | [ 'action' => 'edit' ] |
| 588 | ); |
| 589 | } |
| 590 | |
| 591 | // build the links html |
| 592 | return $this->getLanguage()->pipeList( $links ); |
| 593 | } |
| 594 | |
| 595 | /** |
| 596 | * @param int|false $prevNamespace |
| 597 | * @param int|false $prevPageId |
| 598 | * @param int|false $nextNamespace |
| 599 | * @param int|false $nextPageId |
| 600 | */ |
| 601 | private function getPrevNext( $prevNamespace, $prevPageId, $nextNamespace, $nextPageId ): string { |
| 602 | $navBuilder = new PagerNavigationBuilder( $this->getContext() ); |
| 603 | |
| 604 | $navBuilder |
| 605 | ->setPage( $this->getPageTitle( $this->target->getPrefixedDBkey() ) ) |
| 606 | // Remove 'target', already included in the request title |
| 607 | ->setLinkQuery( |
| 608 | array_diff_key( |
| 609 | array_filter( |
| 610 | $this->formData, |
| 611 | static fn ( $value ) => $value !== null && $value !== '' && $value !== false |
| 612 | ), |
| 613 | [ 'target' => null, 'from' => null ] |
| 614 | ) |
| 615 | ) |
| 616 | ->setLimits( self::LIMITS ) |
| 617 | ->setLimitLinkQueryParam( 'limit' ) |
| 618 | ->setCurrentLimit( $this->formData['limit'] ) |
| 619 | ->setPrevMsg( 'whatlinkshere-prev' ) |
| 620 | ->setNextMsg( 'whatlinkshere-next' ); |
| 621 | |
| 622 | if ( $prevPageId != 0 ) { |
| 623 | $navBuilder->setPrevLinkQuery( [ 'dir' => 'prev', 'offset' => "$prevNamespace|$prevPageId" ] ); |
| 624 | } |
| 625 | if ( $nextPageId != 0 ) { |
| 626 | $navBuilder->setNextLinkQuery( [ 'dir' => 'next', 'offset' => "$nextNamespace|$nextPageId" ] ); |
| 627 | } |
| 628 | |
| 629 | return $navBuilder->getHtml(); |
| 630 | } |
| 631 | |
| 632 | /** @inheritDoc */ |
| 633 | protected function getFormFields() { |
| 634 | $this->addHelpLink( 'Help:What links here' ); |
| 635 | $this->getOutput()->addModuleStyles( 'mediawiki.special' ); |
| 636 | |
| 637 | $fields = [ |
| 638 | 'target' => [ |
| 639 | 'type' => 'title', |
| 640 | 'name' => 'target', |
| 641 | 'id' => 'mw-whatlinkshere-target', |
| 642 | 'label-message' => 'whatlinkshere-page', |
| 643 | 'section' => 'whatlinkshere-target', |
| 644 | 'creatable' => true, |
| 645 | ], |
| 646 | 'namespace' => [ |
| 647 | 'type' => 'namespaceselect', |
| 648 | 'name' => 'namespace', |
| 649 | 'id' => 'namespace', |
| 650 | 'label-message' => 'namespace', |
| 651 | 'all' => '', |
| 652 | 'default' => '', |
| 653 | 'filter-callback' => static function ( $value ) { |
| 654 | return $value !== '' ? intval( $value ) : ''; |
| 655 | }, |
| 656 | 'in-user-lang' => true, |
| 657 | 'section' => 'whatlinkshere-ns', |
| 658 | ], |
| 659 | 'invert' => [ |
| 660 | 'type' => 'check', |
| 661 | 'name' => 'invert', |
| 662 | 'id' => 'nsinvert', |
| 663 | 'hide-if' => [ '===', 'namespace', '' ], |
| 664 | 'label-message' => 'invert', |
| 665 | 'help-message' => 'tooltip-whatlinkshere-invert', |
| 666 | 'help-inline' => false, |
| 667 | 'section' => 'whatlinkshere-ns' |
| 668 | ], |
| 669 | 'limit' => [ |
| 670 | 'type' => 'hidden', |
| 671 | 'name' => 'limit', |
| 672 | 'default' => $this->getConfig()->get( MainConfigNames::QueryPageDefaultLimit ), |
| 673 | 'filter-callback' => static fn ( $value ) => max( 0, min( intval( $value ), 5000 ) ), |
| 674 | ], |
| 675 | 'offset' => [ |
| 676 | 'type' => 'api', |
| 677 | 'name' => 'offset', |
| 678 | 'default' => '', |
| 679 | ], |
| 680 | 'dir' => [ |
| 681 | 'type' => 'api', |
| 682 | 'name' => 'dir', |
| 683 | ], |
| 684 | 'from' => [ |
| 685 | 'type' => 'api', |
| 686 | 'name' => 'from', |
| 687 | 'default' => 0, |
| 688 | ] |
| 689 | ]; |
| 690 | |
| 691 | $filters = [ 'hidetrans', 'hidelinks', 'hideredirs' ]; |
| 692 | |
| 693 | // Combined message keys: 'whatlinkshere-hideredirs', 'whatlinkshere-hidetrans', |
| 694 | // 'whatlinkshere-hidelinks' |
| 695 | // To be sure they will be found by grep |
| 696 | foreach ( $filters as $filter ) { |
| 697 | // Parameter only provided for backwards-compatibility with old translations |
| 698 | $hide = $this->msg( 'hide' )->text(); |
| 699 | $msg = $this->msg( "whatlinkshere-{$filter}", $hide )->text(); |
| 700 | $fields[$filter] = [ |
| 701 | 'type' => 'check', |
| 702 | 'name' => $filter, |
| 703 | 'label' => $msg, |
| 704 | 'section' => 'whatlinkshere-filter', |
| 705 | ]; |
| 706 | } |
| 707 | |
| 708 | return $fields; |
| 709 | } |
| 710 | |
| 711 | protected function alterForm( HTMLForm $form ) { |
| 712 | // This parameter from the subpage syntax is only added after constructing the form, |
| 713 | // so we should add the dynamic field that depends on the user input here. |
| 714 | |
| 715 | // TODO: This looks not good. Ideally we can initialize it in onSubmit(). |
| 716 | // Maybe extend the hide-if feature to match prefixes on the client side. |
| 717 | $this->target = Title::newFromText( $this->getRequest()->getText( 'target' ) ); |
| 718 | if ( $this->target && $this->target->getNamespace() == NS_FILE ) { |
| 719 | $hide = $this->msg( 'hide' )->text(); |
| 720 | $msg = $this->msg( 'whatlinkshere-hideimages', $hide )->text(); |
| 721 | $form->addFields( [ |
| 722 | 'hideimages' => [ |
| 723 | 'type' => 'check', |
| 724 | 'name' => 'hideimages', |
| 725 | 'label' => $msg, |
| 726 | 'section' => 'whatlinkshere-filter', |
| 727 | ] |
| 728 | ] ); |
| 729 | } |
| 730 | |
| 731 | $form->setWrapperLegendMsg( 'whatlinkshere' ) |
| 732 | ->setSubmitTextMsg( 'whatlinkshere-submit' ); |
| 733 | } |
| 734 | |
| 735 | /** @inheritDoc */ |
| 736 | protected function getShowAlways() { |
| 737 | return true; |
| 738 | } |
| 739 | |
| 740 | /** @inheritDoc */ |
| 741 | protected function getSubpageField() { |
| 742 | return 'target'; |
| 743 | } |
| 744 | |
| 745 | /** @inheritDoc */ |
| 746 | public function onSubmit( array $data ) { |
| 747 | $this->formData = $data; |
| 748 | return true; |
| 749 | } |
| 750 | |
| 751 | /** @inheritDoc */ |
| 752 | public function requiresPost() { |
| 753 | return false; |
| 754 | } |
| 755 | |
| 756 | /** @inheritDoc */ |
| 757 | protected function getDisplayFormat() { |
| 758 | return 'ooui'; |
| 759 | } |
| 760 | |
| 761 | /** |
| 762 | * Return an array of subpages beginning with $search that this special page will accept. |
| 763 | * |
| 764 | * @param string $search Prefix to search for |
| 765 | * @param int $limit Maximum number of results to return (usually 10) |
| 766 | * @param int $offset Number of results to skip (usually 0) |
| 767 | * @return string[] Matching subpages |
| 768 | */ |
| 769 | public function prefixSearchSubpages( $search, $limit, $offset ) { |
| 770 | return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory ); |
| 771 | } |
| 772 | |
| 773 | /** @inheritDoc */ |
| 774 | protected function getGroupName() { |
| 775 | return 'pagetools'; |
| 776 | } |
| 777 | } |
| 778 | |
| 779 | /** |
| 780 | * Retain the old class name for backwards compatibility. |
| 781 | * @deprecated since 1.41 |
| 782 | */ |
| 783 | class_alias( SpecialWhatLinksHere::class, 'SpecialWhatLinksHere' ); |