1<?php 2/** 3 * Displays information about a page. 4 * 5 * Copyright © 2011 Alexandre Emsenhuber 6 * 7 * This program is free software; you can redistribute it and/or modify 8 * it under the terms of the GNU General Public License as published by 9 * the Free Software Foundation; either version 2 of the License, or 10 * (at your option) any later version. 11 * 12 * This program is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU General Public License for more details. 16 * 17 * You should have received a copy of the GNU General Public License 18 * along with this program; if not, write to the Free Software 19 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 20 * 21 * @file 22 * @ingroup Actions 23 */ 24 25use MediaWiki\Cache\LinkBatchFactory; 26use MediaWiki\HookContainer\HookContainer; 27use MediaWiki\HookContainer\HookRunner; 28use MediaWiki\Languages\LanguageNameUtils; 29use MediaWiki\Linker\LinkRenderer; 30use MediaWiki\MediaWikiServices; 31use MediaWiki\Page\PageIdentity; 32use MediaWiki\Revision\RevisionLookup; 33use MediaWiki\Revision\RevisionRecord; 34use Wikimedia\Rdbms\Database; 35use Wikimedia\Rdbms\ILoadBalancer; 36 37/** 38 * Displays information about a page. 39 * 40 * @ingroup Actions 41 */ 42class InfoAction extends FormlessAction { 43 private const VERSION = 1; 44 45 /** @var Language */ 46 private $contentLanguage; 47 48 /** @var HookRunner */ 49 private $hookRunner; 50 51 /** @var LanguageNameUtils */ 52 private $languageNameUtils; 53 54 /** @var LinkBatchFactory */ 55 private $linkBatchFactory; 56 57 /** @var LinkRenderer */ 58 private $linkRenderer; 59 60 /** @var ILoadBalancer */ 61 private $loadBalancer; 62 63 /** @var MagicWordFactory */ 64 private $magicWordFactory; 65 66 /** @var NamespaceInfo */ 67 private $namespaceInfo; 68 69 /** @var PageProps */ 70 private $pageProps; 71 72 /** @var RepoGroup */ 73 private $repoGroup; 74 75 /** @var RevisionLookup */ 76 private $revisionLookup; 77 78 /** @var WANObjectCache */ 79 private $wanObjectCache; 80 81 /** @var WatchedItemStoreInterface */ 82 private $watchedItemStore; 83 84 /** 85 * @param Page $page 86 * @param IContextSource $context 87 * @param Language $contentLanguage 88 * @param HookContainer $hookContainer 89 * @param LanguageNameUtils $languageNameUtils 90 * @param LinkBatchFactory $linkBatchFactory 91 * @param LinkRenderer $linkRenderer 92 * @param ILoadBalancer $loadBalancer 93 * @param MagicWordFactory $magicWordFactory 94 * @param NamespaceInfo $namespaceInfo 95 * @param PageProps $pageProps 96 * @param RepoGroup $repoGroup 97 * @param RevisionLookup $revisionLookup 98 * @param WANObjectCache $wanObjectCache 99 * @param WatchedItemStoreInterface $watchedItemStore 100 */ 101 public function __construct( 102 Page $page, 103 IContextSource $context, 104 Language $contentLanguage, 105 HookContainer $hookContainer, 106 LanguageNameUtils $languageNameUtils, 107 LinkBatchFactory $linkBatchFactory, 108 LinkRenderer $linkRenderer, 109 ILoadBalancer $loadBalancer, 110 MagicWordFactory $magicWordFactory, 111 NamespaceInfo $namespaceInfo, 112 PageProps $pageProps, 113 RepoGroup $repoGroup, 114 RevisionLookup $revisionLookup, 115 WANObjectCache $wanObjectCache, 116 WatchedItemStoreInterface $watchedItemStore 117 ) { 118 parent::__construct( $page, $context ); 119 $this->contentLanguage = $contentLanguage; 120 $this->hookRunner = new HookRunner( $hookContainer ); 121 $this->languageNameUtils = $languageNameUtils; 122 $this->linkBatchFactory = $linkBatchFactory; 123 $this->linkRenderer = $linkRenderer; 124 $this->loadBalancer = $loadBalancer; 125 $this->magicWordFactory = $magicWordFactory; 126 $this->namespaceInfo = $namespaceInfo; 127 $this->pageProps = $pageProps; 128 $this->repoGroup = $repoGroup; 129 $this->revisionLookup = $revisionLookup; 130 $this->wanObjectCache = $wanObjectCache; 131 $this->watchedItemStore = $watchedItemStore; 132 } 133 134 /** 135 * Returns the name of the action this object responds to. 136 * 137 * @return string Lowercase name 138 */ 139 public function getName() { 140 return 'info'; 141 } 142 143 /** 144 * Whether this action can still be executed by a blocked user. 145 * 146 * @return bool 147 */ 148 public function requiresUnblock() { 149 return false; 150 } 151 152 /** 153 * Whether this action requires the wiki not to be locked. 154 * 155 * @return bool 156 */ 157 public function requiresWrite() { 158 return false; 159 } 160 161 /** 162 * Clear the info cache for a given Title. 163 * 164 * @since 1.22 165 * @param PageIdentity $page Title to clear cache for 166 * @param int|null $revid Revision id to clear 167 */ 168 public static function invalidateCache( PageIdentity $page, $revid = null ) { 169 $services = MediaWikiServices::getInstance(); 170 if ( !$revid ) { 171 $revision = $services->getRevisionLookup() 172 ->getRevisionByTitle( $page, 0, IDBAccessObject::READ_LATEST ); 173 $revid = $revision ? $revision->getId() : null; 174 } 175 if ( $revid !== null ) { 176 $cache = $services->getMainWANObjectCache(); 177 $key = self::getCacheKey( $cache, $page, $revid ); 178 $cache->delete( $key ); 179 } 180 } 181 182 /** 183 * Shows page information on GET request. 184 * 185 * @return string Page information that will be added to the output 186 */ 187 public function onView() { 188 $this->getOutput()->addModuleStyles( 'mediawiki.interface.helpers.styles' ); 189 190 // "Help" button 191 $this->addHelpLink( 'Page information' ); 192 193 // Validate revision 194 $oldid = $this->getArticle()->getOldID(); 195 if ( $oldid ) { 196 $revRecord = $this->getArticle()->fetchRevisionRecord(); 197 198 // Revision is missing 199 if ( $revRecord === null ) { 200 return $this->msg( 'missing-revision', $oldid )->parse(); 201 } 202 203 // Revision is not current 204 if ( !$revRecord->isCurrent() ) { 205 return $this->msg( 'pageinfo-not-current' )->plain(); 206 } 207 } 208 209 $content = ''; 210 211 // Page header 212 if ( !$this->msg( 'pageinfo-header' )->isDisabled() ) { 213 $content .= $this->msg( 'pageinfo-header' )->parse(); 214 } 215 216 // TODO we shouldn't be adding styles manually like thes 217 // Hide "This page is a member of # hidden categories" explanation 218 $content .= Html::element( 219 'style', 220 [], 221 '.mw-hiddenCategoriesExplanation { display: none; }' 222 ) . "\n"; 223 224 // Hide "Templates used on this page" explanation 225 $content .= Html::element( 226 'style', 227 [], 228 '.mw-templatesUsedExplanation { display: none; }' 229 ) . "\n"; 230 231 // Get page information 232 $pageInfo = $this->pageInfo(); 233 234 // Allow extensions to add additional information 235 $this->hookRunner->onInfoAction( $this->getContext(), $pageInfo ); 236 237 // Render page information 238 foreach ( $pageInfo as $header => $infoTable ) { 239 // Messages: 240 // pageinfo-header-basic, pageinfo-header-edits, pageinfo-header-restrictions, 241 // pageinfo-header-properties, pageinfo-category-info 242 $content .= $this->makeHeader( 243 $this->msg( "pageinfo-$header" )->text(), 244 "mw-pageinfo-$header" 245 ) . "\n"; 246 $table = "\n"; 247 $below = ""; 248 foreach ( $infoTable as $infoRow ) { 249 if ( $infoRow[0] == "below" ) { 250 $below = $infoRow[1] . "\n"; 251 continue; 252 } 253 $name = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->escaped() : $infoRow[0]; 254 $value = ( $infoRow[1] instanceof Message ) ? $infoRow[1]->escaped() : $infoRow[1]; 255 $id = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->getKey() : null; 256 $table = $this->addRow( $table, $name, $value, $id ) . "\n"; 257 } 258 if ( $table === "\n" ) { 259 // Don't add tables with no rows 260 $content .= "\n" . $below; 261 } else { 262 $content = $this->addTable( $content, $table ) . "\n" . $below; 263 } 264 } 265 266 // Page footer 267 if ( !$this->msg( 'pageinfo-footer' )->isDisabled() ) { 268 $content .= $this->msg( 'pageinfo-footer' )->parse(); 269 } 270 271 return $content; 272 } 273 274 /** 275 * Creates a header that can be added to the output. 276 * 277 * @param string $header The header text. 278 * @param string $canonicalId 279 * @return string The HTML. 280 */ 281 protected function makeHeader( $header, $canonicalId ) { 282 return Html::rawElement( 283 'h2', 284 [ 'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ], 285 Html::element( 286 'span', 287 [ 288 'class' => 'mw-headline', 289 'id' => Sanitizer::escapeIdForAttribute( $header ), 290 ], 291 $header 292 ) 293 ); 294 } 295 296 /** 297 * Adds a row to a table that will be added to the content. 298 * 299 * @param string $table The table that will be added to the content 300 * @param string $name The name of the row 301 * @param string $value The value of the row 302 * @param string|null $id The ID to use for the 'tr' element 303 * @return string The table with the row added 304 */ 305 protected function addRow( $table, $name, $value, $id ) { 306 return $table . 307 Html::rawElement( 308 'tr', 309 $id === null ? [] : [ 'id' => 'mw-' . $id ], 310 Html::rawElement( 'td', [ 'style' => 'vertical-align: top;' ], $name ) . 311 Html::rawElement( 'td', [], $value ) 312 ); 313 } 314 315 /** 316 * Adds a table to the content that will be added to the output. 317 * 318 * @param string $content The content that will be added to the output 319 * @param string $table 320 * @return string The content with the table added 321 */ 322 protected function addTable( $content, $table ) { 323 return $content . 324 Html::rawElement( 325 'table', 326 [ 'class' => 'wikitable mw-page-info' ], 327 $table 328 ); 329 } 330 331 /** 332 * Returns an array of info groups (will be rendered as tables), keyed by group ID. 333 * Group IDs are arbitrary and used so that extensions may add additional information in 334 * arbitrary positions (and as message keys for section headers for the tables, prefixed 335 * with 'pageinfo-'). 336 * Each info group is a non-associative array of info items (rendered as table rows). 337 * Each info item is an array with two elements: the first describes the type of 338 * information, the second the value for the current page. Both can be strings (will be 339 * interpreted as raw HTML) or messages (will be interpreted as plain text and escaped). 340 * 341 * @return array 342 */ 343 protected function pageInfo() { 344 $user = $this->getUser(); 345 $lang = $this->getLanguage(); 346 $title = $this->getTitle(); 347 $id = $title->getArticleID(); 348 $config = $this->context->getConfig(); 349 $linkRenderer = $this->linkRenderer; 350 351 $pageCounts = $this->pageCounts(); 352 353 $props = $this->pageProps->getAllProperties( $title ); 354 $pageProperties = $props[$id] ?? []; 355 356 // Basic information 357 $pageInfo = []; 358 $pageInfo['header-basic'] = []; 359 360 // Display title 361 $displayTitle = $pageProperties['displaytitle'] ?? $title->getPrefixedText(); 362 363 $pageInfo['header-basic'][] = [ 364 $this->msg( 'pageinfo-display-title' ), 365 $displayTitle 366 ]; 367 368 // Is it a redirect? If so, where to? 369 $redirectTarget = $this->getWikiPage()->getRedirectTarget(); 370 if ( $redirectTarget !== null ) { 371 $pageInfo['header-basic'][] = [ 372 $this->msg( 'pageinfo-redirectsto' ), 373 $linkRenderer->makeLink( $redirectTarget ) . 374 $this->msg( 'word-separator' )->escaped() . 375 $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink( 376 $redirectTarget, 377 $this->msg( 'pageinfo-redirectsto-info' )->text(), 378 [], 379 [ 'action' => 'info' ] 380 ) )->escaped() 381 ]; 382 } 383 384 // Default sort key 385 $sortKey = $pageProperties['defaultsort'] ?? $title->getCategorySortkey(); 386 387 $sortKey = htmlspecialchars( $sortKey ); 388 $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-default-sort' ), $sortKey ]; 389 390 // Page length (in bytes) 391 $pageInfo['header-basic'][] = [ 392 $this->msg( 'pageinfo-length' ), 393 $lang->formatNum( $title->getLength() ) 394 ]; 395 396 // Page namespace 397 $pageNamespace = $title->getNsText(); 398 if ( $pageNamespace ) { 399 $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-namespace' ), $pageNamespace ]; 400 } 401 402 // Page ID (number not localised, as it's a database ID) 403 $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-article-id' ), $id ]; 404 405 // Language in which the page content is (supposed to be) written 406 $pageLang = $title->getPageLanguage()->getCode(); 407 408 $pageLangHtml = $pageLang . ' - ' . 409 $this->languageNameUtils->getLanguageName( $pageLang, $lang->getCode() ); 410 // Link to Special:PageLanguage with pre-filled page title if user has permissions 411 if ( $config->get( 'PageLanguageUseDB' ) 412 && $this->getContext()->getAuthority()->probablyCan( 'pagelang', $title ) 413 ) { 414 $pageLangHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink( 415 SpecialPage::getTitleValueFor( 'PageLanguage', $title->getPrefixedText() ), 416 $this->msg( 'pageinfo-language-change' )->text() 417 ) )->escaped(); 418 } 419 420 $pageInfo['header-basic'][] = [ 421 $this->msg( 'pageinfo-language' )->escaped(), 422 $pageLangHtml 423 ]; 424 425 // Content model of the page 426 $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->getContentModel() ) ); 427 // If the user can change it, add a link to Special:ChangeContentModel 428 if ( $this->getContext()->getAuthority()->probablyCan( 'editcontentmodel', $title ) ) { 429 $modelHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink( 430 SpecialPage::getTitleValueFor( 'ChangeContentModel', $title->getPrefixedText() ), 431 $this->msg( 'pageinfo-content-model-change' )->text() 432 ) )->escaped(); 433 } 434 435 $pageInfo['header-basic'][] = [ 436 $this->msg( 'pageinfo-content-model' ), 437 $modelHtml 438 ]; 439 440 if ( $title->inNamespace( NS_USER ) ) { 441 $pageUser = User::newFromName( $title->getRootText() ); 442 if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) { 443 $pageInfo['header-basic'][] = [ 444 $this->msg( 'pageinfo-user-id' ), 445 $pageUser->getId() 446 ]; 447 } 448 } 449 450 // Search engine status 451 $pOutput = new ParserOutput(); 452 if ( isset( $pageProperties['noindex'] ) ) { 453 $pOutput->setIndexPolicy( 'noindex' ); 454 } 455 if ( isset( $pageProperties['index'] ) ) { 456 $pOutput->setIndexPolicy( 'index' ); 457 } 458 459 // Use robot policy logic 460 $policy = $this->getArticle()->getRobotPolicy( 'view', $pOutput ); 461 $pageInfo['header-basic'][] = [ 462 // Messages: pageinfo-robot-index, pageinfo-robot-noindex 463 $this->msg( 'pageinfo-robot-policy' ), 464 $this->msg( "pageinfo-robot-${policy['index']}" ) 465 ]; 466 467 $unwatchedPageThreshold = $config->get( 'UnwatchedPageThreshold' ); 468 if ( $this->getContext()->getAuthority()->isAllowed( 'unwatchedpages' ) || 469 ( $unwatchedPageThreshold !== false && 470 $pageCounts['watchers'] >= $unwatchedPageThreshold ) 471 ) { 472 // Number of page watchers 473 $pageInfo['header-basic'][] = [ 474 $this->msg( 'pageinfo-watchers' ), 475 $lang->formatNum( $pageCounts['watchers'] ) 476 ]; 477 if ( 478 $config->get( 'ShowUpdatedMarker' ) && 479 isset( $pageCounts['visitingWatchers'] ) 480 ) { 481 $minToDisclose = $config->get( 'UnwatchedPageSecret' ); 482 if ( $pageCounts['visitingWatchers'] > $minToDisclose || 483 $this->getContext()->getAuthority()->isAllowed( 'unwatchedpages' ) ) { 484 $pageInfo['header-basic'][] = [ 485 $this->msg( 'pageinfo-visiting-watchers' ), 486 $lang->formatNum( $pageCounts['visitingWatchers'] ) 487 ]; 488 } else { 489 $pageInfo['header-basic'][] = [ 490 $this->msg( 'pageinfo-visiting-watchers' ), 491 $this->msg( 'pageinfo-few-visiting-watchers' ) 492 ]; 493 } 494 } 495 } elseif ( $unwatchedPageThreshold !== false ) { 496 $pageInfo['header-basic'][] = [ 497 $this->msg( 'pageinfo-watchers' ), 498 $this->msg( 'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold ) 499 ]; 500 } 501 502 // Redirects to this page 503 $whatLinksHere = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() ); 504 $pageInfo['header-basic'][] = [ 505 $linkRenderer->makeLink( 506 $whatLinksHere, 507 $this->msg( 'pageinfo-redirects-name' )->text(), 508 [], 509 [ 510 'hidelinks' => 1, 511 'hidetrans' => 1, 512 'hideimages' => $title->getNamespace() === NS_FILE 513 ] 514 ), 515 $this->msg( 'pageinfo-redirects-value' ) 516 ->numParams( count( $title->getRedirectsHere() ) ) 517 ]; 518 519 // Is it counted as a content page? 520 if ( $this->getWikiPage()->isCountable() ) { 521 $pageInfo['header-basic'][] = [ 522 $this->msg( 'pageinfo-contentpage' ), 523 $this->msg( 'pageinfo-contentpage-yes' ) 524 ]; 525 } 526 527 // Subpages of this page, if subpages are enabled for the current NS 528 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) { 529 $prefixIndex = SpecialPage::getTitleFor( 530 'Prefixindex', 531 $title->getPrefixedText() . '/' 532 ); 533 $pageInfo['header-basic'][] = [ 534 $linkRenderer->makeLink( 535 $prefixIndex, 536 $this->msg( 'pageinfo-subpages-name' )->text() 537 ), 538 $this->msg( 'pageinfo-subpages-value' ) 539 ->numParams( 540 $pageCounts['subpages']['total'], 541 $pageCounts['subpages']['redirects'], 542 $pageCounts['subpages']['nonredirects'] 543 ) 544 ]; 545 } 546 547 if ( $title->inNamespace( NS_CATEGORY ) ) { 548 $category = Category::newFromTitle( $title ); 549 550 // $allCount is the total number of cat members, 551 // not the count of how many members are normal pages. 552 $allCount = (int)$category->getPageCount(); 553 $subcatCount = (int)$category->getSubcatCount(); 554 $fileCount = (int)$category->getFileCount(); 555 $pagesCount = $allCount - $subcatCount - $fileCount; 556 557 $pageInfo['category-info'] = [ 558 [ 559 $this->msg( 'pageinfo-category-total' ), 560 $lang->formatNum( $allCount ) 561 ], 562 [ 563 $this->msg( 'pageinfo-category-pages' ), 564 $lang->formatNum( $pagesCount ) 565 ], 566 [ 567 $this->msg( 'pageinfo-category-subcats' ), 568 $lang->formatNum( $subcatCount ) 569 ], 570 [ 571 $this->msg( 'pageinfo-category-files' ), 572 $lang->formatNum( $fileCount ) 573 ] 574 ]; 575 } 576 577 // Display image SHA-1 value 578 if ( $title->inNamespace( NS_FILE ) ) { 579 $fileObj = $this->repoGroup->findFile( $title ); 580 if ( $fileObj !== false ) { 581 // Convert the base-36 sha1 value obtained from database to base-16 582 $output = Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 ); 583 $pageInfo['header-basic'][] = [ 584 $this->msg( 'pageinfo-file-hash' ), 585 $output 586 ]; 587 } 588 } 589 590 // Page protection 591 $pageInfo['header-restrictions'] = []; 592 593 // Is this page affected by the cascading protection of something which includes it? 594 if ( $title->isCascadeProtected() ) { 595 $cascadingFrom = ''; 596 $sources = $title->getCascadeProtectionSources()[0]; 597 598 foreach ( $sources as $sourceTitle ) { 599 $cascadingFrom .= Html::rawElement( 600 'li', 601 [], 602 $linkRenderer->makeKnownLink( $sourceTitle ) 603 ); 604 } 605 606 $cascadingFrom = Html::rawElement( 'ul', [], $cascadingFrom ); 607 $pageInfo['header-restrictions'][] = [ 608 $this->msg( 'pageinfo-protect-cascading-from' ), 609 $cascadingFrom 610 ]; 611 } 612 613 // Is out protection set to cascade to other pages? 614 if ( $title->areRestrictionsCascading() ) { 615 $pageInfo['header-restrictions'][] = [ 616 $this->msg( 'pageinfo-protect-cascading' ), 617 $this->msg( 'pageinfo-protect-cascading-yes' ) 618 ]; 619 } 620 621 // Page protection 622 foreach ( $title->getRestrictionTypes() as $restrictionType ) { 623 $protections = $title->getRestrictions( $restrictionType ); 624 625 switch ( count( $protections ) ) { 626 case 0: 627 $message = $this->getNamespaceProtectionMessage( $title ); 628 if ( $message === null ) { 629 // Allow all users 630 $message = $this->msg( 'protect-default' )->escaped(); 631 } 632 break; 633 634 case 1: 635 // Messages: protect-level-autoconfirmed, protect-level-sysop 636 $message = $this->msg( 'protect-level-' . $protections[0] ); 637 if ( !$message->isDisabled() ) { 638 $message = $message->escaped(); 639 break; 640 } 641 // Intentional fall-through if message is disabled (or non-existent) 642 643 default: 644 // Require "$1" permission 645 $message = $this->msg( "protect-fallback", $lang->commaList( $protections ) )->parse(); 646 break; 647 } 648 $expiry = $title->getRestrictionExpiry( $restrictionType ); 649 $formattedexpiry = $this->msg( 650 'parentheses', 651 $lang->formatExpiry( $expiry, true, 'infinity', $user ) 652 )->escaped(); 653 $message .= $this->msg( 'word-separator' )->escaped() . $formattedexpiry; 654 655 // Messages: restriction-edit, restriction-move, restriction-create, 656 // restriction-upload 657 $pageInfo['header-restrictions'][] = [ 658 $this->msg( "restriction-$restrictionType" ), $message 659 ]; 660 } 661 $protectLog = SpecialPage::getTitleFor( 'Log' ); 662 $pageInfo['header-restrictions'][] = [ 663 'below', 664 $linkRenderer->makeKnownLink( 665 $protectLog, 666 $this->msg( 'pageinfo-view-protect-log' )->text(), 667 [], 668 [ 'type' => 'protect', 'page' => $title->getPrefixedText() ] 669 ), 670 ]; 671 672 if ( !$this->getWikiPage()->exists() ) { 673 return $pageInfo; 674 } 675 676 // Edit history 677 $pageInfo['header-edits'] = []; 678 679 $firstRev = $this->revisionLookup->getFirstRevision( $this->getTitle() ); 680 $lastRev = $this->getWikiPage()->getRevisionRecord(); 681 $batch = $this->linkBatchFactory->newLinkBatch(); 682 if ( $firstRev ) { 683 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user ); 684 if ( $firstRevUser ) { 685 $batch->add( NS_USER, $firstRevUser->getName() ); 686 $batch->add( NS_USER_TALK, $firstRevUser->getName() ); 687 } 688 } 689 690 if ( $lastRev ) { 691 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user ); 692 if ( $lastRevUser ) { 693 $batch->add( NS_USER, $lastRevUser->getName() ); 694 $batch->add( NS_USER_TALK, $lastRevUser->getName() ); 695 } 696 } 697 698 $batch->execute(); 699 700 if ( $firstRev ) { 701 // Page creator 702 $pageInfo['header-edits'][] = [ 703 $this->msg( 'pageinfo-firstuser' ), 704 Linker::revUserTools( $firstRev ) 705 ]; 706 707 // Date of page creation 708 $pageInfo['header-edits'][] = [ 709 $this->msg( 'pageinfo-firsttime' ), 710 $linkRenderer->makeKnownLink( 711 $title, 712 $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ), 713 [], 714 [ 'oldid' => $firstRev->getId() ] 715 ) 716 ]; 717 } 718 719 if ( $lastRev ) { 720 // Latest editor 721 $pageInfo['header-edits'][] = [ 722 $this->msg( 'pageinfo-lastuser' ), 723 Linker::revUserTools( $lastRev ) 724 ]; 725 726 // Date of latest edit 727 $pageInfo['header-edits'][] = [ 728 $this->msg( 'pageinfo-lasttime' ), 729 $linkRenderer->makeKnownLink( 730 $title, 731 $lang->userTimeAndDate( $this->getWikiPage()->getTimestamp(), $user ), 732 [], 733 [ 'oldid' => $this->getWikiPage()->getLatest() ] 734 ) 735 ]; 736 } 737 738 // Total number of edits 739 $pageInfo['header-edits'][] = [ 740 $this->msg( 'pageinfo-edits' ), 741 $lang->formatNum( $pageCounts['edits'] ) 742 ]; 743 744 // Total number of distinct authors 745 if ( $pageCounts['authors'] > 0 ) { 746 $pageInfo['header-edits'][] = [ 747 $this->msg( 'pageinfo-authors' ), 748 $lang->formatNum( $pageCounts['authors'] ) 749 ]; 750 } 751 752 // Recent number of edits (within past 30 days) 753 $pageInfo['header-edits'][] = [ 754 $this->msg( 755 'pageinfo-recent-edits', 756 $lang->formatDuration( $config->get( 'RCMaxAge' ) ) 757 ), 758 $lang->formatNum( $pageCounts['recent_edits'] ) 759 ]; 760 761 // Recent number of distinct authors 762 $pageInfo['header-edits'][] = [ 763 $this->msg( 'pageinfo-recent-authors' ), 764 $lang->formatNum( $pageCounts['recent_authors'] ) 765 ]; 766 767 // Array of MagicWord objects 768 $magicWords = $this->magicWordFactory->getDoubleUnderscoreArray(); 769 770 // Array of magic word IDs 771 $wordIDs = $magicWords->names; 772 773 // Array of IDs => localized magic words 774 $localizedWords = $this->contentLanguage->getMagicWords(); 775 776 $listItems = []; 777 foreach ( $pageProperties as $property => $value ) { 778 if ( in_array( $property, $wordIDs ) ) { 779 $listItems[] = Html::element( 'li', [], $localizedWords[$property][1] ); 780 } 781 } 782 783 $localizedList = Html::rawElement( 'ul', [], implode( '', $listItems ) ); 784 $hiddenCategories = $this->getWikiPage()->getHiddenCategories(); 785 786 if ( 787 count( $listItems ) > 0 || 788 count( $hiddenCategories ) > 0 || 789 $pageCounts['transclusion']['from'] > 0 || 790 $pageCounts['transclusion']['to'] > 0 791 ) { 792 $options = [ 'LIMIT' => $config->get( 'PageInfoTransclusionLimit' ) ]; 793 $transcludedTemplates = $title->getTemplateLinksFrom( $options ); 794 if ( $config->get( 'MiserMode' ) ) { 795 $transcludedTargets = []; 796 } else { 797 $transcludedTargets = $title->getTemplateLinksTo( $options ); 798 } 799 800 // Page properties 801 $pageInfo['header-properties'] = []; 802 803 // Magic words 804 if ( count( $listItems ) > 0 ) { 805 $pageInfo['header-properties'][] = [ 806 $this->msg( 'pageinfo-magic-words' )->numParams( count( $listItems ) ), 807 $localizedList 808 ]; 809 } 810 811 // Hidden categories 812 if ( count( $hiddenCategories ) > 0 ) { 813 $pageInfo['header-properties'][] = [ 814 $this->msg( 'pageinfo-hidden-categories' ) 815 ->numParams( count( $hiddenCategories ) ), 816 Linker::formatHiddenCategories( $hiddenCategories ) 817 ]; 818 } 819 820 // Transcluded templates 821 if ( $pageCounts['transclusion']['from'] > 0 ) { 822 if ( $pageCounts['transclusion']['from'] > count( $transcludedTemplates ) ) { 823 $more = $this->msg( 'morenotlisted' )->escaped(); 824 } else { 825 $more = null; 826 } 827 828 $templateListFormatter = new TemplatesOnThisPageFormatter( 829 $this->getContext(), 830 $linkRenderer 831 ); 832 833 $pageInfo['header-properties'][] = [ 834 $this->msg( 'pageinfo-templates' ) 835 ->numParams( $pageCounts['transclusion']['from'] ), 836 $templateListFormatter->format( $transcludedTemplates, false, $more ) 837 ]; 838 } 839 840 if ( !$config->get( 'MiserMode' ) && $pageCounts['transclusion']['to'] > 0 ) { 841 if ( $pageCounts['transclusion']['to'] > count( $transcludedTargets ) ) { 842 $more = $linkRenderer->makeLink( 843 $whatLinksHere, 844 $this->msg( 'moredotdotdot' )->text(), 845 [], 846 [ 'hidelinks' => 1, 'hideredirs' => 1 ] 847 ); 848 } else { 849 $more = null; 850 } 851 852 $templateListFormatter = new TemplatesOnThisPageFormatter( 853 $this->getContext(), 854 $linkRenderer 855 ); 856 857 $pageInfo['header-properties'][] = [ 858 $this->msg( 'pageinfo-transclusions' ) 859 ->numParams( $pageCounts['transclusion']['to'] ), 860 $templateListFormatter->format( $transcludedTargets, false, $more ) 861 ]; 862 } 863 } 864 865 return $pageInfo; 866 } 867 868 /** 869 * Get namespace protection message for title or null if no namespace protection 870 * has been applied 871 * 872 * @param Title $title 873 * @return ?string HTML 874 */ 875 protected function getNamespaceProtectionMessage( Title $title ): ?string { 876 $rights = []; 877 if ( $title->isRawHtmlMessage() ) { 878 $rights[] = 'editsitecss'; 879 $rights[] = 'editsitejs'; 880 } elseif ( $title->isSiteCssConfigPage() ) { 881 $rights[] = 'editsitecss'; 882 } elseif ( $title->isSiteJsConfigPage() ) { 883 $rights[] = 'editsitejs'; 884 } elseif ( $title->isSiteJsonConfigPage() ) { 885 $rights[] = 'editsitejson'; 886 } elseif ( $title->isUserCssConfigPage() ) { 887 $rights[] = 'editusercss'; 888 } elseif ( $title->isUserJsConfigPage() ) { 889 $rights[] = 'edituserjs'; 890 } elseif ( $title->isUserJsonConfigPage() ) { 891 $rights[] = 'edituserjson'; 892 } else { 893 $namespaceProtection = $this->context->getConfig()->get( 'NamespaceProtection' ); 894 $right = $namespaceProtection[$title->getNamespace()] ?? null; 895 if ( $right ) { 896 // a single string as the value is allowed as well as an array 897 $rights = (array)$right; 898 } 899 } 900 if ( $rights ) { 901 return $this->msg( 'protect-fallback', $this->getLanguage()->commaList( $rights ) )->parse(); 902 } else { 903 return null; 904 } 905 } 906 907 /** 908 * Returns page counts that would be too "expensive" to retrieve by normal means. 909 * 910 * @return array 911 */ 912 private function pageCounts() { 913 $page = $this->getWikiPage(); 914 $fname = __METHOD__; 915 $config = $this->context->getConfig(); 916 $cache = $this->wanObjectCache; 917 918 return $cache->getWithSetCallback( 919 self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ), 920 WANObjectCache::TTL_WEEK, 921 function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname ) { 922 global $wgActorTableSchemaMigrationStage; 923 924 $title = $page->getTitle(); 925 $id = $title->getArticleID(); 926 927 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); 928 $dbrWatchlist = $this->loadBalancer->getConnectionRef( 929 DB_REPLICA, 930 [ 'watchlist' ] 931 ); 932 $setOpts += Database::getCacheSetOptions( $dbr, $dbrWatchlist ); 933 934 if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) { 935 $tables = [ 'revision' ]; 936 $field = 'rev_actor'; 937 $pageField = 'rev_page'; 938 $tsField = 'rev_timestamp'; 939 } else /* SCHEMA_COMPAT_READ_TEMP */ { 940 $tables = [ 'revision_actor_temp' ]; 941 $field = 'revactor_actor'; 942 $pageField = 'revactor_page'; 943 $tsField = 'revactor_timestamp'; 944 } 945 $joins = []; 946 947 $watchedItemStore = $this->watchedItemStore; 948 949 $result = []; 950 $result['watchers'] = $watchedItemStore->countWatchers( $title ); 951 952 if ( $config->get( 'ShowUpdatedMarker' ) ) { 953 $updated = (int)wfTimestamp( TS_UNIX, $page->getTimestamp() ); 954 $result['visitingWatchers'] = $watchedItemStore->countVisitingWatchers( 955 $title, 956 $updated - $config->get( 'WatchersMaxAge' ) 957 ); 958 } 959 960 // Total number of edits 961 $edits = (int)$dbr->selectField( 962 'revision', 963 'COUNT(*)', 964 [ 'rev_page' => $id ], 965 $fname 966 ); 967 $result['edits'] = $edits; 968 969 // Total number of distinct authors 970 if ( $config->get( 'MiserMode' ) ) { 971 $result['authors'] = 0; 972 } else { 973 $result['authors'] = (int)$dbr->selectField( 974 $tables, 975 "COUNT(DISTINCT $field)", 976 [ $pageField => $id ], 977 $fname, 978 [], 979 $joins 980 ); 981 } 982 983 // "Recent" threshold defined by RCMaxAge setting 984 $threshold = $dbr->timestamp( time() - $config->get( 'RCMaxAge' ) ); 985 986 // Recent number of edits 987 $edits = (int)$dbr->selectField( 988 'revision', 989 'COUNT(rev_page)', 990 [ 991 'rev_page' => $id, 992 "rev_timestamp >= " . $dbr->addQuotes( $threshold ) 993 ], 994 $fname 995 ); 996 $result['recent_edits'] = $edits; 997 998 // Recent number of distinct authors 999 $result['recent_authors'] = (int)$dbr->selectField( 1000 $tables, 1001 "COUNT(DISTINCT $field)", 1002 [ 1003 $pageField => $id, 1004 "$tsField >= " . $dbr->addQuotes( $threshold ) 1005 ], 1006 $fname, 1007 [], 1008 $joins 1009 ); 1010 1011 // Subpages (if enabled) 1012 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) { 1013 $conds = [ 'page_namespace' => $title->getNamespace() ]; 1014 $conds[] = 'page_title ' . 1015 $dbr->buildLike( $title->getDBkey() . '/', $dbr->anyString() ); 1016 1017 // Subpages of this page (redirects) 1018 $conds['page_is_redirect'] = 1; 1019 $result['subpages']['redirects'] = (int)$dbr->selectField( 1020 'page', 1021 'COUNT(page_id)', 1022 $conds, 1023 $fname 1024 ); 1025 1026 // Subpages of this page (non-redirects) 1027 $conds['page_is_redirect'] = 0; 1028 $result['subpages']['nonredirects'] = (int)$dbr->selectField( 1029 'page', 1030 'COUNT(page_id)', 1031 $conds, 1032 $fname 1033 ); 1034 1035 // Subpages of this page (total) 1036 $result['subpages']['total'] = $result['subpages']['redirects'] 1037 + $result['subpages']['nonredirects']; 1038 } 1039 1040 // Counts for the number of transclusion links (to/from) 1041 if ( $config->get( 'MiserMode' ) ) { 1042 $result['transclusion']['to'] = 0; 1043 } else { 1044 $result['transclusion']['to'] = (int)$dbr->selectField( 1045 'templatelinks', 1046 'COUNT(tl_from)', 1047 [ 1048 'tl_namespace' => $title->getNamespace(), 1049 'tl_title' => $title->getDBkey() 1050 ], 1051 $fname 1052 ); 1053 } 1054 1055 $result['transclusion']['from'] = (int)$dbr->selectField( 1056 'templatelinks', 1057 'COUNT(*)', 1058 [ 'tl_from' => $title->getArticleID() ], 1059 $fname 1060 ); 1061 1062 return $result; 1063 } 1064 ); 1065 } 1066 1067 /** 1068 * Returns the name that goes in the "<h1>" page title. 1069 * 1070 * @return string 1071 */ 1072 protected function getPageTitle() { 1073 return $this->msg( 'pageinfo-title', $this->getTitle()->getPrefixedText() )->text(); 1074 } 1075 1076 /** 1077 * Returns the description that goes below the "<h1>" tag. 1078 * 1079 * @return string 1080 */ 1081 protected function getDescription() { 1082 return ''; 1083 } 1084 1085 /** 1086 * @param WANObjectCache $cache 1087 * @param PageIdentity $page 1088 * @param int $revId 1089 * @return string 1090 */ 1091 protected static function getCacheKey( WANObjectCache $cache, PageIdentity $page, $revId ) { 1092 return $cache->makeKey( 'infoaction', md5( (string)$page ), $revId, self::VERSION ); 1093 } 1094} 1095