59 private array $formData;
68 private const LIMITS = [ 20, 50, 100, 250, 500 ];
88 parent::__construct(
'Whatlinkshere' );
89 $this->mIncludable =
true;
90 $this->dbProvider = $dbProvider;
91 $this->linkBatchFactory = $linkBatchFactory;
92 $this->contentHandlerFactory = $contentHandlerFactory;
93 $this->searchEngineFactory = $searchEngineFactory;
94 $this->namespaceInfo = $namespaceInfo;
95 $this->titleFactory = $titleFactory;
96 $this->linksMigration = $linksMigration;
106 $par = str_replace(
'_',
' ',
$par );
108 parent::setParameter(
$par );
115 $this->
getSkin()->setRelevantTitle( $this->target );
118 $out->setPageTitleMsg(
119 $this->
msg(
'whatlinkshere-title' )->plaintextParams( $this->target->getPrefixedText() )
121 $out->addBacklinkSubtitle( $this->target );
123 [ $offsetNamespace, $offsetPageID, $dir ] = $this->parseOffsetAndDir();
125 $this->showIndirectLinks(
128 $this->formData[
'limit'],
145 private function parseOffsetAndDir(): array {
146 $from = (int)$this->formData[
'from'];
150 $offsetNamespace =
null;
151 $offsetPageID = $from - 1;
153 $dir = $this->formData[
'dir'] ??
'next';
154 [ $offsetNamespaceString, $offsetPageIDString ] = explode(
156 $this->formData[
'offset'] .
'|'
158 if ( !$offsetPageIDString ) {
159 $offsetPageIDString = $offsetNamespaceString;
160 $offsetNamespaceString =
'';
162 if ( is_numeric( $offsetNamespaceString ) ) {
163 $offsetNamespace = (int)$offsetNamespaceString;
165 $offsetNamespace =
null;
167 $offsetPageID = (int)$offsetPageIDString;
170 if ( $offsetNamespace ===
null ) {
171 $offsetTitle = $this->titleFactory->newFromID( $offsetPageID );
172 $offsetNamespace = $offsetTitle ? $offsetTitle->getNamespace() :
NS_MAIN;
175 return [ $offsetNamespace, $offsetPageID, $dir ];
186 private function showIndirectLinks(
187 $level, LinkTarget $target, $limit, $offsetNamespace = 0, $offsetPageID = 0, $dir =
'next'
189 $out = $this->getOutput();
190 $dbr = $this->dbProvider->getReplicaDatabase();
191 $hookRunner = $this->getHookRunner();
193 $hidelinks = $this->formData[
'hidelinks'];
194 $hideredirs = $this->formData[
'hideredirs'];
195 $hidetrans = $this->formData[
'hidetrans'];
196 $hideimages = $target->getNamespace() !==
NS_FILE || ( $this->formData[
'hideimages'] ?? false );
200 $fetchredirs = $hidelinks && !$hideredirs;
204 $conds[
'redirect'] = [
205 'rd_namespace' => $target->getNamespace(),
206 'rd_title' => $target->getDBkey(),
207 'rd_interwiki' =>
'',
209 $conds[
'pagelinks'] = $this->linksMigration->getLinksConditions(
'pagelinks', $target );
210 $conds[
'templatelinks'] = $this->linksMigration->getLinksConditions(
'templatelinks', $target );
211 $conds[
'imagelinks'] = [
212 'il_to' => $target->getDBkey(),
215 $namespace = $this->formData[
'namespace'];
216 if ( $namespace !==
'' ) {
217 $namespace = intval( $this->formData[
'namespace'] );
218 $invert = $this->formData[
'invert'];
222 $namespaces = array_diff(
223 $this->namespaceInfo->getValidNamespaces(), [ $namespace ] );
225 $namespaces = $namespace;
230 $namespaces = $this->namespaceInfo->getValidNamespaces();
232 $conds[
'redirect'][
'page_namespace'] = $namespaces;
233 $conds[
'pagelinks'][
'pl_from_namespace'] = $namespaces;
234 $conds[
'templatelinks'][
'tl_from_namespace'] = $namespaces;
235 $conds[
'imagelinks'][
'il_from_namespace'] = $namespaces;
237 if ( $offsetPageID ) {
238 $op = $dir ===
'prev' ?
'<' :
'>';
239 $conds[
'redirect'][] = $dbr->buildComparison( $op, [
240 'rd_from' => $offsetPageID,
242 $conds[
'templatelinks'][] = $dbr->buildComparison( $op, [
243 'tl_from_namespace' => $offsetNamespace,
244 'tl_from' => $offsetPageID,
246 $conds[
'pagelinks'][] = $dbr->buildComparison( $op, [
247 'pl_from_namespace' => $offsetNamespace,
248 'pl_from' => $offsetPageID,
250 $conds[
'imagelinks'][] = $dbr->buildComparison( $op, [
251 'il_from_namespace' => $offsetNamespace,
252 'il_from' => $offsetPageID,
260 $conds[
'pagelinks'][
'rd_from'] =
null;
263 $sortDirection = $dir ===
'prev' ? SelectQueryBuilder::SORT_DESC : SelectQueryBuilder::SORT_ASC;
266 $queryFunc =
function ( IReadableDatabase $dbr, $table, $fromCol ) use (
267 $conds, $target, $limit, $sortDirection, $fname, $hookRunner
270 $queryLimit = $limit + 1;
272 "rd_from = $fromCol",
273 'rd_title' => $target->getDBkey(),
274 'rd_namespace' => $target->getNamespace(),
275 'rd_interwiki' =>
'',
278 $subQuery = $dbr->newSelectQueryBuilder()
280 ->fields( [ $fromCol,
'rd_from',
'rd_fragment' ] )
281 ->conds( $conds[$table] )
282 ->orderBy( [ $fromCol .
'_namespace', $fromCol ], $sortDirection )
283 ->limit( 2 * $queryLimit )
284 ->leftJoin(
'redirect',
'redirect', $on );
286 $queryBuilder = $dbr->newSelectQueryBuilder()
287 ->table( $subQuery,
'temp_backlink_range' )
288 ->join(
'page',
'page',
"$fromCol = page_id" )
289 ->fields( [
'page_id',
'page_namespace',
'page_title',
290 'rd_from',
'rd_fragment',
'page_is_redirect' ] )
291 ->orderBy( [
'page_namespace',
'page_id' ], $sortDirection )
292 ->limit( $queryLimit );
294 $hookRunner->onSpecialWhatLinksHereQuery( $table, $this->formData, $queryBuilder );
296 return $queryBuilder->caller( $fname )->fetchResultSet();
299 if ( $fetchredirs ) {
300 $queryBuilder = $dbr->newSelectQueryBuilder()
301 ->table(
'redirect' )
302 ->fields( [
'page_id',
'page_namespace',
'page_title',
'rd_from',
'rd_fragment',
'page_is_redirect' ] )
303 ->conds( $conds[
'redirect'] )
304 ->orderBy(
'rd_from', $sortDirection )
305 ->limit( $limit + 1 )
306 ->join(
'page',
'page',
'rd_from = page_id' );
308 $hookRunner->onSpecialWhatLinksHereQuery(
'redirect', $this->formData, $queryBuilder );
310 $rdRes = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
314 $plRes = $queryFunc( $dbr,
'pagelinks',
'pl_from' );
318 $tlRes = $queryFunc( $dbr,
'templatelinks',
'tl_from' );
321 if ( !$hideimages ) {
322 $ilRes = $queryFunc( $dbr,
'imagelinks',
'il_from' );
326 if ( ( !$fetchredirs || !$rdRes->numRows() )
328 && ( $hidelinks || !$plRes->numRows() )
330 && ( $hidetrans || !$tlRes->numRows() )
332 && ( $hideimages || !$ilRes->numRows() )
334 if ( $level == 0 && !$this->including() ) {
335 if ( $hidelinks || $hidetrans || $hideredirs ) {
336 $msgKey =
'nolinkshere-filter';
337 } elseif ( is_int( $namespace ) ) {
338 $msgKey =
'nolinkshere-ns';
340 $msgKey =
'nolinkshere';
342 $link = $this->getLinkRenderer()->makeLink(
346 $this->target->isRedirect() ? [
'redirect' =>
'no' ] : []
349 $errMsg = $this->msg( $msgKey )
350 ->params( $this->target->getPrefixedText() )
353 $out->addHTML( $errMsg );
354 $out->setStatusCode( 404 );
364 if ( $fetchredirs ) {
366 foreach ( $rdRes as $row ) {
367 $row->is_template = 0;
369 $rows[$row->page_id] = $row;
374 foreach ( $plRes as $row ) {
375 $row->is_template = 0;
377 $rows[$row->page_id] = $row;
382 foreach ( $tlRes as $row ) {
383 $row->is_template = 1;
385 $rows[$row->page_id] = $row;
388 if ( !$hideimages ) {
390 foreach ( $ilRes as $row ) {
391 $row->is_template = 0;
393 $rows[$row->page_id] = $row;
398 usort( $rows,
static function ( $rowA, $rowB ) {
399 if ( $rowA->page_namespace !== $rowB->page_namespace ) {
400 return $rowA->page_namespace < $rowB->page_namespace ? -1 : 1;
402 if ( $rowA->page_id !== $rowB->page_id ) {
403 return $rowA->page_id < $rowB->page_id ? -1 : 1;
408 $numRows = count( $rows );
412 $nextNamespace = $nextPageId = $prevNamespace = $prevPageId =
false;
414 } elseif ( $dir ===
'prev' ) {
415 if ( $numRows > $limit ) {
418 $nextNamespace = $rows[$limit]->page_namespace;
419 $nextPageId = $rows[$limit]->page_id;
421 $rows = array_slice( $rows, 1, $limit );
423 $prevNamespace = $rows[0]->page_namespace;
424 $prevPageId = $rows[0]->page_id;
427 $nextNamespace = $rows[$numRows - 1]->page_namespace;
428 $nextPageId = $rows[$numRows - 1]->page_id;
429 $prevNamespace =
false;
434 $prevNamespace = $offsetPageID ? $rows[0]->page_namespace :
false;
435 $prevPageId = $offsetPageID ? $rows[0]->page_id :
false;
436 if ( $numRows > $limit ) {
438 $nextNamespace = $rows[$limit - 1]->page_namespace ??
false;
439 $nextPageId = $rows[$limit - 1]->page_id ??
false;
441 $rows = array_slice( $rows, 0, $limit );
443 $nextNamespace =
false;
450 $lb = $this->linkBatchFactory->newLinkBatch();
451 foreach ( $rows as $row ) {
452 $lb->add( $row->page_namespace, $row->page_title );
456 if ( $level == 0 && !$this->including() ) {
457 $link = $this->getLinkRenderer()->makeLink(
461 $this->target->isRedirect() ? [
'redirect' =>
'no' ] : []
464 $msg = $this->msg(
'linkshere' )
465 ->params( $this->target->getPrefixedText() )
468 $out->addHTML( $msg );
472 $prevnext = $this->getPrevNext( $prevNamespace, $prevPageId, $nextNamespace, $nextPageId );
473 $out->addHTML( $prevnext );
475 $out->addHTML( $this->listStart( $level ) );
476 foreach ( $rows as $row ) {
477 $nt = Title::makeTitle( $row->page_namespace, $row->page_title );
479 if ( $row->rd_from && $level < 2 ) {
480 $out->addHTML( $this->listItem( $row, $nt, $target,
true ) );
481 $this->showIndirectLinks(
488 $out->addHTML( $this->listItem( $row, $nt, $target ) );
492 $out->addHTML( $this->listEnd() );
494 if ( $level == 0 && !$this->including() ) {
497 $out->addHTML( $prevnext );
502 return Xml::openElement(
'ul', ( $level ? [] : [
'id' =>
'mw-whatlinkshere-list' ] ) );
505 private function listItem( stdClass $row,
PageIdentity $nt,
LinkTarget $target,
bool $notClose =
false ) {
506 $legacyTitle = $this->titleFactory->newFromPageIdentity( $nt );
508 if ( $row->rd_from ) {
509 $query = [
'redirect' =>
'no' ];
514 $dir = $this->getLanguage()->getDir();
515 $link = Html::rawElement(
'bdi', [
'dir' => $dir ], $this->getLinkRenderer()->makeKnownLink(
518 $row->page_is_redirect ? [
'class' =>
'mw-redirect' ] : [],
525 if ( (
string)$row->rd_fragment !==
'' ) {
526 $props[] = $this->msg(
'whatlinkshere-sectionredir' )
527 ->rawParams( $this->getLinkRenderer()->makeLink(
531 } elseif ( $row->rd_from ) {
532 $props[] = $this->msg(
'isredirect' )->escaped();
534 if ( $row->is_template ) {
535 $props[] = $this->msg(
'istemplate' )->escaped();
537 if ( $row->is_image ) {
538 $props[] = $this->msg(
'isimage' )->escaped();
541 $legacyTarget = $this->titleFactory->newFromLinkTarget( $target );
542 $this->getHookRunner()->onWhatLinksHereProps( $row, $legacyTitle, $legacyTarget, $props );
544 if ( count( $props ) ) {
545 $propsText = $this->msg(
'parentheses' )
546 ->rawParams( $this->getLanguage()->semicolonList( $props ) )->escaped();
549 # Space for utilities links, with a what-links-here link provided
550 $wlhLink = $this->wlhLink(
552 $this->msg(
'whatlinkshere-links' )->text(),
553 $this->msg(
'editlink' )->text()
555 $wlh = Html::rawElement(
557 [
'class' =>
'mw-whatlinkshere-tools' ],
558 $this->msg(
'parentheses' )->rawParams( $wlhLink )->escaped()
563 Xml::tags(
'li',
null,
"$link $propsText $wlh" ) .
"\n";
571 static $title =
null;
572 $title ??= $this->getPageTitle();
574 $linkRenderer = $this->getLinkRenderer();
578 'links' => $linkRenderer->makeKnownLink(
589 $this->getAuthority()->isAllowed(
'edit' ) &&
591 $this->contentHandlerFactory->getContentHandler( $target->
getContentModel() )
592 ->supportsDirectEditing()
594 $links[
'edit'] = $linkRenderer->makeKnownLink(
598 [
'action' =>
'edit' ]
603 return $this->getLanguage()->pipeList( $links );
606 private function getPrevNext( $prevNamespace, $prevPageId, $nextNamespace, $nextPageId ) {
610 ->setPage( $this->getPageTitle( $this->target->getPrefixedDBkey() ) )
616 fn ( $value ) => $value !==
null && $value !==
'' && $value !==
false
618 [
'target' =>
null,
'from' =>
null ]
621 ->setLimits( self::LIMITS )
622 ->setLimitLinkQueryParam(
'limit' )
623 ->setCurrentLimit( $this->formData[
'limit'] )
624 ->setPrevMsg(
'whatlinkshere-prev' )
625 ->setNextMsg(
'whatlinkshere-next' );
627 if ( $prevPageId != 0 ) {
628 $navBuilder->setPrevLinkQuery( [
'dir' =>
'prev',
'offset' =>
"$prevNamespace|$prevPageId" ] );
630 if ( $nextPageId != 0 ) {
631 $navBuilder->setNextLinkQuery( [
'dir' =>
'next',
'offset' =>
"$nextNamespace|$nextPageId" ] );
634 return $navBuilder->getHtml();
638 $this->addHelpLink(
'Help:What links here' );
639 $this->getOutput()->addModuleStyles(
'mediawiki.special' );
645 'id' =>
'mw-whatlinkshere-target',
646 'label-message' =>
'whatlinkshere-page',
647 'section' =>
'whatlinkshere-target',
651 'type' =>
'namespaceselect',
652 'name' =>
'namespace',
654 'label-message' =>
'namespace',
657 'in-user-lang' =>
true,
658 'section' =>
'whatlinkshere-ns',
664 'hide-if' => [
'===',
'namespace',
'' ],
665 'label-message' =>
'invert',
666 'help-message' =>
'tooltip-whatlinkshere-invert',
667 'help-inline' =>
false,
668 'section' =>
'whatlinkshere-ns'
674 'filter-callback' =>
static fn ( $value ) => max( 0, min( intval( $value ), 5000 ) ),
692 $filters = [
'hidetrans',
'hidelinks',
'hideredirs' ];
697 foreach ( $filters as $filter ) {
699 $hide = $this->msg(
'hide' )->text();
700 $msg = $this->msg(
"whatlinkshere-{$filter}", $hide )->text();
705 'section' =>
'whatlinkshere-filter',
718 $this->target = Title::newFromText( $this->getRequest()->getText(
'target' ) );
719 if ( $this->target && $this->target->getNamespace() ==
NS_FILE ) {
720 $hide = $this->msg(
'hide' )->text();
721 $msg = $this->msg(
'whatlinkshere-hideimages', $hide )->text();
725 'name' =>
'hideimages',
727 'section' =>
'whatlinkshere-filter',
733 ->setSubmitTextMsg(
'whatlinkshere-submit' );
745 $this->formData = $data;
766 return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory );