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