]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/specials/SpecialWhatlinkshere.php
MediaWiki 1.30.2-scripts2
[autoinstalls/mediawiki.git] / includes / specials / SpecialWhatlinkshere.php
1 <?php
2 /**
3  * Implements Special:Whatlinkshere
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License along
16  * with this program; if not, write to the Free Software Foundation, Inc.,
17  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18  * http://www.gnu.org/copyleft/gpl.html
19  *
20  * @file
21  * @todo Use some variant of Pager or something; the pagination here is lousy.
22  */
23
24 use Wikimedia\Rdbms\IDatabase;
25
26 /**
27  * Implements Special:Whatlinkshere
28  *
29  * @ingroup SpecialPage
30  */
31 class SpecialWhatLinksHere extends IncludableSpecialPage {
32         /** @var FormOptions */
33         protected $opts;
34
35         protected $selfTitle;
36
37         /** @var Title */
38         protected $target;
39
40         protected $limits = [ 20, 50, 100, 250, 500 ];
41
42         public function __construct() {
43                 parent::__construct( 'Whatlinkshere' );
44         }
45
46         function execute( $par ) {
47                 $out = $this->getOutput();
48
49                 $this->setHeaders();
50                 $this->outputHeader();
51                 $this->addHelpLink( 'Help:What links here' );
52
53                 $opts = new FormOptions();
54
55                 $opts->add( 'target', '' );
56                 $opts->add( 'namespace', '', FormOptions::INTNULL );
57                 $opts->add( 'limit', $this->getConfig()->get( 'QueryPageDefaultLimit' ) );
58                 $opts->add( 'from', 0 );
59                 $opts->add( 'back', 0 );
60                 $opts->add( 'hideredirs', false );
61                 $opts->add( 'hidetrans', false );
62                 $opts->add( 'hidelinks', false );
63                 $opts->add( 'hideimages', false );
64                 $opts->add( 'invert', false );
65
66                 $opts->fetchValuesFromRequest( $this->getRequest() );
67                 $opts->validateIntBounds( 'limit', 0, 5000 );
68
69                 // Give precedence to subpage syntax
70                 if ( $par !== null ) {
71                         $opts->setValue( 'target', $par );
72                 }
73
74                 // Bind to member variable
75                 $this->opts = $opts;
76
77                 $this->target = Title::newFromText( $opts->getValue( 'target' ) );
78                 if ( !$this->target ) {
79                         if ( !$this->including() ) {
80                                 $out->addHTML( $this->whatlinkshereForm() );
81                         }
82
83                         return;
84                 }
85
86                 $this->getSkin()->setRelevantTitle( $this->target );
87
88                 $this->selfTitle = $this->getPageTitle( $this->target->getPrefixedDBkey() );
89
90                 $out->setPageTitle( $this->msg( 'whatlinkshere-title', $this->target->getPrefixedText() ) );
91                 $out->addBacklinkSubtitle( $this->target );
92                 $this->showIndirectLinks(
93                         0,
94                         $this->target,
95                         $opts->getValue( 'limit' ),
96                         $opts->getValue( 'from' ),
97                         $opts->getValue( 'back' )
98                 );
99         }
100
101         /**
102          * @param int $level Recursion level
103          * @param Title $target Target title
104          * @param int $limit Number of entries to display
105          * @param int $from Display from this article ID (default: 0)
106          * @param int $back Display from this article ID at backwards scrolling (default: 0)
107          */
108         function showIndirectLinks( $level, $target, $limit, $from = 0, $back = 0 ) {
109                 $out = $this->getOutput();
110                 $dbr = wfGetDB( DB_REPLICA );
111
112                 $hidelinks = $this->opts->getValue( 'hidelinks' );
113                 $hideredirs = $this->opts->getValue( 'hideredirs' );
114                 $hidetrans = $this->opts->getValue( 'hidetrans' );
115                 $hideimages = $target->getNamespace() != NS_FILE || $this->opts->getValue( 'hideimages' );
116
117                 $fetchlinks = ( !$hidelinks || !$hideredirs );
118
119                 // Build query conds in concert for all three tables...
120                 $conds['pagelinks'] = [
121                         'pl_namespace' => $target->getNamespace(),
122                         'pl_title' => $target->getDBkey(),
123                 ];
124                 $conds['templatelinks'] = [
125                         'tl_namespace' => $target->getNamespace(),
126                         'tl_title' => $target->getDBkey(),
127                 ];
128                 $conds['imagelinks'] = [
129                         'il_to' => $target->getDBkey(),
130                 ];
131
132                 $namespace = $this->opts->getValue( 'namespace' );
133                 $invert = $this->opts->getValue( 'invert' );
134                 $nsComparison = ( $invert ? '!= ' : '= ' ) . $dbr->addQuotes( $namespace );
135                 if ( is_int( $namespace ) ) {
136                         $conds['pagelinks'][] = "pl_from_namespace $nsComparison";
137                         $conds['templatelinks'][] = "tl_from_namespace $nsComparison";
138                         $conds['imagelinks'][] = "il_from_namespace $nsComparison";
139                 }
140
141                 if ( $from ) {
142                         $conds['templatelinks'][] = "tl_from >= $from";
143                         $conds['pagelinks'][] = "pl_from >= $from";
144                         $conds['imagelinks'][] = "il_from >= $from";
145                 }
146
147                 if ( $hideredirs ) {
148                         $conds['pagelinks']['rd_from'] = null;
149                 } elseif ( $hidelinks ) {
150                         $conds['pagelinks'][] = 'rd_from is NOT NULL';
151                 }
152
153                 $queryFunc = function ( IDatabase $dbr, $table, $fromCol ) use (
154                         $conds, $target, $limit
155                 ) {
156                         // Read an extra row as an at-end check
157                         $queryLimit = $limit + 1;
158                         $on = [
159                                 "rd_from = $fromCol",
160                                 'rd_title' => $target->getDBkey(),
161                                 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL'
162                         ];
163                         $on['rd_namespace'] = $target->getNamespace();
164                         // Inner LIMIT is 2X in case of stale backlinks with wrong namespaces
165                         $subQuery = $dbr->selectSQLText(
166                                 [ $table, 'redirect', 'page' ],
167                                 [ $fromCol, 'rd_from' ],
168                                 $conds[$table],
169                                 __CLASS__ . '::showIndirectLinks',
170                                 // Force JOIN order per T106682 to avoid large filesorts
171                                 [ 'ORDER BY' => $fromCol, 'LIMIT' => 2 * $queryLimit, 'STRAIGHT_JOIN' ],
172                                 [
173                                         'page' => [ 'INNER JOIN', "$fromCol = page_id" ],
174                                         'redirect' => [ 'LEFT JOIN', $on ]
175                                 ]
176                         );
177                         return $dbr->select(
178                                 [ 'page', 'temp_backlink_range' => "($subQuery)" ],
179                                 [ 'page_id', 'page_namespace', 'page_title', 'rd_from', 'page_is_redirect' ],
180                                 [],
181                                 __CLASS__ . '::showIndirectLinks',
182                                 [ 'ORDER BY' => 'page_id', 'LIMIT' => $queryLimit ],
183                                 [ 'page' => [ 'INNER JOIN', "$fromCol = page_id" ] ]
184                         );
185                 };
186
187                 if ( $fetchlinks ) {
188                         $plRes = $queryFunc( $dbr, 'pagelinks', 'pl_from' );
189                 }
190
191                 if ( !$hidetrans ) {
192                         $tlRes = $queryFunc( $dbr, 'templatelinks', 'tl_from' );
193                 }
194
195                 if ( !$hideimages ) {
196                         $ilRes = $queryFunc( $dbr, 'imagelinks', 'il_from' );
197                 }
198
199                 if ( ( !$fetchlinks || !$plRes->numRows() )
200                         && ( $hidetrans || !$tlRes->numRows() )
201                         && ( $hideimages || !$ilRes->numRows() )
202                 ) {
203                         if ( 0 == $level ) {
204                                 if ( !$this->including() ) {
205                                         $out->addHTML( $this->whatlinkshereForm() );
206
207                                         // Show filters only if there are links
208                                         if ( $hidelinks || $hidetrans || $hideredirs || $hideimages ) {
209                                                 $out->addHTML( $this->getFilterPanel() );
210                                         }
211                                         $errMsg = is_int( $namespace ) ? 'nolinkshere-ns' : 'nolinkshere';
212                                         $out->addWikiMsg( $errMsg, $this->target->getPrefixedText() );
213                                         $out->setStatusCode( 404 );
214                                 }
215                         }
216
217                         return;
218                 }
219
220                 // Read the rows into an array and remove duplicates
221                 // templatelinks comes second so that the templatelinks row overwrites the
222                 // pagelinks row, so we get (inclusion) rather than nothing
223                 if ( $fetchlinks ) {
224                         foreach ( $plRes as $row ) {
225                                 $row->is_template = 0;
226                                 $row->is_image = 0;
227                                 $rows[$row->page_id] = $row;
228                         }
229                 }
230                 if ( !$hidetrans ) {
231                         foreach ( $tlRes as $row ) {
232                                 $row->is_template = 1;
233                                 $row->is_image = 0;
234                                 $rows[$row->page_id] = $row;
235                         }
236                 }
237                 if ( !$hideimages ) {
238                         foreach ( $ilRes as $row ) {
239                                 $row->is_template = 0;
240                                 $row->is_image = 1;
241                                 $rows[$row->page_id] = $row;
242                         }
243                 }
244
245                 // Sort by key and then change the keys to 0-based indices
246                 ksort( $rows );
247                 $rows = array_values( $rows );
248
249                 $numRows = count( $rows );
250
251                 // Work out the start and end IDs, for prev/next links
252                 if ( $numRows > $limit ) {
253                         // More rows available after these ones
254                         // Get the ID from the last row in the result set
255                         $nextId = $rows[$limit]->page_id;
256                         // Remove undisplayed rows
257                         $rows = array_slice( $rows, 0, $limit );
258                 } else {
259                         // No more rows after
260                         $nextId = false;
261                 }
262                 $prevId = $from;
263
264                 // use LinkBatch to make sure, that all required data (associated with Titles)
265                 // is loaded in one query
266                 $lb = new LinkBatch();
267                 foreach ( $rows as $row ) {
268                         $lb->add( $row->page_namespace, $row->page_title );
269                 }
270                 $lb->execute();
271
272                 if ( $level == 0 ) {
273                         if ( !$this->including() ) {
274                                 $out->addHTML( $this->whatlinkshereForm() );
275                                 $out->addHTML( $this->getFilterPanel() );
276                                 $out->addWikiMsg( 'linkshere', $this->target->getPrefixedText() );
277
278                                 $prevnext = $this->getPrevNext( $prevId, $nextId );
279                                 $out->addHTML( $prevnext );
280                         }
281                 }
282                 $out->addHTML( $this->listStart( $level ) );
283                 foreach ( $rows as $row ) {
284                         $nt = Title::makeTitle( $row->page_namespace, $row->page_title );
285
286                         if ( $row->rd_from && $level < 2 ) {
287                                 $out->addHTML( $this->listItem( $row, $nt, $target, true ) );
288                                 $this->showIndirectLinks(
289                                         $level + 1,
290                                         $nt,
291                                         $this->getConfig()->get( 'MaxRedirectLinksRetrieved' )
292                                 );
293                                 $out->addHTML( Xml::closeElement( 'li' ) );
294                         } else {
295                                 $out->addHTML( $this->listItem( $row, $nt, $target ) );
296                         }
297                 }
298
299                 $out->addHTML( $this->listEnd() );
300
301                 if ( $level == 0 ) {
302                         if ( !$this->including() ) {
303                                 $out->addHTML( $prevnext );
304                         }
305                 }
306         }
307
308         protected function listStart( $level ) {
309                 return Xml::openElement( 'ul', ( $level ? [] : [ 'id' => 'mw-whatlinkshere-list' ] ) );
310         }
311
312         protected function listItem( $row, $nt, $target, $notClose = false ) {
313                 $dirmark = $this->getLanguage()->getDirMark();
314
315                 # local message cache
316                 static $msgcache = null;
317                 if ( $msgcache === null ) {
318                         static $msgs = [ 'isredirect', 'istemplate', 'semicolon-separator',
319                                 'whatlinkshere-links', 'isimage', 'editlink' ];
320                         $msgcache = [];
321                         foreach ( $msgs as $msg ) {
322                                 $msgcache[$msg] = $this->msg( $msg )->escaped();
323                         }
324                 }
325
326                 if ( $row->rd_from ) {
327                         $query = [ 'redirect' => 'no' ];
328                 } else {
329                         $query = [];
330                 }
331
332                 $link = $this->getLinkRenderer()->makeKnownLink(
333                         $nt,
334                         null,
335                         $row->page_is_redirect ? [ 'class' => 'mw-redirect' ] : [],
336                         $query
337                 );
338
339                 // Display properties (redirect or template)
340                 $propsText = '';
341                 $props = [];
342                 if ( $row->rd_from ) {
343                         $props[] = $msgcache['isredirect'];
344                 }
345                 if ( $row->is_template ) {
346                         $props[] = $msgcache['istemplate'];
347                 }
348                 if ( $row->is_image ) {
349                         $props[] = $msgcache['isimage'];
350                 }
351
352                 Hooks::run( 'WhatLinksHereProps', [ $row, $nt, $target, &$props ] );
353
354                 if ( count( $props ) ) {
355                         $propsText = $this->msg( 'parentheses' )
356                                 ->rawParams( implode( $msgcache['semicolon-separator'], $props ) )->escaped();
357                 }
358
359                 # Space for utilities links, with a what-links-here link provided
360                 $wlhLink = $this->wlhLink( $nt, $msgcache['whatlinkshere-links'], $msgcache['editlink'] );
361                 $wlh = Xml::wrapClass(
362                         $this->msg( 'parentheses' )->rawParams( $wlhLink )->escaped(),
363                         'mw-whatlinkshere-tools'
364                 );
365
366                 return $notClose ?
367                         Xml::openElement( 'li' ) . "$link $propsText $dirmark $wlh\n" :
368                         Xml::tags( 'li', null, "$link $propsText $dirmark $wlh" ) . "\n";
369         }
370
371         protected function listEnd() {
372                 return Xml::closeElement( 'ul' );
373         }
374
375         protected function wlhLink( Title $target, $text, $editText ) {
376                 static $title = null;
377                 if ( $title === null ) {
378                         $title = $this->getPageTitle();
379                 }
380
381                 $linkRenderer = $this->getLinkRenderer();
382
383                 if ( $text !== null ) {
384                         $text = new HtmlArmor( $text );
385                 }
386
387                 // always show a "<- Links" link
388                 $links = [
389                         'links' => $linkRenderer->makeKnownLink(
390                                 $title,
391                                 $text,
392                                 [],
393                                 [ 'target' => $target->getPrefixedText() ]
394                         ),
395                 ];
396
397                 // if the page is editable, add an edit link
398                 if (
399                         // check user permissions
400                         $this->getUser()->isAllowed( 'edit' ) &&
401                         // check, if the content model is editable through action=edit
402                         ContentHandler::getForTitle( $target )->supportsDirectEditing()
403                 ) {
404                         if ( $editText !== null ) {
405                                 $editText = new HtmlArmor( $editText );
406                         }
407
408                         $links['edit'] = $linkRenderer->makeKnownLink(
409                                 $target,
410                                 $editText,
411                                 [],
412                                 [ 'action' => 'edit' ]
413                         );
414                 }
415
416                 // build the links html
417                 return $this->getLanguage()->pipeList( $links );
418         }
419
420         function makeSelfLink( $text, $query ) {
421                 if ( $text !== null ) {
422                         $text = new HtmlArmor( $text );
423                 }
424
425                 return $this->getLinkRenderer()->makeKnownLink(
426                         $this->selfTitle,
427                         $text,
428                         [],
429                         $query
430                 );
431         }
432
433         function getPrevNext( $prevId, $nextId ) {
434                 $currentLimit = $this->opts->getValue( 'limit' );
435                 $prev = $this->msg( 'whatlinkshere-prev' )->numParams( $currentLimit )->escaped();
436                 $next = $this->msg( 'whatlinkshere-next' )->numParams( $currentLimit )->escaped();
437
438                 $changed = $this->opts->getChangedValues();
439                 unset( $changed['target'] ); // Already in the request title
440
441                 if ( 0 != $prevId ) {
442                         $overrides = [ 'from' => $this->opts->getValue( 'back' ) ];
443                         $prev = $this->makeSelfLink( $prev, array_merge( $changed, $overrides ) );
444                 }
445                 if ( 0 != $nextId ) {
446                         $overrides = [ 'from' => $nextId, 'back' => $prevId ];
447                         $next = $this->makeSelfLink( $next, array_merge( $changed, $overrides ) );
448                 }
449
450                 $limitLinks = [];
451                 $lang = $this->getLanguage();
452                 foreach ( $this->limits as $limit ) {
453                         $prettyLimit = htmlspecialchars( $lang->formatNum( $limit ) );
454                         $overrides = [ 'limit' => $limit ];
455                         $limitLinks[] = $this->makeSelfLink( $prettyLimit, array_merge( $changed, $overrides ) );
456                 }
457
458                 $nums = $lang->pipeList( $limitLinks );
459
460                 return $this->msg( 'viewprevnext' )->rawParams( $prev, $next, $nums )->escaped();
461         }
462
463         function whatlinkshereForm() {
464                 // We get nicer value from the title object
465                 $this->opts->consumeValue( 'target' );
466                 // Reset these for new requests
467                 $this->opts->consumeValues( [ 'back', 'from' ] );
468
469                 $target = $this->target ? $this->target->getPrefixedText() : '';
470                 $namespace = $this->opts->consumeValue( 'namespace' );
471                 $nsinvert = $this->opts->consumeValue( 'invert' );
472
473                 # Build up the form
474                 $f = Xml::openElement( 'form', [ 'action' => wfScript() ] );
475
476                 # Values that should not be forgotten
477                 $f .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
478                 foreach ( $this->opts->getUnconsumedValues() as $name => $value ) {
479                         $f .= Html::hidden( $name, $value );
480                 }
481
482                 $f .= Xml::fieldset( $this->msg( 'whatlinkshere' )->text() );
483
484                 # Target input (.mw-searchInput enables suggestions)
485                 $f .= Xml::inputLabel( $this->msg( 'whatlinkshere-page' )->text(), 'target',
486                         'mw-whatlinkshere-target', 40, $target, [ 'class' => 'mw-searchInput' ] );
487
488                 $f .= ' ';
489
490                 # Namespace selector
491                 $f .= Html::namespaceSelector(
492                         [
493                                 'selected' => $namespace,
494                                 'all' => '',
495                                 'label' => $this->msg( 'namespace' )->text()
496                         ], [
497                                 'name' => 'namespace',
498                                 'id' => 'namespace',
499                                 'class' => 'namespaceselector',
500                         ]
501                 );
502
503                 $f .= '&#160;' .
504                         Xml::checkLabel(
505                                 $this->msg( 'invert' )->text(),
506                                 'invert',
507                                 'nsinvert',
508                                 $nsinvert,
509                                 [ 'title' => $this->msg( 'tooltip-whatlinkshere-invert' )->text() ]
510                         );
511
512                 $f .= ' ';
513
514                 # Submit
515                 $f .= Xml::submitButton( $this->msg( 'whatlinkshere-submit' )->text() );
516
517                 # Close
518                 $f .= Xml::closeElement( 'fieldset' ) . Xml::closeElement( 'form' ) . "\n";
519
520                 return $f;
521         }
522
523         /**
524          * Create filter panel
525          *
526          * @return string HTML fieldset and filter panel with the show/hide links
527          */
528         function getFilterPanel() {
529                 $show = $this->msg( 'show' )->escaped();
530                 $hide = $this->msg( 'hide' )->escaped();
531
532                 $changed = $this->opts->getChangedValues();
533                 unset( $changed['target'] ); // Already in the request title
534
535                 $links = [];
536                 $types = [ 'hidetrans', 'hidelinks', 'hideredirs' ];
537                 if ( $this->target->getNamespace() == NS_FILE ) {
538                         $types[] = 'hideimages';
539                 }
540
541                 // Combined message keys: 'whatlinkshere-hideredirs', 'whatlinkshere-hidetrans',
542                 // 'whatlinkshere-hidelinks', 'whatlinkshere-hideimages'
543                 // To be sure they will be found by grep
544                 foreach ( $types as $type ) {
545                         $chosen = $this->opts->getValue( $type );
546                         $msg = $chosen ? $show : $hide;
547                         $overrides = [ $type => !$chosen ];
548                         $links[] = $this->msg( "whatlinkshere-{$type}" )->rawParams(
549                                 $this->makeSelfLink( $msg, array_merge( $changed, $overrides ) ) )->escaped();
550                 }
551
552                 return Xml::fieldset(
553                         $this->msg( 'whatlinkshere-filters' )->text(),
554                         $this->getLanguage()->pipeList( $links )
555                 );
556         }
557
558         /**
559          * Return an array of subpages beginning with $search that this special page will accept.
560          *
561          * @param string $search Prefix to search for
562          * @param int $limit Maximum number of results to return (usually 10)
563          * @param int $offset Number of results to skip (usually 0)
564          * @return string[] Matching subpages
565          */
566         public function prefixSearchSubpages( $search, $limit, $offset ) {
567                 return $this->prefixSearchString( $search, $limit, $offset );
568         }
569
570         protected function getGroupName() {
571                 return 'pagetools';
572         }
573 }