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