Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 441
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 / 440
0.00% covered (danger)
0.00%
0 / 19
9900
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 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 parseOffsetAndDir
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 showIndirectLinks
0.00% covered (danger)
0.00%
0 / 215
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 / 43
0.00% covered (danger)
0.00%
0 / 1
110
 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 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getPrevNext
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 getFormFields
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
12
 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 / 2
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 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\Cache\LinkBatchFactory;
10use MediaWiki\Content\IContentHandlerFactory;
11use MediaWiki\Deferred\LinksUpdate\ImageLinksTable;
12use MediaWiki\Deferred\LinksUpdate\PageLinksTable;
13use MediaWiki\Deferred\LinksUpdate\TemplateLinksTable;
14use MediaWiki\Html\Html;
15use MediaWiki\HTMLForm\HTMLForm;
16use MediaWiki\Linker\LinksMigration;
17use MediaWiki\Linker\LinkTarget;
18use MediaWiki\MainConfigNames;
19use MediaWiki\Message\Message;
20use MediaWiki\Navigation\PagerNavigationBuilder;
21use MediaWiki\Page\PageIdentity;
22use MediaWiki\SpecialPage\FormSpecialPage;
23use MediaWiki\Title\NamespaceInfo;
24use MediaWiki\Title\Title;
25use MediaWiki\Title\TitleFactory;
26use SearchEngineFactory;
27use stdClass;
28use Wikimedia\Rdbms\IConnectionProvider;
29use Wikimedia\Rdbms\IReadableDatabase;
30use Wikimedia\Rdbms\SelectQueryBuilder;
31
32/**
33 * Implements Special:Whatlinkshere
34 *
35 * @ingroup SpecialPage
36 */
37class 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 */
783class_alias( SpecialWhatLinksHere::class, 'SpecialWhatLinksHere' );