58 private array $formData;
67 private const LIMITS = [ 20, 50, 100, 250, 500 ];
78 parent::__construct(
'Whatlinkshere' );
79 $this->mIncludable =
true;
80 $this->dbProvider = $dbProvider;
81 $this->linkBatchFactory = $linkBatchFactory;
82 $this->contentHandlerFactory = $contentHandlerFactory;
83 $this->searchEngineFactory = $searchEngineFactory;
84 $this->namespaceInfo = $namespaceInfo;
85 $this->titleFactory = $titleFactory;
86 $this->linksMigration = $linksMigration;
96 $par = str_replace(
'_',
' ',
$par );
98 parent::setParameter(
$par );
105 $this->
getSkin()->setRelevantTitle( $this->target );
108 $out->setPageTitleMsg(
109 $this->
msg(
'whatlinkshere-title' )->plaintextParams( $this->target->getPrefixedText() )
111 $out->addBacklinkSubtitle( $this->target );
113 [ $offsetNamespace, $offsetPageID, $dir ] = $this->parseOffsetAndDir();
115 $this->showIndirectLinks(
118 $this->formData[
'limit'],
135 private function parseOffsetAndDir(): array {
136 $from = (int)$this->formData[
'from'];
140 $offsetNamespace =
null;
141 $offsetPageID = $from - 1;
143 $dir = $this->formData[
'dir'] ??
'next';
144 [ $offsetNamespaceString, $offsetPageIDString ] = explode(
146 $this->formData[
'offset'] .
'|'
148 if ( !$offsetPageIDString ) {
149 $offsetPageIDString = $offsetNamespaceString;
150 $offsetNamespaceString =
'';
152 if ( is_numeric( $offsetNamespaceString ) ) {
153 $offsetNamespace = (int)$offsetNamespaceString;
155 $offsetNamespace =
null;
157 $offsetPageID = (int)$offsetPageIDString;
160 if ( $offsetNamespace ===
null ) {
161 $offsetTitle = $this->titleFactory->newFromID( $offsetPageID );
162 $offsetNamespace = $offsetTitle ? $offsetTitle->getNamespace() :
NS_MAIN;
165 return [ $offsetNamespace, $offsetPageID, $dir ];
176 private function showIndirectLinks(
177 $level, LinkTarget $target, $limit, $offsetNamespace = 0, $offsetPageID = 0, $dir =
'next'
179 $out = $this->getOutput();
180 $dbr = $this->dbProvider->getReplicaDatabase();
181 $hookRunner = $this->getHookRunner();
183 $hidelinks = $this->formData[
'hidelinks'];
184 $hideredirs = $this->formData[
'hideredirs'];
185 $hidetrans = $this->formData[
'hidetrans'];
186 $hideimages = $target->getNamespace() !==
NS_FILE || ( $this->formData[
'hideimages'] ?? false );
190 $fetchredirs = $hidelinks && !$hideredirs;
194 $conds[
'redirect'] = [
195 'rd_namespace' => $target->getNamespace(),
196 'rd_title' => $target->getDBkey(),
197 'rd_interwiki' =>
'',
199 $conds[
'pagelinks'] = $this->linksMigration->getLinksConditions(
'pagelinks', $target );
200 $conds[
'templatelinks'] = $this->linksMigration->getLinksConditions(
'templatelinks', $target );
201 $conds[
'imagelinks'] = [
202 'il_to' => $target->getDBkey(),
205 $namespace = $this->formData[
'namespace'];
206 if ( $namespace !==
'' ) {
207 $invert = $this->formData[
'invert'];
211 $namespaces = array_diff(
212 $this->namespaceInfo->getValidNamespaces(), [ $namespace ] );
214 $namespaces = $namespace;
219 $namespaces = $this->namespaceInfo->getValidNamespaces();
221 $conds[
'redirect'][
'page_namespace'] = $namespaces;
222 $conds[
'pagelinks'][
'pl_from_namespace'] = $namespaces;
223 $conds[
'templatelinks'][
'tl_from_namespace'] = $namespaces;
224 $conds[
'imagelinks'][
'il_from_namespace'] = $namespaces;
226 if ( $offsetPageID ) {
227 $op = $dir ===
'prev' ?
'<' :
'>';
228 $conds[
'redirect'][] = $dbr->buildComparison( $op, [
229 'rd_from' => $offsetPageID,
231 $conds[
'templatelinks'][] = $dbr->buildComparison( $op, [
232 'tl_from_namespace' => $offsetNamespace,
233 'tl_from' => $offsetPageID,
235 $conds[
'pagelinks'][] = $dbr->buildComparison( $op, [
236 'pl_from_namespace' => $offsetNamespace,
237 'pl_from' => $offsetPageID,
239 $conds[
'imagelinks'][] = $dbr->buildComparison( $op, [
240 'il_from_namespace' => $offsetNamespace,
241 'il_from' => $offsetPageID,
249 $conds[
'pagelinks'][
'rd_from'] =
null;
252 $sortDirection = $dir ===
'prev' ? SelectQueryBuilder::SORT_DESC : SelectQueryBuilder::SORT_ASC;
255 $queryFunc =
function ( IReadableDatabase $dbr, $table, $fromCol ) use (
256 $conds, $target, $limit, $sortDirection, $fname, $hookRunner
259 $queryLimit = $limit + 1;
261 "rd_from = $fromCol",
262 'rd_title' => $target->getDBkey(),
263 'rd_namespace' => $target->getNamespace(),
264 'rd_interwiki' =>
'',
267 $subQuery = $dbr->newSelectQueryBuilder()
269 ->fields( [ $fromCol,
'rd_from',
'rd_fragment' ] )
270 ->conds( $conds[$table] )
271 ->orderBy( [ $fromCol .
'_namespace', $fromCol ], $sortDirection )
272 ->limit( 2 * $queryLimit )
273 ->leftJoin(
'redirect',
'redirect', $on );
275 $queryBuilder = $dbr->newSelectQueryBuilder()
276 ->table( $subQuery,
'temp_backlink_range' )
277 ->join(
'page',
'page',
"$fromCol = page_id" )
278 ->fields( [
'page_id',
'page_namespace',
'page_title',
279 'rd_from',
'rd_fragment',
'page_is_redirect' ] )
280 ->orderBy( [
'page_namespace',
'page_id' ], $sortDirection )
281 ->limit( $queryLimit );
283 $hookRunner->onSpecialWhatLinksHereQuery( $table, $this->formData, $queryBuilder );
285 return $queryBuilder->caller( $fname )->fetchResultSet();
288 if ( $fetchredirs ) {
289 $queryBuilder = $dbr->newSelectQueryBuilder()
290 ->table(
'redirect' )
291 ->fields( [
'page_id',
'page_namespace',
'page_title',
'rd_from',
'rd_fragment',
'page_is_redirect' ] )
292 ->conds( $conds[
'redirect'] )
293 ->orderBy(
'rd_from', $sortDirection )
294 ->limit( $limit + 1 )
295 ->join(
'page',
'page',
'rd_from = page_id' );
297 $hookRunner->onSpecialWhatLinksHereQuery(
'redirect', $this->formData, $queryBuilder );
299 $rdRes = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
303 $plRes = $queryFunc( $dbr,
'pagelinks',
'pl_from' );
307 $tlRes = $queryFunc( $dbr,
'templatelinks',
'tl_from' );
310 if ( !$hideimages ) {
311 $ilRes = $queryFunc( $dbr,
'imagelinks',
'il_from' );
315 if ( ( !$fetchredirs || !$rdRes->numRows() )
317 && ( $hidelinks || !$plRes->numRows() )
319 && ( $hidetrans || !$tlRes->numRows() )
321 && ( $hideimages || !$ilRes->numRows() )
323 if ( $level == 0 && !$this->including() ) {
324 if ( $hidelinks || $hidetrans || $hideredirs ) {
325 $msgKey =
'nolinkshere-filter';
326 } elseif ( $namespace !==
'' ) {
327 $msgKey =
'nolinkshere-ns';
329 $msgKey =
'nolinkshere';
331 $link = $this->getLinkRenderer()->makeLink(
335 $this->target->isRedirect() ? [
'redirect' =>
'no' ] : []
338 $errMsg = $this->msg( $msgKey )
339 ->params( $this->target->getPrefixedText() )
342 $out->addHTML( $errMsg );
343 $out->setStatusCode( 404 );
353 if ( $fetchredirs ) {
355 foreach ( $rdRes as $row ) {
356 $row->is_template = 0;
358 $rows[$row->page_id] = $row;
363 foreach ( $plRes as $row ) {
364 $row->is_template = 0;
366 $rows[$row->page_id] = $row;
371 foreach ( $tlRes as $row ) {
372 $row->is_template = 1;
374 $rows[$row->page_id] = $row;
377 if ( !$hideimages ) {
379 foreach ( $ilRes as $row ) {
380 $row->is_template = 0;
382 $rows[$row->page_id] = $row;
387 usort( $rows,
static function ( $rowA, $rowB ) {
388 if ( $rowA->page_namespace !== $rowB->page_namespace ) {
389 return $rowA->page_namespace < $rowB->page_namespace ? -1 : 1;
391 if ( $rowA->page_id !== $rowB->page_id ) {
392 return $rowA->page_id < $rowB->page_id ? -1 : 1;
397 $numRows = count( $rows );
401 $nextNamespace = $nextPageId = $prevNamespace = $prevPageId =
false;
403 } elseif ( $dir ===
'prev' ) {
404 if ( $numRows > $limit ) {
407 $nextNamespace = $rows[$limit]->page_namespace;
408 $nextPageId = $rows[$limit]->page_id;
410 $rows = array_slice( $rows, 1, $limit );
412 $prevNamespace = $rows[0]->page_namespace;
413 $prevPageId = $rows[0]->page_id;
416 $nextNamespace = $rows[$numRows - 1]->page_namespace;
417 $nextPageId = $rows[$numRows - 1]->page_id;
418 $prevNamespace =
false;
423 $prevNamespace = $offsetPageID ? $rows[0]->page_namespace :
false;
424 $prevPageId = $offsetPageID ? $rows[0]->page_id :
false;
425 if ( $numRows > $limit ) {
427 $nextNamespace = $rows[$limit - 1]->page_namespace ??
false;
428 $nextPageId = $rows[$limit - 1]->page_id ??
false;
430 $rows = array_slice( $rows, 0, $limit );
432 $nextNamespace =
false;
438 $lb = $this->linkBatchFactory->newLinkBatch()->setCaller( __METHOD__ );
439 foreach ( $rows as $row ) {
440 $lb->add( $row->page_namespace, $row->page_title );
444 if ( $level == 0 && !$this->including() ) {
445 $link = $this->getLinkRenderer()->makeLink(
449 $this->target->isRedirect() ? [
'redirect' =>
'no' ] : []
452 $msg = $this->msg(
'linkshere' )
453 ->params( $this->target->getPrefixedText() )
456 $out->addHTML( $msg );
460 $prevnext = $this->getPrevNext( $prevNamespace, $prevPageId, $nextNamespace, $nextPageId );
461 $out->addHTML( $prevnext );
463 $out->addHTML( $this->listStart( $level ) );
464 foreach ( $rows as $row ) {
465 $nt = Title::makeTitle( $row->page_namespace, $row->page_title );
467 if ( $row->rd_from && $level < 2 ) {
468 $out->addHTML( $this->listItem( $row, $nt, $target,
true ) );
469 $this->showIndirectLinks(
474 $out->addHTML( Html::closeElement(
'li' ) );
476 $out->addHTML( $this->listItem( $row, $nt, $target ) );
480 $out->addHTML( $this->listEnd() );
482 if ( $level == 0 && !$this->including() ) {
485 $out->addHTML( $prevnext );
490 return Html::openElement(
'ul', ( $level ? [] : [
'id' =>
'mw-whatlinkshere-list' ] ) );
493 private function listItem( stdClass $row,
PageIdentity $nt,
LinkTarget $target,
bool $notClose =
false ): string {
494 $legacyTitle = $this->titleFactory->newFromPageIdentity( $nt );
496 if ( $row->rd_from || $row->page_is_redirect ) {
497 $query = [
'redirect' =>
'no' ];
502 $dir = $this->getLanguage()->getDir();
503 $link = Html::rawElement(
'bdi', [
'dir' => $dir ], $this->getLinkRenderer()->makeKnownLink(
506 $row->page_is_redirect ? [
'class' =>
'mw-redirect' ] : [],
513 if ( (
string)$row->rd_fragment !==
'' ) {
514 $props[] = $this->msg(
'whatlinkshere-sectionredir' )
515 ->rawParams( $this->getLinkRenderer()->makeLink(
519 } elseif ( $row->rd_from ) {
520 $props[] = $this->msg(
'isredirect' )->escaped();
522 if ( $row->is_template ) {
523 $props[] = $this->msg(
'istemplate' )->escaped();
525 if ( $row->is_image ) {
526 $props[] = $this->msg(
'isimage' )->escaped();
529 $legacyTarget = $this->titleFactory->newFromLinkTarget( $target );
530 $this->getHookRunner()->onWhatLinksHereProps( $row, $legacyTitle, $legacyTarget, $props );
532 if ( count( $props ) ) {
533 $propsText = $this->msg(
'parentheses' )
534 ->rawParams( $this->getLanguage()->semicolonList( $props ) )->escaped();
537 # Space for utilities links, with a what-links-here link provided
538 $wlhLink = $this->wlhLink(
540 $this->msg(
'whatlinkshere-links' )->text(),
541 $this->msg(
'editlink' )->text()
543 $wlh = Html::rawElement(
545 [
'class' =>
'mw-whatlinkshere-tools' ],
546 $this->msg(
'parentheses' )->rawParams( $wlhLink )->escaped()
550 Html::openElement(
'li' ) .
"$link $propsText $wlh\n" :
551 Html::rawElement(
'li', [],
"$link $propsText $wlh" ) .
"\n";
555 return Html::closeElement(
'ul' );
559 static $title =
null;
560 $title ??= $this->getPageTitle();
562 $linkRenderer = $this->getLinkRenderer();
566 'links' => $linkRenderer->makeKnownLink(
577 $this->getAuthority()->isAllowed(
'edit' ) &&
579 $this->contentHandlerFactory->getContentHandler( $target->
getContentModel() )
580 ->supportsDirectEditing()
582 $links[
'edit'] = $linkRenderer->makeKnownLink(
586 [
'action' =>
'edit' ]
591 return $this->getLanguage()->pipeList( $links );
600 private function getPrevNext( $prevNamespace, $prevPageId, $nextNamespace, $nextPageId ): string {
604 ->
setPage( $this->getPageTitle( $this->target->getPrefixedDBkey() ) )
610 static fn ( $value ) => $value !==
null && $value !==
'' && $value !==
false
612 [
'target' =>
null,
'from' =>
null ]
615 ->setLimits( self::LIMITS )
616 ->setLimitLinkQueryParam(
'limit' )
617 ->setCurrentLimit( $this->formData[
'limit'] )
618 ->setPrevMsg(
'whatlinkshere-prev' )
619 ->setNextMsg(
'whatlinkshere-next' );
621 if ( $prevPageId != 0 ) {
622 $navBuilder->setPrevLinkQuery( [
'dir' =>
'prev',
'offset' =>
"$prevNamespace|$prevPageId" ] );
624 if ( $nextPageId != 0 ) {
625 $navBuilder->setNextLinkQuery( [
'dir' =>
'next',
'offset' =>
"$nextNamespace|$nextPageId" ] );
628 return $navBuilder->getHtml();
632 $this->addHelpLink(
'Help:What links here' );
633 $this->getOutput()->addModuleStyles(
'mediawiki.special' );
639 'id' =>
'mw-whatlinkshere-target',
640 'label-message' =>
'whatlinkshere-page',
641 'section' =>
'whatlinkshere-target',
645 'type' =>
'namespaceselect',
646 'name' =>
'namespace',
648 'label-message' =>
'namespace',
651 'filter-callback' =>
static function ( $value ) {
652 return $value !==
'' ? intval( $value ) :
'';
654 'in-user-lang' =>
true,
655 'section' =>
'whatlinkshere-ns',
661 'hide-if' => [
'===',
'namespace',
'' ],
662 'label-message' =>
'invert',
663 'help-message' =>
'tooltip-whatlinkshere-invert',
664 'help-inline' =>
false,
665 'section' =>
'whatlinkshere-ns'
670 'default' => $this->getConfig()->get( MainConfigNames::QueryPageDefaultLimit ),
671 'filter-callback' =>
static fn ( $value ) => max( 0, min( intval( $value ), 5000 ) ),
689 $filters = [
'hidetrans',
'hidelinks',
'hideredirs' ];
694 foreach ( $filters as $filter ) {
696 $hide = $this->msg(
'hide' )->text();
697 $msg = $this->msg(
"whatlinkshere-{$filter}", $hide )->text();
702 'section' =>
'whatlinkshere-filter',
715 $this->target = Title::newFromText( $this->getRequest()->getText(
'target' ) );
716 if ( $this->target && $this->target->getNamespace() ==
NS_FILE ) {
717 $hide = $this->msg(
'hide' )->text();
718 $msg = $this->msg(
'whatlinkshere-hideimages', $hide )->text();
722 'name' =>
'hideimages',
724 'section' =>
'whatlinkshere-filter',
730 ->setSubmitTextMsg(
'whatlinkshere-submit' );
742 $this->formData = $data;
763 return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory );