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