InfoAction.php 28 KB

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