InfoAction.php 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971
  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. use MediaWiki\MediaWikiServices;
  25. use MediaWiki\Revision\RevisionRecord;
  26. use Wikimedia\Rdbms\Database;
  27. /**
  28. * Displays information about a page.
  29. *
  30. * @ingroup Actions
  31. */
  32. class InfoAction extends FormlessAction {
  33. const VERSION = 1;
  34. /**
  35. * Returns the name of the action this object responds to.
  36. *
  37. * @return string Lowercase name
  38. */
  39. public function getName() {
  40. return 'info';
  41. }
  42. /**
  43. * Whether this action can still be executed by a blocked user.
  44. *
  45. * @return bool
  46. */
  47. public function requiresUnblock() {
  48. return false;
  49. }
  50. /**
  51. * Whether this action requires the wiki not to be locked.
  52. *
  53. * @return bool
  54. */
  55. public function requiresWrite() {
  56. return false;
  57. }
  58. /**
  59. * Clear the info cache for a given Title.
  60. *
  61. * @since 1.22
  62. * @param Title $title Title to clear cache for
  63. * @param int|null $revid Revision id to clear
  64. */
  65. public static function invalidateCache( Title $title, $revid = null ) {
  66. if ( !$revid ) {
  67. $revision = Revision::newFromTitle( $title, 0, Revision::READ_LATEST );
  68. $revid = $revision ? $revision->getId() : null;
  69. }
  70. if ( $revid !== null ) {
  71. $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
  72. $key = self::getCacheKey( $cache, $title, $revid );
  73. $cache->delete( $key );
  74. }
  75. }
  76. /**
  77. * Shows page information on GET request.
  78. *
  79. * @return string Page information that will be added to the output
  80. */
  81. public function onView() {
  82. $content = '';
  83. // Validate revision
  84. $oldid = $this->page->getOldID();
  85. if ( $oldid ) {
  86. $revision = $this->page->getRevisionFetched();
  87. // Revision is missing
  88. if ( $revision === null ) {
  89. return $this->msg( 'missing-revision', $oldid )->parse();
  90. }
  91. // Revision is not current
  92. if ( !$revision->isCurrent() ) {
  93. return $this->msg( 'pageinfo-not-current' )->plain();
  94. }
  95. }
  96. // "Help" button
  97. $this->addHelpLink( 'Page information' );
  98. // Page header
  99. if ( !$this->msg( 'pageinfo-header' )->isDisabled() ) {
  100. $content .= $this->msg( 'pageinfo-header' )->parse();
  101. }
  102. // Hide "This page is a member of # hidden categories" explanation
  103. $content .= Html::element( 'style', [],
  104. '.mw-hiddenCategoriesExplanation { display: none; }' ) . "\n";
  105. // Hide "Templates used on this page" explanation
  106. $content .= Html::element( 'style', [],
  107. '.mw-templatesUsedExplanation { display: none; }' ) . "\n";
  108. // Get page information
  109. $pageInfo = $this->pageInfo();
  110. // Allow extensions to add additional information
  111. Hooks::run( 'InfoAction', [ $this->getContext(), &$pageInfo ] );
  112. // Render page information
  113. foreach ( $pageInfo as $header => $infoTable ) {
  114. // Messages:
  115. // pageinfo-header-basic, pageinfo-header-edits, pageinfo-header-restrictions,
  116. // pageinfo-header-properties, pageinfo-category-info
  117. $content .= $this->makeHeader(
  118. $this->msg( "pageinfo-${header}" )->text(),
  119. "mw-pageinfo-${header}"
  120. ) . "\n";
  121. $table = "\n";
  122. $below = "";
  123. foreach ( $infoTable as $infoRow ) {
  124. if ( $infoRow[0] == "below" ) {
  125. $below = $infoRow[1] . "\n";
  126. continue;
  127. }
  128. $name = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->escaped() : $infoRow[0];
  129. $value = ( $infoRow[1] instanceof Message ) ? $infoRow[1]->escaped() : $infoRow[1];
  130. $id = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->getKey() : null;
  131. $table = $this->addRow( $table, $name, $value, $id ) . "\n";
  132. }
  133. $content = $this->addTable( $content, $table ) . "\n" . $below;
  134. }
  135. // Page footer
  136. if ( !$this->msg( 'pageinfo-footer' )->isDisabled() ) {
  137. $content .= $this->msg( 'pageinfo-footer' )->parse();
  138. }
  139. return $content;
  140. }
  141. /**
  142. * Creates a header that can be added to the output.
  143. *
  144. * @param string $header The header text.
  145. * @param string $canonicalId
  146. * @return string The HTML.
  147. */
  148. protected function makeHeader( $header, $canonicalId ) {
  149. $spanAttribs = [ 'class' => 'mw-headline', 'id' => Sanitizer::escapeIdForAttribute( $header ) ];
  150. $h2Attribs = [ 'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ];
  151. return Html::rawElement( 'h2', $h2Attribs, Html::element( 'span', $spanAttribs, $header ) );
  152. }
  153. /**
  154. * Adds a row to a table that will be added to the content.
  155. *
  156. * @param string $table The table that will be added to the content
  157. * @param string $name The name of the row
  158. * @param string $value The value of the row
  159. * @param string|null $id The ID to use for the 'tr' element
  160. * @return string The table with the row added
  161. */
  162. protected function addRow( $table, $name, $value, $id ) {
  163. return $table .
  164. Html::rawElement(
  165. 'tr',
  166. $id === null ? [] : [ 'id' => 'mw-' . $id ],
  167. Html::rawElement( 'td', [ 'style' => 'vertical-align: top;' ], $name ) .
  168. Html::rawElement( 'td', [], $value )
  169. );
  170. }
  171. /**
  172. * Adds a table to the content that will be added to the output.
  173. *
  174. * @param string $content The content that will be added to the output
  175. * @param string $table
  176. * @return string The content with the table added
  177. */
  178. protected function addTable( $content, $table ) {
  179. return $content . Html::rawElement( 'table', [ 'class' => 'wikitable mw-page-info' ],
  180. $table );
  181. }
  182. /**
  183. * Returns an array of info groups (will be rendered as tables), keyed by group ID.
  184. * Group IDs are arbitrary and used so that extensions may add additional information in
  185. * arbitrary positions (and as message keys for section headers for the tables, prefixed
  186. * with 'pageinfo-').
  187. * Each info group is a non-associative array of info items (rendered as table rows).
  188. * Each info item is an array with two elements: the first describes the type of
  189. * information, the second the value for the current page. Both can be strings (will be
  190. * interpreted as raw HTML) or messages (will be interpreted as plain text and escaped).
  191. *
  192. * @return array
  193. */
  194. protected function pageInfo() {
  195. $services = MediaWikiServices::getInstance();
  196. $user = $this->getUser();
  197. $lang = $this->getLanguage();
  198. $title = $this->getTitle();
  199. $id = $title->getArticleID();
  200. $config = $this->context->getConfig();
  201. $linkRenderer = $services->getLinkRenderer();
  202. $pageCounts = $this->pageCounts( $this->page );
  203. $props = PageProps::getInstance()->getAllProperties( $title );
  204. $pageProperties = $props[$id] ?? [];
  205. // Basic information
  206. $pageInfo = [];
  207. $pageInfo['header-basic'] = [];
  208. // Display title
  209. $displayTitle = $pageProperties['displaytitle'] ?? $title->getPrefixedText();
  210. $pageInfo['header-basic'][] = [
  211. $this->msg( 'pageinfo-display-title' ), $displayTitle
  212. ];
  213. // Is it a redirect? If so, where to?
  214. $redirectTarget = $this->page->getRedirectTarget();
  215. if ( $redirectTarget !== null ) {
  216. $pageInfo['header-basic'][] = [
  217. $this->msg( 'pageinfo-redirectsto' ),
  218. $linkRenderer->makeLink( $redirectTarget ) .
  219. $this->msg( 'word-separator' )->escaped() .
  220. $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
  221. $redirectTarget,
  222. $this->msg( 'pageinfo-redirectsto-info' )->text(),
  223. [],
  224. [ 'action' => 'info' ]
  225. ) )->escaped()
  226. ];
  227. }
  228. // Default sort key
  229. $sortKey = $pageProperties['defaultsort'] ?? $title->getCategorySortkey();
  230. $sortKey = htmlspecialchars( $sortKey );
  231. $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-default-sort' ), $sortKey ];
  232. // Page length (in bytes)
  233. $pageInfo['header-basic'][] = [
  234. $this->msg( 'pageinfo-length' ), $lang->formatNum( $title->getLength() )
  235. ];
  236. // Page namespace
  237. $pageNamespace = $title->getNsText();
  238. if ( $pageNamespace ) {
  239. $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-namespace' ), $pageNamespace ];
  240. }
  241. // Page ID (number not localised, as it's a database ID)
  242. $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-article-id' ), $id ];
  243. // Language in which the page content is (supposed to be) written
  244. $pageLang = $title->getPageLanguage()->getCode();
  245. $pageLangHtml = $pageLang . ' - ' .
  246. Language::fetchLanguageName( $pageLang, $lang->getCode() );
  247. // Link to Special:PageLanguage with pre-filled page title if user has permissions
  248. $permissionManager = $services->getPermissionManager();
  249. if ( $config->get( 'PageLanguageUseDB' )
  250. && $permissionManager->userCan( 'pagelang', $user, $title )
  251. ) {
  252. $pageLangHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
  253. SpecialPage::getTitleValueFor( 'PageLanguage', $title->getPrefixedText() ),
  254. $this->msg( 'pageinfo-language-change' )->text()
  255. ) )->escaped();
  256. }
  257. $pageInfo['header-basic'][] = [
  258. $this->msg( 'pageinfo-language' )->escaped(),
  259. $pageLangHtml
  260. ];
  261. // Content model of the page
  262. $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->getContentModel() ) );
  263. // If the user can change it, add a link to Special:ChangeContentModel
  264. if ( $config->get( 'ContentHandlerUseDB' )
  265. && $permissionManager->userCan( 'editcontentmodel', $user, $title )
  266. ) {
  267. $modelHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
  268. SpecialPage::getTitleValueFor( 'ChangeContentModel', $title->getPrefixedText() ),
  269. $this->msg( 'pageinfo-content-model-change' )->text()
  270. ) )->escaped();
  271. }
  272. $pageInfo['header-basic'][] = [
  273. $this->msg( 'pageinfo-content-model' ),
  274. $modelHtml
  275. ];
  276. if ( $title->inNamespace( NS_USER ) ) {
  277. $pageUser = User::newFromName( $title->getRootText() );
  278. if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) {
  279. $pageInfo['header-basic'][] = [
  280. $this->msg( 'pageinfo-user-id' ),
  281. $pageUser->getId()
  282. ];
  283. }
  284. }
  285. // Search engine status
  286. $pOutput = new ParserOutput();
  287. if ( isset( $pageProperties['noindex'] ) ) {
  288. $pOutput->setIndexPolicy( 'noindex' );
  289. }
  290. if ( isset( $pageProperties['index'] ) ) {
  291. $pOutput->setIndexPolicy( 'index' );
  292. }
  293. // Use robot policy logic
  294. $policy = $this->page->getRobotPolicy( 'view', $pOutput );
  295. $pageInfo['header-basic'][] = [
  296. // Messages: pageinfo-robot-index, pageinfo-robot-noindex
  297. $this->msg( 'pageinfo-robot-policy' ),
  298. $this->msg( "pageinfo-robot-${policy['index']}" )
  299. ];
  300. $unwatchedPageThreshold = $config->get( 'UnwatchedPageThreshold' );
  301. if ( $permissionManager->userHasRight( $user, 'unwatchedpages' ) ||
  302. ( $unwatchedPageThreshold !== false &&
  303. $pageCounts['watchers'] >= $unwatchedPageThreshold )
  304. ) {
  305. // Number of page watchers
  306. $pageInfo['header-basic'][] = [
  307. $this->msg( 'pageinfo-watchers' ),
  308. $lang->formatNum( $pageCounts['watchers'] )
  309. ];
  310. if (
  311. $config->get( 'ShowUpdatedMarker' ) &&
  312. isset( $pageCounts['visitingWatchers'] )
  313. ) {
  314. $minToDisclose = $config->get( 'UnwatchedPageSecret' );
  315. if ( $pageCounts['visitingWatchers'] > $minToDisclose ||
  316. $permissionManager->userHasRight( $user, 'unwatchedpages' ) ) {
  317. $pageInfo['header-basic'][] = [
  318. $this->msg( 'pageinfo-visiting-watchers' ),
  319. $lang->formatNum( $pageCounts['visitingWatchers'] )
  320. ];
  321. } else {
  322. $pageInfo['header-basic'][] = [
  323. $this->msg( 'pageinfo-visiting-watchers' ),
  324. $this->msg( 'pageinfo-few-visiting-watchers' )
  325. ];
  326. }
  327. }
  328. } elseif ( $unwatchedPageThreshold !== false ) {
  329. $pageInfo['header-basic'][] = [
  330. $this->msg( 'pageinfo-watchers' ),
  331. $this->msg( 'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
  332. ];
  333. }
  334. // Redirects to this page
  335. $whatLinksHere = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() );
  336. $pageInfo['header-basic'][] = [
  337. $linkRenderer->makeLink(
  338. $whatLinksHere,
  339. $this->msg( 'pageinfo-redirects-name' )->text(),
  340. [],
  341. [
  342. 'hidelinks' => 1,
  343. 'hidetrans' => 1,
  344. 'hideimages' => $title->getNamespace() == NS_FILE
  345. ]
  346. ),
  347. $this->msg( 'pageinfo-redirects-value' )
  348. ->numParams( count( $title->getRedirectsHere() ) )
  349. ];
  350. // Is it counted as a content page?
  351. if ( $this->page->isCountable() ) {
  352. $pageInfo['header-basic'][] = [
  353. $this->msg( 'pageinfo-contentpage' ),
  354. $this->msg( 'pageinfo-contentpage-yes' )
  355. ];
  356. }
  357. // Subpages of this page, if subpages are enabled for the current NS
  358. if ( $services->getNamespaceInfo()->hasSubpages( $title->getNamespace() ) ) {
  359. $prefixIndex = SpecialPage::getTitleFor(
  360. 'Prefixindex', $title->getPrefixedText() . '/' );
  361. $pageInfo['header-basic'][] = [
  362. $linkRenderer->makeLink(
  363. $prefixIndex,
  364. $this->msg( 'pageinfo-subpages-name' )->text()
  365. ),
  366. $this->msg( 'pageinfo-subpages-value' )
  367. ->numParams(
  368. $pageCounts['subpages']['total'],
  369. $pageCounts['subpages']['redirects'],
  370. $pageCounts['subpages']['nonredirects'] )
  371. ];
  372. }
  373. if ( $title->inNamespace( NS_CATEGORY ) ) {
  374. $category = Category::newFromTitle( $title );
  375. // $allCount is the total number of cat members,
  376. // not the count of how many members are normal pages.
  377. $allCount = (int)$category->getPageCount();
  378. $subcatCount = (int)$category->getSubcatCount();
  379. $fileCount = (int)$category->getFileCount();
  380. $pagesCount = $allCount - $subcatCount - $fileCount;
  381. $pageInfo['category-info'] = [
  382. [
  383. $this->msg( 'pageinfo-category-total' ),
  384. $lang->formatNum( $allCount )
  385. ],
  386. [
  387. $this->msg( 'pageinfo-category-pages' ),
  388. $lang->formatNum( $pagesCount )
  389. ],
  390. [
  391. $this->msg( 'pageinfo-category-subcats' ),
  392. $lang->formatNum( $subcatCount )
  393. ],
  394. [
  395. $this->msg( 'pageinfo-category-files' ),
  396. $lang->formatNum( $fileCount )
  397. ]
  398. ];
  399. }
  400. // Display image SHA-1 value
  401. if ( $title->inNamespace( NS_FILE ) ) {
  402. $fileObj = $services->getRepoGroup()->findFile( $title );
  403. if ( $fileObj !== false ) {
  404. // Convert the base-36 sha1 value obtained from database to base-16
  405. $output = Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 );
  406. $pageInfo['header-basic'][] = [
  407. $this->msg( 'pageinfo-file-hash' ),
  408. $output
  409. ];
  410. }
  411. }
  412. // Page protection
  413. $pageInfo['header-restrictions'] = [];
  414. // Is this page affected by the cascading protection of something which includes it?
  415. if ( $title->isCascadeProtected() ) {
  416. $cascadingFrom = '';
  417. $sources = $title->getCascadeProtectionSources()[0];
  418. foreach ( $sources as $sourceTitle ) {
  419. $cascadingFrom .= Html::rawElement(
  420. 'li', [], $linkRenderer->makeKnownLink( $sourceTitle ) );
  421. }
  422. $cascadingFrom = Html::rawElement( 'ul', [], $cascadingFrom );
  423. $pageInfo['header-restrictions'][] = [
  424. $this->msg( 'pageinfo-protect-cascading-from' ),
  425. $cascadingFrom
  426. ];
  427. }
  428. // Is out protection set to cascade to other pages?
  429. if ( $title->areRestrictionsCascading() ) {
  430. $pageInfo['header-restrictions'][] = [
  431. $this->msg( 'pageinfo-protect-cascading' ),
  432. $this->msg( 'pageinfo-protect-cascading-yes' )
  433. ];
  434. }
  435. // Page protection
  436. foreach ( $title->getRestrictionTypes() as $restrictionType ) {
  437. $protectionLevel = implode( ', ', $title->getRestrictions( $restrictionType ) );
  438. if ( $protectionLevel == '' ) {
  439. // Allow all users
  440. $message = $this->msg( 'protect-default' )->escaped();
  441. } else {
  442. // Administrators only
  443. // Messages: protect-level-autoconfirmed, protect-level-sysop
  444. $message = $this->msg( "protect-level-$protectionLevel" );
  445. if ( $message->isDisabled() ) {
  446. // Require "$1" permission
  447. $message = $this->msg( "protect-fallback", $protectionLevel )->parse();
  448. } else {
  449. $message = $message->escaped();
  450. }
  451. }
  452. $expiry = $title->getRestrictionExpiry( $restrictionType );
  453. $formattedexpiry = $this->msg( 'parentheses',
  454. $lang->formatExpiry( $expiry ) )->escaped();
  455. $message .= $this->msg( 'word-separator' )->escaped() . $formattedexpiry;
  456. // Messages: restriction-edit, restriction-move, restriction-create,
  457. // restriction-upload
  458. $pageInfo['header-restrictions'][] = [
  459. $this->msg( "restriction-$restrictionType" ), $message
  460. ];
  461. }
  462. $protectLog = SpecialPage::getTitleFor( 'Log' );
  463. $pageInfo['header-restrictions'][] = [
  464. 'below',
  465. $linkRenderer->makeKnownLink(
  466. $protectLog,
  467. $this->msg( 'pageinfo-view-protect-log' )->text(),
  468. [],
  469. [ 'type' => 'protect', 'page' => $title->getPrefixedText() ]
  470. ),
  471. ];
  472. if ( !$this->page->exists() ) {
  473. return $pageInfo;
  474. }
  475. // Edit history
  476. $pageInfo['header-edits'] = [];
  477. $firstRev = $this->page->getOldestRevision();
  478. $lastRev = $this->page->getRevision();
  479. $batch = new LinkBatch;
  480. if ( $firstRev ) {
  481. $firstRevUser = $firstRev->getUserText( RevisionRecord::FOR_THIS_USER );
  482. if ( $firstRevUser !== '' ) {
  483. $firstRevUserTitle = Title::makeTitle( NS_USER, $firstRevUser );
  484. $batch->addObj( $firstRevUserTitle );
  485. $batch->addObj( $firstRevUserTitle->getTalkPage() );
  486. }
  487. }
  488. if ( $lastRev ) {
  489. $lastRevUser = $lastRev->getUserText( RevisionRecord::FOR_THIS_USER );
  490. if ( $lastRevUser !== '' ) {
  491. $lastRevUserTitle = Title::makeTitle( NS_USER, $lastRevUser );
  492. $batch->addObj( $lastRevUserTitle );
  493. $batch->addObj( $lastRevUserTitle->getTalkPage() );
  494. }
  495. }
  496. $batch->execute();
  497. if ( $firstRev ) {
  498. // Page creator
  499. $pageInfo['header-edits'][] = [
  500. $this->msg( 'pageinfo-firstuser' ),
  501. Linker::revUserTools( $firstRev )
  502. ];
  503. // Date of page creation
  504. $pageInfo['header-edits'][] = [
  505. $this->msg( 'pageinfo-firsttime' ),
  506. $linkRenderer->makeKnownLink(
  507. $title,
  508. $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
  509. [],
  510. [ 'oldid' => $firstRev->getId() ]
  511. )
  512. ];
  513. }
  514. if ( $lastRev ) {
  515. // Latest editor
  516. $pageInfo['header-edits'][] = [
  517. $this->msg( 'pageinfo-lastuser' ),
  518. Linker::revUserTools( $lastRev )
  519. ];
  520. // Date of latest edit
  521. $pageInfo['header-edits'][] = [
  522. $this->msg( 'pageinfo-lasttime' ),
  523. $linkRenderer->makeKnownLink(
  524. $title,
  525. $lang->userTimeAndDate( $this->page->getTimestamp(), $user ),
  526. [],
  527. [ 'oldid' => $this->page->getLatest() ]
  528. )
  529. ];
  530. }
  531. // Total number of edits
  532. $pageInfo['header-edits'][] = [
  533. $this->msg( 'pageinfo-edits' ), $lang->formatNum( $pageCounts['edits'] )
  534. ];
  535. // Total number of distinct authors
  536. if ( $pageCounts['authors'] > 0 ) {
  537. $pageInfo['header-edits'][] = [
  538. $this->msg( 'pageinfo-authors' ), $lang->formatNum( $pageCounts['authors'] )
  539. ];
  540. }
  541. // Recent number of edits (within past 30 days)
  542. $pageInfo['header-edits'][] = [
  543. $this->msg( 'pageinfo-recent-edits',
  544. $lang->formatDuration( $config->get( 'RCMaxAge' ) ) ),
  545. $lang->formatNum( $pageCounts['recent_edits'] )
  546. ];
  547. // Recent number of distinct authors
  548. $pageInfo['header-edits'][] = [
  549. $this->msg( 'pageinfo-recent-authors' ),
  550. $lang->formatNum( $pageCounts['recent_authors'] )
  551. ];
  552. // Array of MagicWord objects
  553. $magicWords = $services->getMagicWordFactory()->getDoubleUnderscoreArray();
  554. // Array of magic word IDs
  555. $wordIDs = $magicWords->names;
  556. // Array of IDs => localized magic words
  557. $localizedWords = $services->getContentLanguage()->getMagicWords();
  558. $listItems = [];
  559. foreach ( $pageProperties as $property => $value ) {
  560. if ( in_array( $property, $wordIDs ) ) {
  561. $listItems[] = Html::element( 'li', [], $localizedWords[$property][1] );
  562. }
  563. }
  564. $localizedList = Html::rawElement( 'ul', [], implode( '', $listItems ) );
  565. $hiddenCategories = $this->page->getHiddenCategories();
  566. if (
  567. count( $listItems ) > 0 ||
  568. count( $hiddenCategories ) > 0 ||
  569. $pageCounts['transclusion']['from'] > 0 ||
  570. $pageCounts['transclusion']['to'] > 0
  571. ) {
  572. $options = [ 'LIMIT' => $config->get( 'PageInfoTransclusionLimit' ) ];
  573. $transcludedTemplates = $title->getTemplateLinksFrom( $options );
  574. if ( $config->get( 'MiserMode' ) ) {
  575. $transcludedTargets = [];
  576. } else {
  577. $transcludedTargets = $title->getTemplateLinksTo( $options );
  578. }
  579. // Page properties
  580. $pageInfo['header-properties'] = [];
  581. // Magic words
  582. if ( count( $listItems ) > 0 ) {
  583. $pageInfo['header-properties'][] = [
  584. $this->msg( 'pageinfo-magic-words' )->numParams( count( $listItems ) ),
  585. $localizedList
  586. ];
  587. }
  588. // Hidden categories
  589. if ( count( $hiddenCategories ) > 0 ) {
  590. $pageInfo['header-properties'][] = [
  591. $this->msg( 'pageinfo-hidden-categories' )
  592. ->numParams( count( $hiddenCategories ) ),
  593. Linker::formatHiddenCategories( $hiddenCategories )
  594. ];
  595. }
  596. // Transcluded templates
  597. if ( $pageCounts['transclusion']['from'] > 0 ) {
  598. if ( $pageCounts['transclusion']['from'] > count( $transcludedTemplates ) ) {
  599. $more = $this->msg( 'morenotlisted' )->escaped();
  600. } else {
  601. $more = null;
  602. }
  603. $templateListFormatter = new TemplatesOnThisPageFormatter(
  604. $this->getContext(),
  605. $linkRenderer
  606. );
  607. $pageInfo['header-properties'][] = [
  608. $this->msg( 'pageinfo-templates' )
  609. ->numParams( $pageCounts['transclusion']['from'] ),
  610. $templateListFormatter->format( $transcludedTemplates, false, $more )
  611. ];
  612. }
  613. if ( !$config->get( 'MiserMode' ) && $pageCounts['transclusion']['to'] > 0 ) {
  614. if ( $pageCounts['transclusion']['to'] > count( $transcludedTargets ) ) {
  615. $more = $linkRenderer->makeLink(
  616. $whatLinksHere,
  617. $this->msg( 'moredotdotdot' )->text(),
  618. [],
  619. [ 'hidelinks' => 1, 'hideredirs' => 1 ]
  620. );
  621. } else {
  622. $more = null;
  623. }
  624. $templateListFormatter = new TemplatesOnThisPageFormatter(
  625. $this->getContext(),
  626. $linkRenderer
  627. );
  628. $pageInfo['header-properties'][] = [
  629. $this->msg( 'pageinfo-transclusions' )
  630. ->numParams( $pageCounts['transclusion']['to'] ),
  631. $templateListFormatter->format( $transcludedTargets, false, $more )
  632. ];
  633. }
  634. }
  635. return $pageInfo;
  636. }
  637. /**
  638. * Returns page counts that would be too "expensive" to retrieve by normal means.
  639. *
  640. * @param WikiPage|Article|Page $page
  641. * @return array
  642. */
  643. protected function pageCounts( Page $page ) {
  644. $fname = __METHOD__;
  645. $config = $this->context->getConfig();
  646. $services = MediaWikiServices::getInstance();
  647. $cache = $services->getMainWANObjectCache();
  648. return $cache->getWithSetCallback(
  649. self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ),
  650. WANObjectCache::TTL_WEEK,
  651. function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname, $services ) {
  652. $title = $page->getTitle();
  653. $id = $title->getArticleID();
  654. $dbr = wfGetDB( DB_REPLICA );
  655. $dbrWatchlist = wfGetDB( DB_REPLICA, 'watchlist' );
  656. $setOpts += Database::getCacheSetOptions( $dbr, $dbrWatchlist );
  657. $tables = [ 'revision_actor_temp' ];
  658. $field = 'revactor_actor';
  659. $pageField = 'revactor_page';
  660. $tsField = 'revactor_timestamp';
  661. $joins = [];
  662. $watchedItemStore = $services->getWatchedItemStore();
  663. $result = [];
  664. $result['watchers'] = $watchedItemStore->countWatchers( $title );
  665. if ( $config->get( 'ShowUpdatedMarker' ) ) {
  666. $updated = wfTimestamp( TS_UNIX, $page->getTimestamp() );
  667. $result['visitingWatchers'] = $watchedItemStore->countVisitingWatchers(
  668. $title,
  669. $updated - $config->get( 'WatchersMaxAge' )
  670. );
  671. }
  672. // Total number of edits
  673. $edits = (int)$dbr->selectField(
  674. 'revision',
  675. 'COUNT(*)',
  676. [ 'rev_page' => $id ],
  677. $fname
  678. );
  679. $result['edits'] = $edits;
  680. // Total number of distinct authors
  681. if ( $config->get( 'MiserMode' ) ) {
  682. $result['authors'] = 0;
  683. } else {
  684. $result['authors'] = (int)$dbr->selectField(
  685. $tables,
  686. "COUNT(DISTINCT $field)",
  687. [ $pageField => $id ],
  688. $fname,
  689. [],
  690. $joins
  691. );
  692. }
  693. // "Recent" threshold defined by RCMaxAge setting
  694. $threshold = $dbr->timestamp( time() - $config->get( 'RCMaxAge' ) );
  695. // Recent number of edits
  696. $edits = (int)$dbr->selectField(
  697. 'revision',
  698. 'COUNT(rev_page)',
  699. [
  700. 'rev_page' => $id,
  701. "rev_timestamp >= " . $dbr->addQuotes( $threshold )
  702. ],
  703. $fname
  704. );
  705. $result['recent_edits'] = $edits;
  706. // Recent number of distinct authors
  707. $result['recent_authors'] = (int)$dbr->selectField(
  708. $tables,
  709. "COUNT(DISTINCT $field)",
  710. [
  711. $pageField => $id,
  712. "$tsField >= " . $dbr->addQuotes( $threshold )
  713. ],
  714. $fname,
  715. [],
  716. $joins
  717. );
  718. // Subpages (if enabled)
  719. if ( $services->getNamespaceInfo()->hasSubpages( $title->getNamespace() ) ) {
  720. $conds = [ 'page_namespace' => $title->getNamespace() ];
  721. $conds[] = 'page_title ' .
  722. $dbr->buildLike( $title->getDBkey() . '/', $dbr->anyString() );
  723. // Subpages of this page (redirects)
  724. $conds['page_is_redirect'] = 1;
  725. $result['subpages']['redirects'] = (int)$dbr->selectField(
  726. 'page',
  727. 'COUNT(page_id)',
  728. $conds,
  729. $fname
  730. );
  731. // Subpages of this page (non-redirects)
  732. $conds['page_is_redirect'] = 0;
  733. $result['subpages']['nonredirects'] = (int)$dbr->selectField(
  734. 'page',
  735. 'COUNT(page_id)',
  736. $conds,
  737. $fname
  738. );
  739. // Subpages of this page (total)
  740. $result['subpages']['total'] = $result['subpages']['redirects']
  741. + $result['subpages']['nonredirects'];
  742. }
  743. // Counts for the number of transclusion links (to/from)
  744. if ( $config->get( 'MiserMode' ) ) {
  745. $result['transclusion']['to'] = 0;
  746. } else {
  747. $result['transclusion']['to'] = (int)$dbr->selectField(
  748. 'templatelinks',
  749. 'COUNT(tl_from)',
  750. [
  751. 'tl_namespace' => $title->getNamespace(),
  752. 'tl_title' => $title->getDBkey()
  753. ],
  754. $fname
  755. );
  756. }
  757. $result['transclusion']['from'] = (int)$dbr->selectField(
  758. 'templatelinks',
  759. 'COUNT(*)',
  760. [ 'tl_from' => $title->getArticleID() ],
  761. $fname
  762. );
  763. return $result;
  764. }
  765. );
  766. }
  767. /**
  768. * Returns the name that goes in the "<h1>" page title.
  769. *
  770. * @return string
  771. */
  772. protected function getPageTitle() {
  773. return $this->msg( 'pageinfo-title', $this->getTitle()->getPrefixedText() )->text();
  774. }
  775. /**
  776. * Get a list of contributors of $article
  777. * @return string Html
  778. */
  779. protected function getContributors() {
  780. $contributors = $this->page->getContributors();
  781. $real_names = [];
  782. $user_names = [];
  783. $anon_ips = [];
  784. $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
  785. # Sift for real versus user names
  786. /** @var User $user */
  787. foreach ( $contributors as $user ) {
  788. $page = $user->isAnon()
  789. ? SpecialPage::getTitleFor( 'Contributions', $user->getName() )
  790. : $user->getUserPage();
  791. $hiddenPrefs = $this->context->getConfig()->get( 'HiddenPrefs' );
  792. if ( $user->getId() == 0 ) {
  793. $anon_ips[] = $linkRenderer->makeLink( $page, $user->getName() );
  794. } elseif ( !in_array( 'realname', $hiddenPrefs ) && $user->getRealName() ) {
  795. $real_names[] = $linkRenderer->makeLink( $page, $user->getRealName() );
  796. } else {
  797. $user_names[] = $linkRenderer->makeLink( $page, $user->getName() );
  798. }
  799. }
  800. $lang = $this->getLanguage();
  801. $real = $lang->listToText( $real_names );
  802. # "ThisSite user(s) A, B and C"
  803. if ( count( $user_names ) ) {
  804. $user = $this->msg( 'siteusers' )
  805. ->rawParams( $lang->listToText( $user_names ) )
  806. ->params( count( $user_names ) )->escaped();
  807. } else {
  808. $user = false;
  809. }
  810. if ( count( $anon_ips ) ) {
  811. $anon = $this->msg( 'anonusers' )
  812. ->rawParams( $lang->listToText( $anon_ips ) )
  813. ->params( count( $anon_ips ) )->escaped();
  814. } else {
  815. $anon = false;
  816. }
  817. # This is the big list, all mooshed together. We sift for blank strings
  818. $fulllist = [];
  819. foreach ( [ $real, $user, $anon ] as $s ) {
  820. if ( $s !== '' ) {
  821. array_push( $fulllist, $s );
  822. }
  823. }
  824. $count = count( $fulllist );
  825. # "Based on work by ..."
  826. return $count
  827. ? $this->msg( 'othercontribs' )->rawParams(
  828. $lang->listToText( $fulllist ) )->params( $count )->escaped()
  829. : '';
  830. }
  831. /**
  832. * Returns the description that goes below the "<h1>" tag.
  833. *
  834. * @return string
  835. */
  836. protected function getDescription() {
  837. return '';
  838. }
  839. /**
  840. * @param WANObjectCache $cache
  841. * @param Title $title
  842. * @param int $revId
  843. * @return string
  844. */
  845. protected static function getCacheKey( WANObjectCache $cache, Title $title, $revId ) {
  846. return $cache->makeKey( 'infoaction', md5( $title->getPrefixedText() ), $revId, self::VERSION );
  847. }
  848. }