66 private const VERSION = 1;
105 parent::__construct( $article, $context );
106 $this->contentLanguage = $contentLanguage;
107 $this->languageNameUtils = $languageNameUtils;
108 $this->linkBatchFactory = $linkBatchFactory;
109 $this->linkRenderer = $linkRenderer;
110 $this->dbProvider = $dbProvider;
111 $this->magicWordFactory = $magicWordFactory;
112 $this->namespaceInfo = $namespaceInfo;
113 $this->pageProps = $pageProps;
114 $this->repoGroup = $repoGroup;
115 $this->revisionLookup = $revisionLookup;
116 $this->wanObjectCache = $wanObjectCache;
117 $this->watchedItemStore = $watchedItemStore;
118 $this->redirectLookup = $redirectLookup;
119 $this->restrictionStore = $restrictionStore;
120 $this->linksMigration = $linksMigration;
121 $this->userFactory = $userFactory;
147 $services = MediaWikiServices::getInstance();
148 if ( $revid ===
null ) {
149 $revision = $services->getRevisionLookup()
150 ->getRevisionByTitle( $page, 0, IDBAccessObject::READ_LATEST );
151 $revid = $revision ? $revision->getId() : 0;
153 $cache = $services->getMainWANObjectCache();
155 $cache->delete( $key );
165 'mediawiki.interface.helpers.styles',
166 'mediawiki.action.styles',
175 $revRecord = $this->
getArticle()->fetchRevisionRecord();
178 return $this->
msg(
'missing-revision', $oldid )->parse();
179 } elseif ( !$revRecord->isCurrent() ) {
180 return $this->
msg(
'pageinfo-not-current' )->plain();
185 $msg = $this->
msg(
'pageinfo-header' );
186 $content = $msg->isDisabled() ?
'' : $msg->parse();
189 $pageInfo = $this->pageInfo();
195 foreach ( $pageInfo as
$header => $infoTable ) {
199 $content .= $this->makeHeader(
200 $this->
msg(
"pageinfo-$header" )->text(),
201 "mw-pageinfo-$header"
205 foreach ( $infoTable as $infoRow ) {
206 if ( $infoRow[0] ==
"below" ) {
207 $below = $infoRow[1] .
"\n";
210 $name = ( $infoRow[0] instanceof
Message ) ? $infoRow[0]->escaped() : $infoRow[0];
211 $value = ( $infoRow[1] instanceof
Message ) ? $infoRow[1]->escaped() : $infoRow[1];
212 $id = ( $infoRow[0] instanceof
Message ) ? $infoRow[0]->getKey() :
null;
213 $rows .= $this->getRow( $name, $value, $id ) .
"\n";
215 if ( $rows !==
'' ) {
216 $content .= Html::rawElement(
'table', [
'class' =>
'wikitable mw-page-info' ],
219 $content .=
"\n" . $below;
223 if ( !$this->
msg(
'pageinfo-footer' )->isDisabled() ) {
224 $content .= $this->
msg(
'pageinfo-footer' )->parse();
237 private function makeHeader(
$header, $canonicalId ) {
238 return Html::rawElement(
240 [
'id' => Sanitizer::escapeIdForAttribute(
$header ) ],
243 [
'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ],
257 private function getRow( $name, $value, $id ) {
258 return Html::rawElement(
261 'id' => $id ===
null ?
null :
'mw-' . $id,
262 'style' =>
'vertical-align: top;',
264 Html::rawElement(
'td', [], $name ) .
265 Html::rawElement(
'td', [], $value )
282 private function pageInfo() {
287 $config = $this->context->getConfig();
288 $linkRenderer = $this->linkRenderer;
290 $pageCounts = $this->pageCounts();
292 $pageProperties = $this->pageProps->getAllProperties( $title )[$id] ?? [];
296 $pageInfo[
'header-basic'] = [];
299 $displayTitle = $pageProperties[
'displaytitle'] ??
302 $pageInfo[
'header-basic'][] = [
303 $this->
msg(
'pageinfo-display-title' ),
308 $redirectTarget = $this->redirectLookup->getRedirectTarget( $this->
getWikiPage() );
309 if ( $redirectTarget !==
null ) {
310 $pageInfo[
'header-basic'][] = [
311 $this->
msg(
'pageinfo-redirectsto' ),
312 $linkRenderer->
makeLink( $redirectTarget ) .
313 $this->
msg(
'word-separator' )->escaped() .
314 $this->
msg(
'parentheses' )->rawParams( $linkRenderer->
makeLink(
316 $this->msg(
'pageinfo-redirectsto-info' )->text(),
318 [
'action' =>
'info' ]
325 $pageInfo[
'header-basic'][] = [
326 $this->
msg(
'pageinfo-default-sort' ),
327 htmlspecialchars( $sortKey )
331 $pageInfo[
'header-basic'][] = [
332 $this->
msg(
'pageinfo-length' ),
337 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-namespace-id' ), $title->
getNamespace() ];
339 if ( $pageNamespace ) {
340 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-namespace' ), $pageNamespace ];
344 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-article-id' ), $id ];
349 $pageLangHtml = $pageLang .
' - ' .
350 $this->languageNameUtils->getLanguageName( $pageLang, $lang->getCode() );
352 if ( $config->get( MainConfigNames::PageLanguageUseDB )
353 && $this->getAuthority()->probablyCan(
'pagelang', $title )
355 $pageLangHtml .= $this->
msg(
'word-separator' )->escaped();
356 $pageLangHtml .= $this->
msg(
'parentheses' )->rawParams( $linkRenderer->
makeLink(
357 SpecialPage::getTitleValueFor(
'PageLanguage', $title->
getPrefixedText() ),
358 $this->msg(
'pageinfo-language-change' )->text()
362 $pageInfo[
'header-basic'][] = [
363 $this->
msg(
'pageinfo-language' )->escaped(),
368 $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->
getContentModel() ) );
370 if ( $this->
getAuthority()->probablyCan(
'editcontentmodel', $title ) ) {
371 $modelHtml .= $this->
msg(
'word-separator' )->escaped();
372 $modelHtml .= $this->
msg(
'parentheses' )->rawParams( $linkRenderer->
makeLink(
373 SpecialPage::getTitleValueFor(
'ChangeContentModel', $title->
getPrefixedText() ),
374 $this->msg(
'pageinfo-content-model-change' )->text()
378 $pageInfo[
'header-basic'][] = [
379 $this->
msg(
'pageinfo-content-model' ),
384 $pageUser = $this->userFactory->newFromName( $title->
getRootText() );
385 if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) {
386 $pageInfo[
'header-basic'][] = [
387 $this->
msg(
'pageinfo-user-id' ),
395 if ( isset( $pageProperties[
'noindex'] ) ) {
396 $parserOutput->setIndexPolicy(
'noindex' );
398 if ( isset( $pageProperties[
'index'] ) ) {
399 $parserOutput->setIndexPolicy(
'index' );
403 $policy = $this->
getArticle()->getRobotPolicy(
'view', $parserOutput );
404 $pageInfo[
'header-basic'][] = [
406 $this->
msg(
'pageinfo-robot-policy' ),
407 $this->
msg(
"pageinfo-robot-{$policy['index']}" )
410 $unwatchedPageThreshold = $config->get( MainConfigNames::UnwatchedPageThreshold );
411 if ( $this->
getAuthority()->isAllowed(
'unwatchedpages' ) ||
412 ( $unwatchedPageThreshold !==
false &&
413 $pageCounts[
'watchers'] >= $unwatchedPageThreshold )
416 $pageInfo[
'header-basic'][] = [
417 $this->
msg(
'pageinfo-watchers' ),
418 $lang->formatNum( $pageCounts[
'watchers'] )
421 $visiting = $pageCounts[
'visitingWatchers'] ??
null;
422 if ( $visiting !==
null && $config->get( MainConfigNames::ShowUpdatedMarker ) ) {
423 if ( $visiting > $config->get( MainConfigNames::UnwatchedPageSecret ) ||
424 $this->getAuthority()->isAllowed(
'unwatchedpages' )
426 $value = $lang->formatNum( $visiting );
428 $value = $this->
msg(
'pageinfo-few-visiting-watchers' );
430 $pageInfo[
'header-basic'][] = [
431 $this->
msg(
'pageinfo-visiting-watchers' )
432 ->numParams( ceil( $config->get( MainConfigNames::WatchersMaxAge ) / 86400 ) ),
436 } elseif ( $unwatchedPageThreshold !==
false ) {
437 $pageInfo[
'header-basic'][] = [
438 $this->
msg(
'pageinfo-watchers' ),
439 $this->
msg(
'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
444 $whatLinksHere = SpecialPage::getTitleFor(
'Whatlinkshere', $title->
getPrefixedText() );
445 $pageInfo[
'header-basic'][] = [
448 $this->
msg(
'pageinfo-redirects-name' )->text(),
456 $this->
msg(
'pageinfo-redirects-value' )
462 $pageInfo[
'header-basic'][] = [
463 $this->
msg(
'pageinfo-contentpage' ),
464 $this->
msg(
'pageinfo-contentpage-yes' )
469 if ( $this->namespaceInfo->hasSubpages( $title->
getNamespace() ) ) {
470 $prefixIndex = SpecialPage::getTitleFor(
474 $pageInfo[
'header-basic'][] = [
477 $this->
msg(
'pageinfo-subpages-name' )->text()
480 isset( $pageCounts[
'subpages'] )
481 ? $this->
msg(
'pageinfo-subpages-value' )->numParams(
482 $pageCounts[
'subpages'][
'total'],
483 $pageCounts[
'subpages'][
'redirects'],
484 $pageCounts[
'subpages'][
'nonredirects']
485 ) : $this->
msg(
'pageinfo-subpages-value-unknown' )->rawParams(
487 $title, $this->msg(
'purge' )->text(), [], [
'action' =>
'purge' ] )
493 $category = Category::newFromTitle( $title );
495 $allCount = $category->getMemberCount();
496 $subcatCount = $category->getSubcatCount();
497 $fileCount = $category->getFileCount();
498 $pageCount = $category->getPageCount( Category::COUNT_CONTENT_PAGES );
500 $pageInfo[
'category-info'] = [
502 $this->
msg(
'pageinfo-category-total' ),
503 $lang->formatNum( $allCount )
506 $this->
msg(
'pageinfo-category-pages' ),
507 $lang->formatNum( $pageCount )
510 $this->
msg(
'pageinfo-category-subcats' ),
511 $lang->formatNum( $subcatCount )
514 $this->
msg(
'pageinfo-category-files' ),
515 $lang->formatNum( $fileCount )
522 $fileObj = $this->repoGroup->findFile( $title );
523 if ( $fileObj !==
false ) {
525 $output = Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 );
526 $pageInfo[
'header-basic'][] = [
527 $this->
msg(
'pageinfo-file-hash' ),
534 $pageInfo[
'header-restrictions'] = [];
537 if ( $this->restrictionStore->isCascadeProtected( $title ) ) {
539 $sources = $this->restrictionStore->getCascadeProtectionSources( $title )[0];
541 foreach ( $sources as $sourcePageIdentity ) {
542 $cascadingFrom .= Html::rawElement(
549 $cascadingFrom = Html::rawElement(
'ul', [], $cascadingFrom );
550 $pageInfo[
'header-restrictions'][] = [
551 $this->
msg(
'pageinfo-protect-cascading-from' ),
557 if ( $this->restrictionStore->areRestrictionsCascading( $title ) ) {
558 $pageInfo[
'header-restrictions'][] = [
559 $this->
msg(
'pageinfo-protect-cascading' ),
560 $this->
msg(
'pageinfo-protect-cascading-yes' )
565 foreach ( $this->restrictionStore->listApplicableRestrictionTypes( $title ) as $restrictionType ) {
566 $protections = $this->restrictionStore->getRestrictions( $title, $restrictionType );
568 switch ( count( $protections ) ) {
570 $message = $this->getNamespaceProtectionMessage( $title ) ??
572 $this->
msg(
'protect-default' )->escaped();
577 $message = $this->
msg(
'protect-level-' . $protections[0] );
578 if ( !$message->isDisabled() ) {
579 $message = $message->escaped();
586 $message = $this->
msg(
"protect-fallback", $lang->commaList( $protections ) )->parse();
589 $expiry = $this->restrictionStore->getRestrictionExpiry( $title, $restrictionType );
590 $formattedexpiry = $expiry ===
null ?
'' : $this->
msg(
592 $lang->formatExpiry( $expiry,
true,
'infinity', $user )
594 $message .= $this->
msg(
'word-separator' )->escaped() . $formattedexpiry;
598 $pageInfo[
'header-restrictions'][] = [
599 $this->
msg(
"restriction-$restrictionType" ), $message
602 $protectLog = SpecialPage::getTitleFor(
'Log' );
603 $pageInfo[
'header-restrictions'][] = [
607 $this->
msg(
'pageinfo-view-protect-log' )->text(),
618 $pageInfo[
'header-edits'] = [];
620 $firstRev = $this->revisionLookup->getFirstRevision( $this->
getTitle() );
621 $lastRev = $this->
getWikiPage()->getRevisionRecord();
622 $batch = $this->linkBatchFactory->newLinkBatch();
624 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
625 if ( $firstRevUser ) {
626 $batch->add(
NS_USER, $firstRevUser->getName() );
632 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
633 if ( $lastRevUser ) {
634 $batch->add(
NS_USER, $lastRevUser->getName() );
643 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
647 $firstRevUserName = $firstRevUser ? $firstRevUser->getName() :
'[HIDDEN]';
648 $pageInfo[
'header-edits'][] = [
649 $this->
msg(
'pageinfo-firstuser', $firstRevUserName ),
650 Linker::revUserTools( $firstRev )
654 $pageInfo[
'header-edits'][] = [
655 $this->
msg(
'pageinfo-firsttime' ),
658 $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
660 [
'oldid' => $firstRev->getId() ]
667 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
671 $lastRevUserName = $lastRevUser ? $lastRevUser->getName() :
'[HIDDEN]';
672 $pageInfo[
'header-edits'][] = [
673 $this->
msg(
'pageinfo-lastuser', $lastRevUserName ),
674 Linker::revUserTools( $lastRev )
678 $pageInfo[
'header-edits'][] = [
679 $this->
msg(
'pageinfo-lasttime' ),
682 $lang->userTimeAndDate( $this->getWikiPage()->getTimestamp(), $user ),
684 [
'oldid' => $this->getWikiPage()->getLatest() ]
690 $pageInfo[
'header-edits'][] = [
691 $this->
msg(
'pageinfo-edits' ),
692 $lang->formatNum( $pageCounts[
'edits'] )
696 if ( $pageCounts[
'authors'] > 0 ) {
697 $pageInfo[
'header-edits'][] = [
698 $this->
msg(
'pageinfo-authors' ),
699 $lang->formatNum( $pageCounts[
'authors'] )
704 $pageInfo[
'header-edits'][] = [
706 'pageinfo-recent-edits',
707 $lang->formatDuration( $config->get( MainConfigNames::RCMaxAge ) )
709 $lang->formatNum( $pageCounts[
'recent_edits'] )
713 $pageInfo[
'header-edits'][] = [
714 $this->
msg(
'pageinfo-recent-authors' ),
715 $lang->formatNum( $pageCounts[
'recent_authors'] )
719 $wordIDs = $this->magicWordFactory->getDoubleUnderscoreArray()->getNames();
722 $localizedWords = $this->contentLanguage->getMagicWords();
725 foreach ( $pageProperties as $property => $value ) {
726 if ( in_array( $property, $wordIDs ) ) {
727 $listItems[] = Html::element(
'li', [], $localizedWords[$property][1] );
731 $localizedList = Html::rawElement(
'ul', [], implode(
'', $listItems ) );
732 $hiddenCategories = $this->
getWikiPage()->getHiddenCategories();
735 count( $listItems ) > 0 ||
736 count( $hiddenCategories ) > 0 ||
737 $pageCounts[
'transclusion'][
'from'] > 0 ||
738 $pageCounts[
'transclusion'][
'to'] > 0
740 $options = [
'LIMIT' => $config->get( MainConfigNames::PageInfoTransclusionLimit ) ];
742 if ( $config->get( MainConfigNames::MiserMode ) ) {
743 $transcludedTargets = [];
749 $pageInfo[
'header-properties'] = [];
752 if ( count( $listItems ) > 0 ) {
753 $pageInfo[
'header-properties'][] = [
754 $this->
msg(
'pageinfo-magic-words' )->numParams( count( $listItems ) ),
760 if ( count( $hiddenCategories ) > 0 ) {
761 $pageInfo[
'header-properties'][] = [
762 $this->
msg(
'pageinfo-hidden-categories' )
763 ->numParams( count( $hiddenCategories ) ),
764 Linker::formatHiddenCategories( $hiddenCategories )
769 if ( $pageCounts[
'transclusion'][
'from'] > 0 ) {
770 if ( $pageCounts[
'transclusion'][
'from'] > count( $transcludedTemplates ) ) {
771 $more = $this->
msg(
'morenotlisted' )->escaped();
779 $this->linkBatchFactory,
780 $this->restrictionStore
783 $pageInfo[
'header-properties'][] = [
784 $this->
msg(
'pageinfo-templates' )
785 ->numParams( $pageCounts[
'transclusion'][
'from'] ),
786 $templateListFormatter->format( $transcludedTemplates,
false, $more )
790 if ( !$config->get( MainConfigNames::MiserMode ) && $pageCounts[
'transclusion'][
'to'] > 0 ) {
791 if ( $pageCounts[
'transclusion'][
'to'] > count( $transcludedTargets ) ) {
794 $this->
msg(
'moredotdotdot' )->text(),
796 [
'hidelinks' => 1,
'hideredirs' => 1 ]
805 $this->linkBatchFactory,
806 $this->restrictionStore
809 $pageInfo[
'header-properties'][] = [
810 $this->
msg(
'pageinfo-transclusions' )
811 ->numParams( $pageCounts[
'transclusion'][
'to'] ),
812 $templateListFormatter->format( $transcludedTargets,
false, $more )
827 private function getNamespaceProtectionMessage(
Title $title ): ?string {
830 $rights[] =
'editsitecss';
831 $rights[] =
'editsitejs';
833 $rights[] =
'editsitecss';
835 $rights[] =
'editsitejs';
837 $rights[] =
'editsitejson';
839 $rights[] =
'editusercss';
841 $rights[] =
'edituserjs';
843 $rights[] =
'edituserjson';
845 $namespaceProtection = $this->context->getConfig()->get( MainConfigNames::NamespaceProtection );
846 $right = $namespaceProtection[$title->
getNamespace()] ??
null;
849 $rights = (array)$right;
853 return $this->
msg(
'protect-fallback', $this->
getLanguage()->commaList( $rights ) )->parse();
864 private function pageCounts() {
865 $page = $this->getWikiPage();
867 $config = $this->context->getConfig();
868 $cache = $this->wanObjectCache;
870 return $cache->getWithSetCallback(
871 self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ),
872 WANObjectCache::TTL_WEEK,
873 function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname ) {
874 $title = $page->getTitle();
877 $dbr = $this->dbProvider->getReplicaDatabase();
880 $field =
'rev_actor';
881 $pageField =
'rev_page';
883 $watchedItemStore = $this->watchedItemStore;
886 $result[
'watchers'] = $watchedItemStore->
countWatchers( $title );
888 if ( $config->get( MainConfigNames::ShowUpdatedMarker ) ) {
889 $updated = (int)
wfTimestamp( TS_UNIX, $page->getTimestamp() );
892 $updated - $config->get( MainConfigNames::WatchersMaxAge )
897 $edits = (int)$dbr->newSelectQueryBuilder()
898 ->select(
'COUNT(*)' )
900 ->where( [
'rev_page' => $id ] )
903 $result[
'edits'] = $edits;
906 if ( $config->get( MainConfigNames::MiserMode ) ) {
907 $result[
'authors'] = 0;
909 $result[
'authors'] = (int)$dbr->newSelectQueryBuilder()
910 ->select(
"COUNT(DISTINCT $field)" )
912 ->where( [ $pageField => $id ] )
918 $threshold = $dbr->timestamp( time() - $config->get( MainConfigNames::RCMaxAge ) );
921 $edits = (int)$dbr->newSelectQueryBuilder()
922 ->select(
'COUNT(rev_page)' )
924 ->where( [
'rev_page' => $id ] )
925 ->andWhere( $dbr->expr(
'rev_timestamp',
'>=', $threshold ) )
928 $result[
'recent_edits'] = $edits;
931 $result[
'recent_authors'] = (int)$dbr->newSelectQueryBuilder()
932 ->select(
"COUNT(DISTINCT $field)" )
934 ->where( [ $pageField => $id ] )
935 ->andWhere( [ $dbr->expr(
'rev_timestamp',
'>=', $threshold ) ] )
940 if ( $this->namespaceInfo->hasSubpages( $title->
getNamespace() ) ) {
941 $conds = [
'page_namespace' => $title->
getNamespace() ];
942 $conds[] = $dbr->expr(
949 $conds[
'page_is_redirect'] = 1;
950 $result[
'subpages'][
'redirects'] = (int)$dbr->newSelectQueryBuilder()
951 ->select(
'COUNT(page_id)' )
957 $conds[
'page_is_redirect'] = 0;
958 $result[
'subpages'][
'nonredirects'] = (int)$dbr->newSelectQueryBuilder()
959 ->select(
'COUNT(page_id)' )
966 $result[
'subpages'][
'total'] = $result[
'subpages'][
'redirects']
967 + $result[
'subpages'][
'nonredirects'];
971 if ( $config->get( MainConfigNames::MiserMode ) ) {
972 $result[
'transclusion'][
'to'] = 0;
974 $result[
'transclusion'][
'to'] = (int)$dbr->newSelectQueryBuilder()
975 ->select(
'COUNT(tl_from)' )
976 ->from(
'templatelinks' )
977 ->where( $this->linksMigration->getLinksConditions(
'templatelinks', $title ) )
982 $result[
'transclusion'][
'from'] = (int)$dbr->newSelectQueryBuilder()
983 ->select(
'COUNT(*)' )
984 ->from(
'templatelinks' )
1000 return $this->msg(
'pageinfo-title' )->plaintextParams( $this->
getTitle()->getPrefixedText() );
1019 return $cache->
makeKey(
'infoaction', md5( (
string)$page ), $revId, self::VERSION );