58 private const VERSION = 1;
61 private LanguageNameUtils $languageNameUtils;
62 private LinkBatchFactory $linkBatchFactory;
81 LanguageNameUtils $languageNameUtils,
82 LinkBatchFactory $linkBatchFactory,
97 parent::__construct( $article,
$context );
98 $this->contentLanguage = $contentLanguage;
99 $this->languageNameUtils = $languageNameUtils;
100 $this->linkBatchFactory = $linkBatchFactory;
101 $this->linkRenderer = $linkRenderer;
102 $this->dbProvider = $dbProvider;
103 $this->magicWordFactory = $magicWordFactory;
104 $this->namespaceInfo = $namespaceInfo;
105 $this->pageProps = $pageProps;
106 $this->repoGroup = $repoGroup;
107 $this->revisionLookup = $revisionLookup;
108 $this->wanObjectCache = $wanObjectCache;
109 $this->watchedItemStore = $watchedItemStore;
110 $this->redirectLookup = $redirectLookup;
111 $this->restrictionStore = $restrictionStore;
112 $this->linksMigration = $linksMigration;
113 $this->userFactory = $userFactory;
140 if ( $revid ===
null ) {
141 $revision = $services->getRevisionLookup()
142 ->getRevisionByTitle( $page, 0, IDBAccessObject::READ_LATEST );
143 $revid = $revision ? $revision->getId() : 0;
145 $cache = $services->getMainWANObjectCache();
147 $cache->delete( $key );
157 'mediawiki.interface.helpers.styles',
158 'mediawiki.action.styles',
167 $revRecord = $this->
getArticle()->fetchRevisionRecord();
170 return $this->
msg(
'missing-revision', $oldid )->parse();
171 } elseif ( !$revRecord->isCurrent() ) {
172 return $this->
msg(
'pageinfo-not-current' )->plain();
177 $msg = $this->
msg(
'pageinfo-header' );
178 $content = $msg->isDisabled() ?
'' : $msg->parse();
181 $pageInfo = $this->pageInfo();
187 foreach ( $pageInfo as $header => $infoTable ) {
191 $content .= $this->makeHeader(
192 $this->
msg(
"pageinfo-$header" )->text(),
193 "mw-pageinfo-$header"
197 foreach ( $infoTable as $infoRow ) {
198 if ( $infoRow[0] ==
"below" ) {
199 $below = $infoRow[1] .
"\n";
202 $name = ( $infoRow[0] instanceof
Message ) ? $infoRow[0]->escaped() : $infoRow[0];
203 $value = ( $infoRow[1] instanceof
Message ) ? $infoRow[1]->escaped() : $infoRow[1];
204 $id = ( $infoRow[0] instanceof
Message ) ? $infoRow[0]->getKey() :
null;
205 $rows .= $this->getRow( $name, $value, $id ) .
"\n";
207 if ( $rows !==
'' ) {
208 $content .= Html::rawElement(
'table', [
'class' =>
'wikitable mw-page-info' ],
211 $content .=
"\n" . $below;
215 if ( !$this->
msg(
'pageinfo-footer' )->isDisabled() ) {
216 $content .= $this->
msg(
'pageinfo-footer' )->parse();
229 private function makeHeader( $header, $canonicalId ) {
230 return Html::rawElement(
232 [
'id' => Sanitizer::escapeIdForAttribute( $header ) ],
235 [
'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ],
238 htmlspecialchars( $header )
249 private function getRow( $name, $value, $id ) {
250 return Html::rawElement(
253 'id' => $id ===
null ?
null :
'mw-' . $id,
254 'style' =>
'vertical-align: top;',
256 Html::rawElement(
'td', [], $name ) .
257 Html::rawElement(
'td', [], $value )
274 private function pageInfo() {
278 $id = $title->getArticleID();
279 $config = $this->context->getConfig();
280 $linkRenderer = $this->linkRenderer;
282 $pageCounts = $this->pageCounts();
284 $pageProperties = $this->pageProps->getAllProperties( $title )[$id] ?? [];
288 $pageInfo[
'header-basic'] = [];
291 $displayTitle = $pageProperties[
'displaytitle'] ??
292 htmlspecialchars( $title->getPrefixedText(), ENT_NOQUOTES );
294 $pageInfo[
'header-basic'][] = [
295 $this->
msg(
'pageinfo-display-title' ),
300 $redirectTarget = $this->redirectLookup->getRedirectTarget( $this->
getWikiPage() );
301 if ( $redirectTarget !==
null ) {
302 $pageInfo[
'header-basic'][] = [
303 $this->
msg(
'pageinfo-redirectsto' ),
304 $linkRenderer->
makeLink( $redirectTarget ) .
305 $this->
msg(
'word-separator' )->escaped() .
306 $this->
msg(
'parentheses' )->rawParams( $linkRenderer->
makeLink(
308 $this->msg(
'pageinfo-redirectsto-info' )->text(),
310 [
'action' =>
'info' ]
316 $sortKey = $pageProperties[
'defaultsort'] ?? $title->getCategorySortkey();
317 $pageInfo[
'header-basic'][] = [
318 $this->
msg(
'pageinfo-default-sort' ),
319 htmlspecialchars( $sortKey )
323 $pageInfo[
'header-basic'][] = [
324 $this->
msg(
'pageinfo-length' ),
325 $lang->formatNum( $title->getLength() )
329 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-namespace-id' ), $title->getNamespace() ];
330 $pageNamespace = $title->getNsText();
331 if ( $pageNamespace ) {
332 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-namespace' ), $pageNamespace ];
336 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-article-id' ), $id ];
339 $pageLang = $title->getPageLanguage()->getCode();
341 $pageLangHtml = $pageLang .
' - ' .
342 $this->languageNameUtils->getLanguageName( $pageLang, $lang->getCode() );
345 && $this->getAuthority()->probablyCan(
'pagelang', $title )
347 $pageLangHtml .= $this->
msg(
'word-separator' )->escaped();
348 $pageLangHtml .= $this->
msg(
'parentheses' )->rawParams( $linkRenderer->
makeLink(
350 $this->msg(
'pageinfo-language-change' )->text()
354 $pageInfo[
'header-basic'][] = [
355 $this->
msg(
'pageinfo-language' )->escaped(),
362 if ( $this->
getAuthority()->probablyCan(
'editcontentmodel', $title ) ) {
363 $modelHtml .= $this->
msg(
'word-separator' )->escaped();
364 $modelHtml .= $this->
msg(
'parentheses' )->rawParams( $linkRenderer->
makeLink(
366 $this->msg(
'pageinfo-content-model-change' )->text()
370 $pageInfo[
'header-basic'][] = [
371 $this->
msg(
'pageinfo-content-model' ),
375 if ( $title->inNamespace(
NS_USER ) ) {
376 $pageUser = $this->userFactory->newFromName( $title->getRootText() );
377 if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) {
378 $pageInfo[
'header-basic'][] = [
379 $this->
msg(
'pageinfo-user-id' ),
386 $parserOutput =
new ParserOutput();
387 if ( isset( $pageProperties[
'noindex'] ) ) {
388 $parserOutput->setIndexPolicy(
'noindex' );
390 if ( isset( $pageProperties[
'index'] ) ) {
391 $parserOutput->setIndexPolicy(
'index' );
395 $policy = $this->
getArticle()->getRobotPolicy(
'view', $parserOutput );
396 $pageInfo[
'header-basic'][] = [
398 $this->
msg(
'pageinfo-robot-policy' ),
399 $this->
msg(
"pageinfo-robot-{$policy['index']}" )
403 if ( $this->
getAuthority()->isAllowed(
'unwatchedpages' ) ||
404 ( $unwatchedPageThreshold !==
false &&
405 $pageCounts[
'watchers'] >= $unwatchedPageThreshold )
408 $pageInfo[
'header-basic'][] = [
409 $this->
msg(
'pageinfo-watchers' ),
410 $lang->formatNum( $pageCounts[
'watchers'] )
413 $visiting = $pageCounts[
'visitingWatchers'] ??
null;
416 $this->getAuthority()->isAllowed(
'unwatchedpages' )
418 $value = $lang->formatNum( $visiting );
420 $value = $this->
msg(
'pageinfo-few-visiting-watchers' );
422 $pageInfo[
'header-basic'][] = [
423 $this->
msg(
'pageinfo-visiting-watchers' )
428 } elseif ( $unwatchedPageThreshold !==
false ) {
429 $pageInfo[
'header-basic'][] = [
430 $this->
msg(
'pageinfo-watchers' ),
431 $this->
msg(
'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
437 $pageInfo[
'header-basic'][] = [
440 $this->
msg(
'pageinfo-redirects-name' )->text(),
445 'hideimages' => $title->getNamespace() ===
NS_FILE
448 $this->
msg(
'pageinfo-redirects-value' )
449 ->numParams( count( $title->getRedirectsHere() ) )
454 $pageInfo[
'header-basic'][] = [
455 $this->
msg(
'pageinfo-contentpage' ),
456 $this->
msg(
'pageinfo-contentpage-yes' )
461 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
464 $title->getPrefixedText() .
'/'
466 $pageInfo[
'header-basic'][] = [
469 $this->
msg(
'pageinfo-subpages-name' )->text()
472 isset( $pageCounts[
'subpages'] )
473 ? $this->
msg(
'pageinfo-subpages-value' )->numParams(
474 $pageCounts[
'subpages'][
'total'],
475 $pageCounts[
'subpages'][
'redirects'],
476 $pageCounts[
'subpages'][
'nonredirects']
477 ) : $this->
msg(
'pageinfo-subpages-value-unknown' )->rawParams(
479 $title, $this->msg(
'purge' )->text(), [], [
'action' =>
'purge' ] )
487 $allCount = $category->getMemberCount();
488 $subcatCount = $category->getSubcatCount();
489 $fileCount = $category->getFileCount();
492 $pageInfo[
'category-info'] = [
494 $this->
msg(
'pageinfo-category-total' ),
495 $lang->formatNum( $allCount )
498 $this->
msg(
'pageinfo-category-pages' ),
499 $lang->formatNum( $pageCount )
502 $this->
msg(
'pageinfo-category-subcats' ),
503 $lang->formatNum( $subcatCount )
506 $this->
msg(
'pageinfo-category-files' ),
507 $lang->formatNum( $fileCount )
513 if ( $title->inNamespace(
NS_FILE ) ) {
514 $fileObj = $this->repoGroup->findFile( $title );
515 if ( $fileObj !==
false ) {
517 $output = \Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 );
518 $pageInfo[
'header-basic'][] = [
519 $this->
msg(
'pageinfo-file-hash' ),
526 $pageInfo[
'header-restrictions'] = [];
529 if ( $this->restrictionStore->isCascadeProtected( $title ) ) {
531 $sources = $this->restrictionStore->getCascadeProtectionSources( $title )[0];
533 foreach ( $sources as $sourcePageIdentity ) {
534 $cascadingFrom .= Html::rawElement(
541 $cascadingFrom = Html::rawElement(
'ul', [], $cascadingFrom );
542 $pageInfo[
'header-restrictions'][] = [
543 $this->
msg(
'pageinfo-protect-cascading-from' ),
549 if ( $this->restrictionStore->areRestrictionsCascading( $title ) ) {
550 $pageInfo[
'header-restrictions'][] = [
551 $this->
msg(
'pageinfo-protect-cascading' ),
552 $this->
msg(
'pageinfo-protect-cascading-yes' )
557 foreach ( $this->restrictionStore->listApplicableRestrictionTypes( $title ) as $restrictionType ) {
558 $protections = $this->restrictionStore->getRestrictions( $title, $restrictionType );
560 switch ( count( $protections ) ) {
562 $message = $this->getNamespaceProtectionMessage( $title ) ??
564 $this->
msg(
'protect-default' )->escaped();
569 $message = $this->
msg(
'protect-level-' . $protections[0] );
570 if ( !$message->isDisabled() ) {
571 $message = $message->escaped();
578 $message = $this->
msg(
"protect-fallback", $lang->commaList( $protections ) )->parse();
581 $expiry = $this->restrictionStore->getRestrictionExpiry( $title, $restrictionType );
582 $formattedexpiry = $expiry ===
null ?
'' : $this->
msg(
584 $lang->formatExpiry( $expiry,
true,
'infinity', $user )
586 $message .= $this->
msg(
'word-separator' )->escaped() . $formattedexpiry;
590 $pageInfo[
'header-restrictions'][] = [
591 $this->
msg(
"restriction-$restrictionType" ), $message
595 $pageInfo[
'header-restrictions'][] = [
599 $this->
msg(
'pageinfo-view-protect-log' )->text(),
601 [
'type' =>
'protect',
'page' => $title->getPrefixedText() ]
610 $pageInfo[
'header-edits'] = [];
612 $firstRev = $this->revisionLookup->getFirstRevision( $this->
getTitle() );
613 $lastRev = $this->
getWikiPage()->getRevisionRecord();
614 $batch = $this->linkBatchFactory->newLinkBatch()
615 ->setCaller( __METHOD__ );
617 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
618 if ( $firstRevUser ) {
619 $batch->addUser( $firstRevUser );
624 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
625 if ( $lastRevUser ) {
626 $batch->addUser( $lastRevUser );
634 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
638 $firstRevUserName = $firstRevUser ? $firstRevUser->getName() :
'[HIDDEN]';
639 $pageInfo[
'header-edits'][] = [
640 $this->
msg(
'pageinfo-firstuser', $firstRevUserName ),
641 Linker::revUserTools( $firstRev )
645 $pageInfo[
'header-edits'][] = [
646 $this->
msg(
'pageinfo-firsttime' ),
649 $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
651 [
'oldid' => $firstRev->getId() ]
658 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
662 $lastRevUserName = $lastRevUser ? $lastRevUser->getName() :
'[HIDDEN]';
663 $pageInfo[
'header-edits'][] = [
664 $this->
msg(
'pageinfo-lastuser', $lastRevUserName ),
665 Linker::revUserTools( $lastRev )
669 $pageInfo[
'header-edits'][] = [
670 $this->
msg(
'pageinfo-lasttime' ),
673 $lang->userTimeAndDate( $this->getWikiPage()->getTimestamp(), $user ),
675 [
'oldid' => $this->getWikiPage()->getLatest() ]
681 $pageInfo[
'header-edits'][] = [
682 $this->
msg(
'pageinfo-edits' ),
683 $lang->formatNum( $pageCounts[
'edits'] )
687 if ( $pageCounts[
'authors'] > 0 ) {
688 $pageInfo[
'header-edits'][] = [
689 $this->
msg(
'pageinfo-authors' ),
690 $lang->formatNum( $pageCounts[
'authors'] )
695 $pageInfo[
'header-edits'][] = [
697 'pageinfo-recent-edits',
700 $lang->formatNum( $pageCounts[
'recent_edits'] )
704 $pageInfo[
'header-edits'][] = [
705 $this->
msg(
'pageinfo-recent-authors' ),
706 $lang->formatNum( $pageCounts[
'recent_authors'] )
710 $wordIDs = $this->magicWordFactory->getDoubleUnderscoreArray()->getNames();
713 $localizedWords = $this->contentLanguage->getMagicWords();
716 foreach ( $pageProperties as $property => $value ) {
717 if ( in_array( $property, $wordIDs ) ) {
718 $listItems[] =
Html::element(
'li', [], $localizedWords[$property][1] );
722 $localizedList = Html::rawElement(
'ul', [], implode(
'', $listItems ) );
723 $hiddenCategories = $this->
getWikiPage()->getHiddenCategories();
726 count( $listItems ) > 0 ||
727 count( $hiddenCategories ) > 0 ||
728 $pageCounts[
'transclusion'][
'from'] > 0 ||
729 $pageCounts[
'transclusion'][
'to'] > 0
732 $transcludedTemplates = $title->getTemplateLinksFrom( $options );
734 $transcludedTargets = [];
736 $transcludedTargets = $title->getTemplateLinksTo( $options );
740 $pageInfo[
'header-properties'] = [];
743 if ( count( $listItems ) > 0 ) {
744 $pageInfo[
'header-properties'][] = [
745 $this->
msg(
'pageinfo-magic-words' )->numParams( count( $listItems ) ),
751 if ( count( $hiddenCategories ) > 0 ) {
752 $pageInfo[
'header-properties'][] = [
753 $this->
msg(
'pageinfo-hidden-categories' )
754 ->numParams( count( $hiddenCategories ) ),
755 Linker::formatHiddenCategories( $hiddenCategories )
760 if ( $pageCounts[
'transclusion'][
'from'] > 0 ) {
761 if ( $pageCounts[
'transclusion'][
'from'] > count( $transcludedTemplates ) ) {
762 $more = $this->
msg(
'morenotlisted' )->escaped();
767 $templateListFormatter =
new TemplatesOnThisPageFormatter(
770 $this->linkBatchFactory,
771 $this->restrictionStore
774 $pageInfo[
'header-properties'][] = [
775 $this->
msg(
'pageinfo-templates' )
776 ->numParams( $pageCounts[
'transclusion'][
'from'] ),
777 $templateListFormatter->format( $transcludedTemplates,
false, $more )
782 if ( $pageCounts[
'transclusion'][
'to'] > count( $transcludedTargets ) ) {
785 $this->
msg(
'moredotdotdot' )->text(),
787 [
'hidelinks' => 1,
'hideredirs' => 1 ]
793 $templateListFormatter =
new TemplatesOnThisPageFormatter(
796 $this->linkBatchFactory,
797 $this->restrictionStore
800 $pageInfo[
'header-properties'][] = [
801 $this->
msg(
'pageinfo-transclusions' )
802 ->numParams( $pageCounts[
'transclusion'][
'to'] ),
803 $templateListFormatter->format( $transcludedTargets,
false, $more )
818 private function getNamespaceProtectionMessage( Title $title ): ?string {
820 if ( $title->isRawHtmlMessage() ) {
821 $rights[] =
'editsitecss';
822 $rights[] =
'editsitejs';
823 } elseif ( $title->isSiteCssConfigPage() ) {
824 $rights[] =
'editsitecss';
825 } elseif ( $title->isSiteJsConfigPage() ) {
826 $rights[] =
'editsitejs';
827 } elseif ( $title->isSiteJsonConfigPage() ) {
828 $rights[] =
'editsitejson';
829 } elseif ( $title->isUserCssConfigPage() ) {
830 $rights[] =
'editusercss';
831 } elseif ( $title->isUserJsConfigPage() ) {
832 $rights[] =
'edituserjs';
833 } elseif ( $title->isUserJsonConfigPage() ) {
834 $rights[] =
'edituserjson';
837 $right = $namespaceProtection[$title->getNamespace()] ??
null;
840 $rights = (array)$right;
844 return $this->
msg(
'protect-fallback', $this->
getLanguage()->commaList( $rights ) )->parse();
855 private function pageCounts() {
856 $page = $this->getWikiPage();
858 $config = $this->context->getConfig();
859 $cache = $this->wanObjectCache;
861 return $cache->getWithSetCallback(
862 self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ),
863 WANObjectCache::TTL_WEEK,
864 function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname ) {
865 $title = $page->getTitle();
866 $id = $title->getArticleID();
868 $dbr = $this->dbProvider->getReplicaDatabase();
871 $field =
'rev_actor';
872 $pageField =
'rev_page';
874 $watchedItemStore = $this->watchedItemStore;
877 $result[
'watchers'] = $watchedItemStore->
countWatchers( $title );
880 $updated = (int)
wfTimestamp( TS_UNIX, $page->getTimestamp() );
888 $edits = (int)$dbr->newSelectQueryBuilder()
889 ->select(
'COUNT(*)' )
891 ->where( [
'rev_page' => $id ] )
894 $result[
'edits'] = $edits;
898 $result[
'authors'] = 0;
900 $result[
'authors'] = (int)$dbr->newSelectQueryBuilder()
901 ->select(
"COUNT(DISTINCT $field)" )
903 ->where( [ $pageField => $id ] )
912 $edits = (int)$dbr->newSelectQueryBuilder()
913 ->select(
'COUNT(rev_page)' )
915 ->where( [
'rev_page' => $id ] )
916 ->andWhere( $dbr->expr(
'rev_timestamp',
'>=', $threshold ) )
919 $result[
'recent_edits'] = $edits;
922 $result[
'recent_authors'] = (int)$dbr->newSelectQueryBuilder()
923 ->select(
"COUNT(DISTINCT $field)" )
925 ->where( [ $pageField => $id ] )
926 ->andWhere( [ $dbr->expr(
'rev_timestamp',
'>=', $threshold ) ] )
931 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
932 $conds = [
'page_namespace' => $title->getNamespace() ];
933 $conds[] = $dbr->expr(
936 new LikeValue( $title->getDBkey() .
'/', $dbr->anyString() )
940 $conds[
'page_is_redirect'] = 1;
941 $result[
'subpages'][
'redirects'] = (int)$dbr->newSelectQueryBuilder()
942 ->select(
'COUNT(page_id)' )
948 $conds[
'page_is_redirect'] = 0;
949 $result[
'subpages'][
'nonredirects'] = (int)$dbr->newSelectQueryBuilder()
950 ->select(
'COUNT(page_id)' )
957 $result[
'subpages'][
'total'] = $result[
'subpages'][
'redirects']
958 + $result[
'subpages'][
'nonredirects'];
964 $result[
'transclusion'][
'to'] = 0;
966 $result[
'transclusion'][
'to'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
967 ->select(
'COUNT(tl_from)' )
968 ->from(
'templatelinks' )
969 ->where( $this->linksMigration->getLinksConditions(
'templatelinks', $title ) )
974 $result[
'transclusion'][
'from'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
975 ->select(
'COUNT(*)' )
976 ->from(
'templatelinks' )
977 ->where( [
'tl_from' => $title->getArticleID() ] )
992 return $this->msg(
'pageinfo-title' )->plaintextParams( $this->getTitle()->getPrefixedText() );
1011 return $cache->
makeKey(
'infoaction', md5( (
string)$page ), $revId, self::VERSION );