MediaWiki  master
InfoAction.php
Go to the documentation of this file.
1 <?php
49 
55 class InfoAction extends FormlessAction {
56  private const VERSION = 1;
57 
58  private Language $contentLanguage;
59  private LanguageNameUtils $languageNameUtils;
60  private LinkBatchFactory $linkBatchFactory;
61  private LinkRenderer $linkRenderer;
62  private IConnectionProvider $dbProvider;
63  private MagicWordFactory $magicWordFactory;
64  private NamespaceInfo $namespaceInfo;
65  private PageProps $pageProps;
66  private RepoGroup $repoGroup;
67  private RevisionLookup $revisionLookup;
68  private WANObjectCache $wanObjectCache;
69  private WatchedItemStoreInterface $watchedItemStore;
70  private RedirectLookup $redirectLookup;
71  private RestrictionStore $restrictionStore;
72  private LinksMigration $linksMigration;
73 
93  public function __construct(
94  Article $article,
96  Language $contentLanguage,
97  LanguageNameUtils $languageNameUtils,
98  LinkBatchFactory $linkBatchFactory,
99  LinkRenderer $linkRenderer,
100  IConnectionProvider $dbProvider,
101  MagicWordFactory $magicWordFactory,
102  NamespaceInfo $namespaceInfo,
103  PageProps $pageProps,
104  RepoGroup $repoGroup,
105  RevisionLookup $revisionLookup,
106  WANObjectCache $wanObjectCache,
107  WatchedItemStoreInterface $watchedItemStore,
108  RedirectLookup $redirectLookup,
109  RestrictionStore $restrictionStore,
110  LinksMigration $linksMigration
111  ) {
112  parent::__construct( $article, $context );
113  $this->contentLanguage = $contentLanguage;
114  $this->languageNameUtils = $languageNameUtils;
115  $this->linkBatchFactory = $linkBatchFactory;
116  $this->linkRenderer = $linkRenderer;
117  $this->dbProvider = $dbProvider;
118  $this->magicWordFactory = $magicWordFactory;
119  $this->namespaceInfo = $namespaceInfo;
120  $this->pageProps = $pageProps;
121  $this->repoGroup = $repoGroup;
122  $this->revisionLookup = $revisionLookup;
123  $this->wanObjectCache = $wanObjectCache;
124  $this->watchedItemStore = $watchedItemStore;
125  $this->redirectLookup = $redirectLookup;
126  $this->restrictionStore = $restrictionStore;
127  $this->linksMigration = $linksMigration;
128  }
129 
135  public function getName() {
136  return 'info';
137  }
138 
144  public function requiresUnblock() {
145  return false;
146  }
147 
153  public function requiresWrite() {
154  return false;
155  }
156 
164  public static function invalidateCache( PageIdentity $page, $revid = null ) {
165  $services = MediaWikiServices::getInstance();
166  if ( $revid === null ) {
167  $revision = $services->getRevisionLookup()
168  ->getRevisionByTitle( $page, 0, IDBAccessObject::READ_LATEST );
169  $revid = $revision ? $revision->getId() : 0;
170  }
171  $cache = $services->getMainWANObjectCache();
172  $key = self::getCacheKey( $cache, $page, $revid ?? 0 );
173  $cache->delete( $key );
174  }
175 
181  public function onView() {
182  $this->getOutput()->addModuleStyles( [
183  'mediawiki.interface.helpers.styles',
184  'mediawiki.action.styles',
185  ] );
186 
187  // "Help" button
188  $this->addHelpLink( 'Page information' );
189 
190  // Validate revision
191  $oldid = $this->getArticle()->getOldID();
192  if ( $oldid ) {
193  $revRecord = $this->getArticle()->fetchRevisionRecord();
194 
195  // Revision is missing
196  if ( $revRecord === null ) {
197  return $this->msg( 'missing-revision', $oldid )->parse();
198  }
199 
200  // Revision is not current
201  if ( !$revRecord->isCurrent() ) {
202  return $this->msg( 'pageinfo-not-current' )->plain();
203  }
204  }
205 
206  $content = '';
207 
208  // Page header
209  if ( !$this->msg( 'pageinfo-header' )->isDisabled() ) {
210  $content .= $this->msg( 'pageinfo-header' )->parse();
211  }
212 
213  // Get page information
214  $pageInfo = $this->pageInfo();
215 
216  // Allow extensions to add additional information
217  $this->getHookRunner()->onInfoAction( $this->getContext(), $pageInfo );
218 
219  // Render page information
220  foreach ( $pageInfo as $header => $infoTable ) {
221  // Messages:
222  // pageinfo-header-basic, pageinfo-header-edits, pageinfo-header-restrictions,
223  // pageinfo-header-properties, pageinfo-category-info
224  $content .= $this->makeHeader(
225  $this->msg( "pageinfo-$header" )->text(),
226  "mw-pageinfo-$header"
227  ) . "\n";
228  $table = "\n";
229  $below = "";
230  foreach ( $infoTable as $infoRow ) {
231  if ( $infoRow[0] == "below" ) {
232  $below = $infoRow[1] . "\n";
233  continue;
234  }
235  $name = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->escaped() : $infoRow[0];
236  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
237  $value = ( $infoRow[1] instanceof Message ) ? $infoRow[1]->escaped() : $infoRow[1];
238  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
239  $id = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->getKey() : null;
240  $table = $this->addRow( $table, $name, $value, $id ) . "\n";
241  }
242  if ( $table === "\n" ) {
243  // Don't add tables with no rows
244  $content .= "\n" . $below;
245  } else {
246  $content = $this->addTable( $content, $table ) . "\n" . $below;
247  }
248  }
249 
250  // Page footer
251  if ( !$this->msg( 'pageinfo-footer' )->isDisabled() ) {
252  $content .= $this->msg( 'pageinfo-footer' )->parse();
253  }
254 
255  return $content;
256  }
257 
265  protected function makeHeader( $header, $canonicalId ) {
266  return Html::rawElement(
267  'h2',
268  [ 'id' => Sanitizer::escapeIdForAttribute( $header ) ],
269  Html::element(
270  'span',
271  [ 'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ],
272  ''
273  ) .
274  htmlspecialchars( $header )
275  );
276  }
277 
287  protected function addRow( $table, $name, $value, $id ) {
288  return $table .
289  Html::rawElement(
290  'tr',
291  $id === null ? [] : [ 'id' => 'mw-' . $id ],
292  Html::rawElement( 'td', [ 'style' => 'vertical-align: top;' ], $name ) .
293  Html::rawElement( 'td', [], $value )
294  );
295  }
296 
304  protected function addTable( $content, $table ) {
305  return $content .
306  Html::rawElement(
307  'table',
308  [ 'class' => 'wikitable mw-page-info' ],
309  $table
310  );
311  }
312 
325  protected function pageInfo() {
326  $user = $this->getUser();
327  $lang = $this->getLanguage();
328  $title = $this->getTitle();
329  $id = $title->getArticleID();
330  $config = $this->context->getConfig();
331  $linkRenderer = $this->linkRenderer;
332 
333  $pageCounts = $this->pageCounts();
334 
335  $props = $this->pageProps->getAllProperties( $title );
336  $pageProperties = $props[$id] ?? [];
337 
338  // Basic information
339  $pageInfo = [];
340  $pageInfo['header-basic'] = [];
341 
342  // Display title
343  $displayTitle = $pageProperties['displaytitle'] ??
344  htmlspecialchars( $title->getPrefixedText(), ENT_NOQUOTES );
345 
346  $pageInfo['header-basic'][] = [
347  $this->msg( 'pageinfo-display-title' ),
348  $displayTitle
349  ];
350 
351  // Is it a redirect? If so, where to?
352  $redirectTarget = $this->redirectLookup->getRedirectTarget( $this->getWikiPage() );
353  if ( $redirectTarget !== null ) {
354  $pageInfo['header-basic'][] = [
355  $this->msg( 'pageinfo-redirectsto' ),
356  $linkRenderer->makeLink( $redirectTarget ) .
357  $this->msg( 'word-separator' )->escaped() .
358  $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
359  $redirectTarget,
360  $this->msg( 'pageinfo-redirectsto-info' )->text(),
361  [],
362  [ 'action' => 'info' ]
363  ) )->escaped()
364  ];
365  }
366 
367  // Default sort key
368  $sortKey = $pageProperties['defaultsort'] ?? $title->getCategorySortkey();
369 
370  $sortKey = htmlspecialchars( $sortKey );
371  $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-default-sort' ), $sortKey ];
372 
373  // Page length (in bytes)
374  $pageInfo['header-basic'][] = [
375  $this->msg( 'pageinfo-length' ),
376  $lang->formatNum( $title->getLength() )
377  ];
378 
379  // Page namespace
380  $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-namespace-id' ), $title->getNamespace() ];
381  $pageNamespace = $title->getNsText();
382  if ( $pageNamespace ) {
383  $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-namespace' ), $pageNamespace ];
384  }
385 
386  // Page ID (number not localised, as it's a database ID)
387  $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-article-id' ), $id ];
388 
389  // Language in which the page content is (supposed to be) written
390  $pageLang = $title->getPageLanguage()->getCode();
391 
392  $pageLangHtml = $pageLang . ' - ' .
393  $this->languageNameUtils->getLanguageName( $pageLang, $lang->getCode() );
394  // Link to Special:PageLanguage with pre-filled page title if user has permissions
395  if ( $config->get( MainConfigNames::PageLanguageUseDB )
396  && $this->getAuthority()->probablyCan( 'pagelang', $title )
397  ) {
398  $pageLangHtml .= $this->msg( 'word-separator' )->escaped();
399  $pageLangHtml .= $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
400  SpecialPage::getTitleValueFor( 'PageLanguage', $title->getPrefixedText() ),
401  $this->msg( 'pageinfo-language-change' )->text()
402  ) )->escaped();
403  }
404 
405  $pageInfo['header-basic'][] = [
406  $this->msg( 'pageinfo-language' )->escaped(),
407  $pageLangHtml
408  ];
409 
410  // Content model of the page
411  $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->getContentModel() ) );
412  // If the user can change it, add a link to Special:ChangeContentModel
413  if ( $this->getAuthority()->probablyCan( 'editcontentmodel', $title ) ) {
414  $modelHtml .= $this->msg( 'word-separator' )->escaped();
415  $modelHtml .= $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
416  SpecialPage::getTitleValueFor( 'ChangeContentModel', $title->getPrefixedText() ),
417  $this->msg( 'pageinfo-content-model-change' )->text()
418  ) )->escaped();
419  }
420 
421  $pageInfo['header-basic'][] = [
422  $this->msg( 'pageinfo-content-model' ),
423  $modelHtml
424  ];
425 
426  if ( $title->inNamespace( NS_USER ) ) {
427  $pageUser = User::newFromName( $title->getRootText() );
428  if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) {
429  $pageInfo['header-basic'][] = [
430  $this->msg( 'pageinfo-user-id' ),
431  $pageUser->getId()
432  ];
433  }
434  }
435 
436  // Search engine status
437  $parserOutput = new ParserOutput();
438  if ( isset( $pageProperties['noindex'] ) ) {
439  $parserOutput->setIndexPolicy( 'noindex' );
440  }
441  if ( isset( $pageProperties['index'] ) ) {
442  $parserOutput->setIndexPolicy( 'index' );
443  }
444 
445  // Use robot policy logic
446  $policy = $this->getArticle()->getRobotPolicy( 'view', $parserOutput );
447  $pageInfo['header-basic'][] = [
448  // Messages: pageinfo-robot-index, pageinfo-robot-noindex
449  $this->msg( 'pageinfo-robot-policy' ),
450  $this->msg( "pageinfo-robot-{$policy['index']}" )
451  ];
452 
453  $unwatchedPageThreshold = $config->get( MainConfigNames::UnwatchedPageThreshold );
454  if ( $this->getAuthority()->isAllowed( 'unwatchedpages' ) ||
455  ( $unwatchedPageThreshold !== false &&
456  $pageCounts['watchers'] >= $unwatchedPageThreshold )
457  ) {
458  // Number of page watchers
459  $pageInfo['header-basic'][] = [
460  $this->msg( 'pageinfo-watchers' ),
461  $lang->formatNum( $pageCounts['watchers'] )
462  ];
463  if (
464  $config->get( MainConfigNames::ShowUpdatedMarker ) &&
465  isset( $pageCounts['visitingWatchers'] )
466  ) {
467  $minToDisclose = $config->get( MainConfigNames::UnwatchedPageSecret );
468  if ( $pageCounts['visitingWatchers'] > $minToDisclose ||
469  $this->getAuthority()->isAllowed( 'unwatchedpages' ) ) {
470  $pageInfo['header-basic'][] = [
471  $this->msg( 'pageinfo-visiting-watchers' ),
472  $lang->formatNum( $pageCounts['visitingWatchers'] )
473  ];
474  } else {
475  $pageInfo['header-basic'][] = [
476  $this->msg( 'pageinfo-visiting-watchers' ),
477  $this->msg( 'pageinfo-few-visiting-watchers' )
478  ];
479  }
480  }
481  } elseif ( $unwatchedPageThreshold !== false ) {
482  $pageInfo['header-basic'][] = [
483  $this->msg( 'pageinfo-watchers' ),
484  $this->msg( 'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
485  ];
486  }
487 
488  // Redirects to this page
489  $whatLinksHere = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() );
490  $pageInfo['header-basic'][] = [
491  $linkRenderer->makeLink(
492  $whatLinksHere,
493  $this->msg( 'pageinfo-redirects-name' )->text(),
494  [],
495  [
496  'hidelinks' => 1,
497  'hidetrans' => 1,
498  'hideimages' => $title->getNamespace() === NS_FILE
499  ]
500  ),
501  $this->msg( 'pageinfo-redirects-value' )
502  ->numParams( count( $title->getRedirectsHere() ) )
503  ];
504 
505  // Is it counted as a content page?
506  if ( $this->getWikiPage()->isCountable() ) {
507  $pageInfo['header-basic'][] = [
508  $this->msg( 'pageinfo-contentpage' ),
509  $this->msg( 'pageinfo-contentpage-yes' )
510  ];
511  }
512 
513  // Subpages of this page, if subpages are enabled for the current NS
514  if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
515  $prefixIndex = SpecialPage::getTitleFor(
516  'Prefixindex',
517  $title->getPrefixedText() . '/'
518  );
519  $pageInfo['header-basic'][] = [
520  $linkRenderer->makeLink(
521  $prefixIndex,
522  $this->msg( 'pageinfo-subpages-name' )->text()
523  ),
524  // $wgNamespacesWithSubpages can be changed and this can be unset (T340749)
525  isset( $pageCounts['subpages'] )
526  ? $this->msg( 'pageinfo-subpages-value' )->numParams(
527  $pageCounts['subpages']['total'],
528  $pageCounts['subpages']['redirects'],
529  $pageCounts['subpages']['nonredirects']
530  ) : $this->msg( 'pageinfo-subpages-value-unknown' )->rawParams(
531  $linkRenderer->makeKnownLink(
532  $title, $this->msg( 'purge' )->text(), [], [ 'action' => 'purge' ] )
533  )
534  ];
535  }
536 
537  if ( $title->inNamespace( NS_CATEGORY ) ) {
538  $category = Category::newFromTitle( $title );
539 
540  $allCount = $category->getMemberCount();
541  $subcatCount = $category->getSubcatCount();
542  $fileCount = $category->getFileCount();
543  $pageCount = $category->getPageCount( Category::COUNT_CONTENT_PAGES );
544 
545  $pageInfo['category-info'] = [
546  [
547  $this->msg( 'pageinfo-category-total' ),
548  $lang->formatNum( $allCount )
549  ],
550  [
551  $this->msg( 'pageinfo-category-pages' ),
552  $lang->formatNum( $pageCount )
553  ],
554  [
555  $this->msg( 'pageinfo-category-subcats' ),
556  $lang->formatNum( $subcatCount )
557  ],
558  [
559  $this->msg( 'pageinfo-category-files' ),
560  $lang->formatNum( $fileCount )
561  ]
562  ];
563  }
564 
565  // Display image SHA-1 value
566  if ( $title->inNamespace( NS_FILE ) ) {
567  $fileObj = $this->repoGroup->findFile( $title );
568  if ( $fileObj !== false ) {
569  // Convert the base-36 sha1 value obtained from database to base-16
570  $output = Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 );
571  $pageInfo['header-basic'][] = [
572  $this->msg( 'pageinfo-file-hash' ),
573  $output
574  ];
575  }
576  }
577 
578  // Page protection
579  $pageInfo['header-restrictions'] = [];
580 
581  // Is this page affected by the cascading protection of something which includes it?
582  if ( $this->restrictionStore->isCascadeProtected( $title ) ) {
583  $cascadingFrom = '';
584  $sources = $this->restrictionStore->getCascadeProtectionSources( $title )[0];
585 
586  foreach ( $sources as $sourcePageIdentity ) {
587  $cascadingFrom .= Html::rawElement(
588  'li',
589  [],
590  $linkRenderer->makeKnownLink( $sourcePageIdentity )
591  );
592  }
593 
594  $cascadingFrom = Html::rawElement( 'ul', [], $cascadingFrom );
595  $pageInfo['header-restrictions'][] = [
596  $this->msg( 'pageinfo-protect-cascading-from' ),
597  $cascadingFrom
598  ];
599  }
600 
601  // Is out protection set to cascade to other pages?
602  if ( $this->restrictionStore->areRestrictionsCascading( $title ) ) {
603  $pageInfo['header-restrictions'][] = [
604  $this->msg( 'pageinfo-protect-cascading' ),
605  $this->msg( 'pageinfo-protect-cascading-yes' )
606  ];
607  }
608 
609  // Page protection
610  foreach ( $this->restrictionStore->listApplicableRestrictionTypes( $title ) as $restrictionType ) {
611  $protections = $this->restrictionStore->getRestrictions( $title, $restrictionType );
612 
613  switch ( count( $protections ) ) {
614  case 0:
615  $message = $this->getNamespaceProtectionMessage( $title ) ??
616  // Allow all users by default
617  $this->msg( 'protect-default' )->escaped();
618  break;
619 
620  case 1:
621  // Messages: protect-level-autoconfirmed, protect-level-sysop
622  $message = $this->msg( 'protect-level-' . $protections[0] );
623  if ( !$message->isDisabled() ) {
624  $message = $message->escaped();
625  break;
626  }
627  // Intentional fall-through if message is disabled (or non-existent)
628 
629  default:
630  // Require "$1" permission
631  $message = $this->msg( "protect-fallback", $lang->commaList( $protections ) )->parse();
632  break;
633  }
634  $expiry = $this->restrictionStore->getRestrictionExpiry( $title, $restrictionType );
635  $formattedexpiry = $expiry === null ? '' : $this->msg(
636  'parentheses',
637  $lang->formatExpiry( $expiry, true, 'infinity', $user )
638  )->escaped();
639  $message .= $this->msg( 'word-separator' )->escaped() . $formattedexpiry;
640 
641  // Messages: restriction-edit, restriction-move, restriction-create,
642  // restriction-upload
643  $pageInfo['header-restrictions'][] = [
644  $this->msg( "restriction-$restrictionType" ), $message
645  ];
646  }
647  $protectLog = SpecialPage::getTitleFor( 'Log' );
648  $pageInfo['header-restrictions'][] = [
649  'below',
650  $linkRenderer->makeKnownLink(
651  $protectLog,
652  $this->msg( 'pageinfo-view-protect-log' )->text(),
653  [],
654  [ 'type' => 'protect', 'page' => $title->getPrefixedText() ]
655  ),
656  ];
657 
658  if ( !$this->getWikiPage()->exists() ) {
659  return $pageInfo;
660  }
661 
662  // Edit history
663  $pageInfo['header-edits'] = [];
664 
665  $firstRev = $this->revisionLookup->getFirstRevision( $this->getTitle() );
666  $lastRev = $this->getWikiPage()->getRevisionRecord();
667  $batch = $this->linkBatchFactory->newLinkBatch();
668  if ( $firstRev ) {
669  $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
670  if ( $firstRevUser ) {
671  $batch->add( NS_USER, $firstRevUser->getName() );
672  $batch->add( NS_USER_TALK, $firstRevUser->getName() );
673  }
674  }
675 
676  if ( $lastRev ) {
677  $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
678  if ( $lastRevUser ) {
679  $batch->add( NS_USER, $lastRevUser->getName() );
680  $batch->add( NS_USER_TALK, $lastRevUser->getName() );
681  }
682  }
683 
684  $batch->execute();
685 
686  if ( $firstRev ) {
687  // Page creator
688  $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
689  // Check if the username is available – it may have been suppressed, in
690  // which case use the invalid user name '[HIDDEN]' to get the wiki's
691  // default user gender.
692  $firstRevUserName = $firstRevUser ? $firstRevUser->getName() : '[HIDDEN]';
693  $pageInfo['header-edits'][] = [
694  $this->msg( 'pageinfo-firstuser', $firstRevUserName ),
695  Linker::revUserTools( $firstRev )
696  ];
697 
698  // Date of page creation
699  $pageInfo['header-edits'][] = [
700  $this->msg( 'pageinfo-firsttime' ),
701  $linkRenderer->makeKnownLink(
702  $title,
703  $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
704  [],
705  [ 'oldid' => $firstRev->getId() ]
706  )
707  ];
708  }
709 
710  if ( $lastRev ) {
711  // Latest editor
712  $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
713  // Check if the username is available – it may have been suppressed, in
714  // which case use the invalid user name '[HIDDEN]' to get the wiki's
715  // default user gender.
716  $lastRevUserName = $lastRevUser ? $lastRevUser->getName() : '[HIDDEN]';
717  $pageInfo['header-edits'][] = [
718  $this->msg( 'pageinfo-lastuser', $lastRevUserName ),
719  Linker::revUserTools( $lastRev )
720  ];
721 
722  // Date of latest edit
723  $pageInfo['header-edits'][] = [
724  $this->msg( 'pageinfo-lasttime' ),
725  $linkRenderer->makeKnownLink(
726  $title,
727  $lang->userTimeAndDate( $this->getWikiPage()->getTimestamp(), $user ),
728  [],
729  [ 'oldid' => $this->getWikiPage()->getLatest() ]
730  )
731  ];
732  }
733 
734  // Total number of edits
735  $pageInfo['header-edits'][] = [
736  $this->msg( 'pageinfo-edits' ),
737  $lang->formatNum( $pageCounts['edits'] )
738  ];
739 
740  // Total number of distinct authors
741  if ( $pageCounts['authors'] > 0 ) {
742  $pageInfo['header-edits'][] = [
743  $this->msg( 'pageinfo-authors' ),
744  $lang->formatNum( $pageCounts['authors'] )
745  ];
746  }
747 
748  // Recent number of edits (within past 30 days)
749  $pageInfo['header-edits'][] = [
750  $this->msg(
751  'pageinfo-recent-edits',
752  $lang->formatDuration( $config->get( MainConfigNames::RCMaxAge ) )
753  ),
754  $lang->formatNum( $pageCounts['recent_edits'] )
755  ];
756 
757  // Recent number of distinct authors
758  $pageInfo['header-edits'][] = [
759  $this->msg( 'pageinfo-recent-authors' ),
760  $lang->formatNum( $pageCounts['recent_authors'] )
761  ];
762 
763  // Array of MagicWord objects
764  $magicWords = $this->magicWordFactory->getDoubleUnderscoreArray();
765 
766  // Array of magic word IDs
767  $wordIDs = $magicWords->names;
768 
769  // Array of IDs => localized magic words
770  $localizedWords = $this->contentLanguage->getMagicWords();
771 
772  $listItems = [];
773  foreach ( $pageProperties as $property => $value ) {
774  if ( in_array( $property, $wordIDs ) ) {
775  $listItems[] = Html::element( 'li', [], $localizedWords[$property][1] );
776  }
777  }
778 
779  $localizedList = Html::rawElement( 'ul', [], implode( '', $listItems ) );
780  $hiddenCategories = $this->getWikiPage()->getHiddenCategories();
781 
782  if (
783  count( $listItems ) > 0 ||
784  count( $hiddenCategories ) > 0 ||
785  $pageCounts['transclusion']['from'] > 0 ||
786  $pageCounts['transclusion']['to'] > 0
787  ) {
788  $options = [ 'LIMIT' => $config->get( MainConfigNames::PageInfoTransclusionLimit ) ];
789  $transcludedTemplates = $title->getTemplateLinksFrom( $options );
790  if ( $config->get( MainConfigNames::MiserMode ) ) {
791  $transcludedTargets = [];
792  } else {
793  $transcludedTargets = $title->getTemplateLinksTo( $options );
794  }
795 
796  // Page properties
797  $pageInfo['header-properties'] = [];
798 
799  // Magic words
800  if ( count( $listItems ) > 0 ) {
801  $pageInfo['header-properties'][] = [
802  $this->msg( 'pageinfo-magic-words' )->numParams( count( $listItems ) ),
803  $localizedList
804  ];
805  }
806 
807  // Hidden categories
808  if ( count( $hiddenCategories ) > 0 ) {
809  $pageInfo['header-properties'][] = [
810  $this->msg( 'pageinfo-hidden-categories' )
811  ->numParams( count( $hiddenCategories ) ),
812  Linker::formatHiddenCategories( $hiddenCategories )
813  ];
814  }
815 
816  // Transcluded templates
817  if ( $pageCounts['transclusion']['from'] > 0 ) {
818  if ( $pageCounts['transclusion']['from'] > count( $transcludedTemplates ) ) {
819  $more = $this->msg( 'morenotlisted' )->escaped();
820  } else {
821  $more = null;
822  }
823 
824  $templateListFormatter = new TemplatesOnThisPageFormatter(
825  $this->getContext(),
826  $linkRenderer,
827  $this->linkBatchFactory,
828  $this->restrictionStore
829  );
830 
831  $pageInfo['header-properties'][] = [
832  $this->msg( 'pageinfo-templates' )
833  ->numParams( $pageCounts['transclusion']['from'] ),
834  $templateListFormatter->format( $transcludedTemplates, false, $more )
835  ];
836  }
837 
838  if ( !$config->get( MainConfigNames::MiserMode ) && $pageCounts['transclusion']['to'] > 0 ) {
839  if ( $pageCounts['transclusion']['to'] > count( $transcludedTargets ) ) {
840  $more = $linkRenderer->makeLink(
841  $whatLinksHere,
842  $this->msg( 'moredotdotdot' )->text(),
843  [],
844  [ 'hidelinks' => 1, 'hideredirs' => 1 ]
845  );
846  } else {
847  $more = null;
848  }
849 
850  $templateListFormatter = new TemplatesOnThisPageFormatter(
851  $this->getContext(),
852  $linkRenderer,
853  $this->linkBatchFactory,
854  $this->restrictionStore
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 
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( MainConfigNames::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 
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  $title = $page->getTitle();
923  $id = $title->getArticleID();
924 
925  $dbr = $this->dbProvider->getReplicaDatabase();
926  $setOpts += Database::getCacheSetOptions( $dbr );
927 
928  $field = 'rev_actor';
929  $pageField = 'rev_page';
930 
931  $watchedItemStore = $this->watchedItemStore;
932 
933  $result = [];
934  $result['watchers'] = $watchedItemStore->countWatchers( $title );
935 
936  if ( $config->get( MainConfigNames::ShowUpdatedMarker ) ) {
937  $updated = (int)wfTimestamp( TS_UNIX, $page->getTimestamp() );
938  $result['visitingWatchers'] = $watchedItemStore->countVisitingWatchers(
939  $title,
940  $updated - $config->get( MainConfigNames::WatchersMaxAge )
941  );
942  }
943 
944  // Total number of edits
945  $edits = (int)$dbr->newSelectQueryBuilder()
946  ->select( 'COUNT(*)' )
947  ->from( 'revision' )
948  ->where( [ 'rev_page' => $id ] )
949  ->caller( $fname )
950  ->fetchField();
951  $result['edits'] = $edits;
952 
953  // Total number of distinct authors
954  if ( $config->get( MainConfigNames::MiserMode ) ) {
955  $result['authors'] = 0;
956  } else {
957  $result['authors'] = (int)$dbr->newSelectQueryBuilder()
958  ->select( "COUNT(DISTINCT $field)" )
959  ->from( 'revision' )
960  ->where( [ $pageField => $id ] )
961  ->caller( $fname )
962  ->fetchField();
963  }
964 
965  // "Recent" threshold defined by RCMaxAge setting
966  $threshold = $dbr->timestamp( time() - $config->get( MainConfigNames::RCMaxAge ) );
967 
968  // Recent number of edits
969  $edits = (int)$dbr->newSelectQueryBuilder()
970  ->select( 'COUNT(rev_page)' )
971  ->from( 'revision' )
972  ->where( [ 'rev_page' => $id ] )
973  ->andWhere( [ "rev_timestamp >= " . $dbr->addQuotes( $threshold ) ] )
974  ->caller( $fname )
975  ->fetchField();
976  $result['recent_edits'] = $edits;
977 
978  // Recent number of distinct authors
979  $result['recent_authors'] = (int)$dbr->newSelectQueryBuilder()
980  ->select( "COUNT(DISTINCT $field)" )
981  ->from( 'revision' )
982  ->where( [ $pageField => $id ] )
983  ->andWhere( [ 'rev_timestamp >= ' . $dbr->addQuotes( $threshold ) ] )
984  ->caller( $fname )
985  ->fetchField();
986 
987  // Subpages (if enabled)
988  if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
989  $conds = [ 'page_namespace' => $title->getNamespace() ];
990  $conds[] = 'page_title ' .
991  $dbr->buildLike( $title->getDBkey() . '/', $dbr->anyString() );
992 
993  // Subpages of this page (redirects)
994  $conds['page_is_redirect'] = 1;
995  $result['subpages']['redirects'] = (int)$dbr->newSelectQueryBuilder()
996  ->select( 'COUNT(page_id)' )
997  ->from( 'page' )
998  ->where( $conds )
999  ->caller( $fname )
1000  ->fetchField();
1001  // Subpages of this page (non-redirects)
1002  $conds['page_is_redirect'] = 0;
1003  $result['subpages']['nonredirects'] = (int)$dbr->newSelectQueryBuilder()
1004  ->select( 'COUNT(page_id)' )
1005  ->from( 'page' )
1006  ->where( $conds )
1007  ->caller( $fname )
1008  ->fetchField();
1009 
1010  // Subpages of this page (total)
1011  $result['subpages']['total'] = $result['subpages']['redirects']
1012  + $result['subpages']['nonredirects'];
1013  }
1014 
1015  // Counts for the number of transclusion links (to/from)
1016  if ( $config->get( MainConfigNames::MiserMode ) ) {
1017  $result['transclusion']['to'] = 0;
1018  } else {
1019  $result['transclusion']['to'] = (int)$dbr->newSelectQueryBuilder()
1020  ->select( 'COUNT(tl_from)' )
1021  ->from( 'templatelinks' )
1022  ->where( $this->linksMigration->getLinksConditions( 'templatelinks', $title ) )
1023  ->caller( $fname )
1024  ->fetchField();
1025  }
1026 
1027  $result['transclusion']['from'] = (int)$dbr->newSelectQueryBuilder()
1028  ->select( 'COUNT(*)' )
1029  ->from( 'templatelinks' )
1030  ->where( [ 'tl_from' => $title->getArticleID() ] )
1031  ->caller( $fname )
1032  ->fetchField();
1033 
1034  return $result;
1035  }
1036  );
1037  }
1038 
1044  protected function getPageTitle() {
1045  return $this->msg( 'pageinfo-title' )->plaintextParams( $this->getTitle()->getPrefixedText() );
1046  }
1047 
1053  protected function getDescription() {
1054  return '';
1055  }
1056 
1063  protected static function getCacheKey( WANObjectCache $cache, PageIdentity $page, $revId ) {
1064  return $cache->makeKey( 'infoaction', md5( (string)$page ), $revId, self::VERSION );
1065  }
1066 }
const NS_USER
Definition: Defines.php:66
const NS_FILE
Definition: Defines.php:70
const NS_USER_TALK
Definition: Defines.php:67
const NS_CATEGORY
Definition: Defines.php:78
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
$magicWords
@phpcs-require-sorted-array
Definition: MessagesAb.php:70
getWikiPage()
Get a WikiPage object.
Definition: Action.php:188
IContextSource null $context
IContextSource if specified; otherwise we'll use the Context from the Page.
Definition: Action.php:62
getHookRunner()
Definition: Action.php:253
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
Definition: Action.php:440
getTitle()
Shortcut to get the Title object from the page.
Definition: Action.php:209
getContext()
Get the IContextSource in use here.
Definition: Action.php:115
getOutput()
Get the OutputPage being used for this instance.
Definition: Action.php:139
getUser()
Shortcut to get the User being used for this instance.
Definition: Action.php:149
getArticle()
Get a Article object.
Definition: Action.php:199
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
Definition: Action.php:221
getLanguage()
Shortcut to get the user Language being used for this instance.
Definition: Action.php:178
getAuthority()
Shortcut to get the Authority executing this instance.
Definition: Action.php:159
Legacy class representing an editable page and handling UI for some page actions.
Definition: Article.php:61
static getLocalizedName( $name, Language $lang=null)
Returns the localized name for a given content model.
An action which just does something, without showing a form first.
Displays information about a page.
Definition: InfoAction.php:55
requiresWrite()
Whether this action requires the wiki not to be locked.
Definition: InfoAction.php:153
makeHeader( $header, $canonicalId)
Creates a header that can be added to the output.
Definition: InfoAction.php:265
getPageTitle()
Returns the name that goes in the "<h1>" page title.
__construct(Article $article, IContextSource $context, Language $contentLanguage, LanguageNameUtils $languageNameUtils, LinkBatchFactory $linkBatchFactory, LinkRenderer $linkRenderer, IConnectionProvider $dbProvider, MagicWordFactory $magicWordFactory, NamespaceInfo $namespaceInfo, PageProps $pageProps, RepoGroup $repoGroup, RevisionLookup $revisionLookup, WANObjectCache $wanObjectCache, WatchedItemStoreInterface $watchedItemStore, RedirectLookup $redirectLookup, RestrictionStore $restrictionStore, LinksMigration $linksMigration)
Definition: InfoAction.php:93
pageInfo()
Returns an array of info groups (will be rendered as tables), keyed by group ID.
Definition: InfoAction.php:325
static getCacheKey(WANObjectCache $cache, PageIdentity $page, $revId)
onView()
Shows page information on GET request.
Definition: InfoAction.php:181
getNamespaceProtectionMessage(Title $title)
Get namespace protection message for title or null if no namespace protection has been applied.
Definition: InfoAction.php:875
getDescription()
Returns the description that goes below the "<h1>" tag.
requiresUnblock()
Whether this action can still be executed by a blocked user.
Definition: InfoAction.php:144
getName()
Returns the name of the action this object responds to.
Definition: InfoAction.php:135
addRow( $table, $name, $value, $id)
Adds a row to a table that will be added to the content.
Definition: InfoAction.php:287
addTable( $content, $table)
Adds a table to the content that will be added to the output.
Definition: InfoAction.php:304
static invalidateCache(PageIdentity $page, $revid=null)
Clear the info cache for a given Title.
Definition: InfoAction.php:164
Base class for language-specific code.
Definition: Language.php:61
Category objects are immutable, strictly speaking.
Definition: Category.php:41
Handles formatting for the "templates used on this page" lists.
This class is a collection of static functions that serve two purposes:
Definition: Html.php:57
A service that provides utilities to do with language names and codes.
Class that generates HTML for internal links.
makeKnownLink( $target, $text=null, array $extraAttribs=[], array $query=[])
Make a link that's styled as if the target page exists (usually a "blue link", although the styling m...
makeLink( $target, $text=null, array $extraAttribs=[], array $query=[])
Render a wikilink.
Some internal bits split of from Skin.php.
Definition: Linker.php:65
Service for compat reading of links tables.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Gives access to properties of a page.
Definition: PageProps.php:35
A factory that stores information about MagicWords, and creates them on demand with caching.
HTML sanitizer for MediaWiki.
Definition: Sanitizer.php:46
Page revision base class.
Parent class for all special pages.
Definition: SpecialPage.php:65
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Represents a title within MediaWiki.
Definition: Title.php:76
getPageLanguage()
Get the language in which the content of this page is written in wikitext.
Definition: Title.php:3665
isUserJsConfigPage()
Is this a JS "config" sub-page of a user page?
Definition: Title.php:1534
isRawHtmlMessage()
Is this a message which can contain raw HTML?
Definition: Title.php:1602
getNsText()
Get the namespace text.
Definition: Title.php:1154
isSiteJsonConfigPage()
Is this a sitewide JSON "config" page?
Definition: Title.php:1566
isSiteJsConfigPage()
Is this a sitewide JS "config" page?
Definition: Title.php:1584
inNamespace(int $ns)
Returns true if the title is inside the specified namespace.
Definition: Title.php:1316
isUserCssConfigPage()
Is this a CSS "config" sub-page of a user page?
Definition: Title.php:1506
getArticleID( $flags=0)
Get the article ID for this Title from the link cache, adding it if necessary.
Definition: Title.php:2595
getNamespace()
Get the namespace index, i.e.
Definition: Title.php:1058
getTemplateLinksTo( $options=[])
Get an array of Title objects using this Title as a template Also stores the IDs in the link cache.
Definition: Title.php:2839
getLength( $flags=0)
What is the length of this page? Uses link cache, adding it if necessary.
Definition: Title.php:2638
getDBkey()
Get the main part with underscores.
Definition: Title.php:1049
getContentModel( $flags=0)
Get the page's content model id, see the CONTENT_MODEL_XXX constants.
Definition: Title.php:1080
getRootText()
Get the root page name text without a namespace, i.e.
Definition: Title.php:1969
getTemplateLinksFrom( $options=[])
Get an array of Title objects used on this Title as a template Also stores the IDs in the link cache.
Definition: Title.php:2921
getRedirectsHere( $ns=null)
Get all extant redirects to this Title.
Definition: Title.php:3518
getPrefixedText()
Get the prefixed title with spaces.
Definition: Title.php:1885
getCategorySortkey( $prefix='')
Returns the raw sort key to be used for categories, with the specified prefix.
Definition: Title.php:3597
isSiteCssConfigPage()
Is this a sitewide CSS "config" page?
Definition: Title.php:1548
isUserJsonConfigPage()
Is this a JSON "config" sub-page of a user page?
Definition: Title.php:1520
internal since 1.36
Definition: User.php:98
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition: Message.php:144
Prioritized list of file repositories.
Definition: RepoGroup.php:30
Multi-datacenter aware caching interface.
makeKey( $keygroup,... $components)
Interface for objects which can provide a MediaWiki context on request.
Interface for objects (potentially) representing an editable wiki page.
Service for resolving a wiki page redirect.
Service for looking up page revisions.
countVisitingWatchers( $target, $threshold)
Number of page watchers who also visited a "recent" edit.
Provide primary and replica IDatabase connections.
$content
Definition: router.php:76
$header