60 use TocGeneratorTrait;
62 private const VERSION = 1;
67 private readonly
Language $contentLanguage,
85 parent::__construct( $article, $context );
112 if ( $revid ===
null ) {
113 $revision = $services->getRevisionLookup()
114 ->getRevisionByTitle( $page, 0, IDBAccessObject::READ_LATEST );
115 $revid = $revision ? $revision->getId() : 0;
117 $cache = $services->getMainWANObjectCache();
119 $cache->delete( $key );
129 'mediawiki.interface.helpers.styles',
130 'mediawiki.action.styles',
139 $revRecord = $this->
getArticle()->fetchRevisionRecord();
142 return $this->
msg(
'missing-revision', $oldid )->parse();
143 } elseif ( !$revRecord->isCurrent() ) {
144 return $this->
msg(
'pageinfo-not-current' )->plain();
149 $pageInfo = $this->pageInfo();
156 foreach ( $pageInfo as $header => $infoTable ) {
160 $this->addTocSection(
id:
"mw-pageinfo-$header",
msg:
"pageinfo-$header" );
161 $content .= $this->makeHeader(
162 $this->
msg(
"pageinfo-$header" )->text(),
163 "mw-pageinfo-$header"
167 foreach ( $infoTable as $infoRow ) {
168 if ( $infoRow[0] ==
"below" ) {
169 $below = $infoRow[1] .
"\n";
172 $name = ( $infoRow[0] instanceof
Message ) ? $infoRow[0]->escaped() : $infoRow[0];
173 $value = ( $infoRow[1] instanceof
Message ) ? $infoRow[1]->escaped() : $infoRow[1];
174 $id = ( $infoRow[0] instanceof
Message ) ? $infoRow[0]->getKey() :
null;
175 $rows .= $this->getRow( $name, $value, $id ) .
"\n";
177 if ( $rows !==
'' ) {
178 $content .= Html::rawElement(
'table', [
'class' =>
'wikitable mw-page-info' ],
181 $content .=
"\n" . $below;
185 $message = $this->
msg(
'pageinfo-footer' );
186 if ( !$message->isDisabled() ) {
189 $parserOutput = $this->messageParser->parse(
194 $message->getLanguage()
196 $content .= $parserOutput->getContentHolderText();
198 if ( $parserOutput->getTOCData() ) {
199 foreach ( $parserOutput->getTOCData()->getSections() as $s ) {
200 $this->addTocSection( $s->anchor,
'rawmessage', $s->line );
206 $msg = $this->
msg(
'pageinfo-header' );
207 if ( !$msg->isDisabled() ) {
208 $this->
getOutput()->addHTML( $msg->parseAsBlock() );
213 $this->
getOutput()->addTOCPlaceholder( $this->getTocData() );
225 private function makeHeader( $header, $canonicalId ) {
226 return Html::rawElement(
228 [
'id' => Sanitizer::escapeIdForAttribute( $header ) ],
231 [
'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ],
234 htmlspecialchars( $header )
245 private function getRow( $name, $value, $id ) {
246 return Html::rawElement(
249 'id' => $id ===
null ?
null :
'mw-' . $id,
250 'style' =>
'vertical-align: top;',
252 Html::rawElement(
'td', [], $name ) .
253 Html::rawElement(
'td', [], $value )
270 private function pageInfo() {
274 $id = $title->getArticleID();
275 $config = $this->context->getConfig();
276 $linkRenderer = $this->linkRenderer;
278 $pageCounts = $this->pageCounts();
280 $pageProperties = $this->pageProps->getAllProperties( $title )[$id] ?? [];
283 $pageInfo = [
'header-basic' => [] ];
286 $displayTitle = $pageProperties[
'displaytitle'] ??
287 htmlspecialchars( $title->getPrefixedText(), ENT_NOQUOTES );
289 $pageInfo[
'header-basic'][] = [
290 $this->
msg(
'pageinfo-display-title' ),
295 $redirectTarget = $this->redirectLookup->getRedirectTarget( $this->
getWikiPage() );
296 if ( $redirectTarget !==
null ) {
297 $pageInfo[
'header-basic'][] = [
298 $this->
msg(
'pageinfo-redirectsto' ),
299 $linkRenderer->makeLink( $redirectTarget ) .
300 $this->
msg(
'word-separator' )->escaped() .
301 $this->
msg(
'parentheses' )->rawParams( $linkRenderer->makeLink(
303 $this->msg(
'pageinfo-redirectsto-info' )->text(),
305 [
'action' =>
'info' ]
311 $sortKey = $pageProperties[
'defaultsort'] ?? $title->getCategorySortkey();
312 $pageInfo[
'header-basic'][] = [
313 $this->
msg(
'pageinfo-default-sort' ),
314 htmlspecialchars( $sortKey )
318 $pageInfo[
'header-basic'][] = [
319 $this->
msg(
'pageinfo-length' ),
320 $lang->formatNum( $title->getLength() )
324 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-namespace-id' ), $title->getNamespace() ];
325 $pageNamespace = $title->getNsText();
326 if ( $pageNamespace ) {
327 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-namespace' ), $pageNamespace ];
331 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-article-id' ), $id ];
334 $pageLang = $title->getPageLanguage()->getCode();
336 $pageLangHtml = $pageLang .
' - ' .
337 $this->languageNameUtils->getLanguageName( $pageLang, $lang->getCode() );
340 && $this->getAuthority()->probablyCan(
'pagelang', $title )
342 $pageLangHtml .= $this->
msg(
'word-separator' )->escaped();
343 $pageLangHtml .= $this->
msg(
'parentheses' )->rawParams( $linkRenderer->makeLink(
345 $this->msg(
'pageinfo-language-change' )->text()
349 $pageInfo[
'header-basic'][] = [
350 $this->
msg(
'pageinfo-language' )->escaped(),
357 $perm = $title->exists() ?
'editcontentmodel' :
'createwithcontentmodel';
358 if ( $this->
getAuthority()->probablyCan( $perm, $title ) ) {
359 $modelHtml .= $this->
msg(
'word-separator' )->escaped();
360 $modelHtml .= $this->
msg(
'parentheses' )->rawParams( $linkRenderer->makeLink(
362 $this->msg(
'pageinfo-content-model-change' )->text()
366 $pageInfo[
'header-basic'][] = [
367 $this->
msg(
'pageinfo-content-model' ),
371 if ( $title->inNamespace(
NS_USER ) ) {
372 $pageUser = $this->userFactory->newFromName( $title->getRootText() );
373 if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) {
374 $pageInfo[
'header-basic'][] = [
375 $this->
msg(
'pageinfo-user-id' ),
382 $parserOutput =
new ParserOutput();
383 if ( isset( $pageProperties[
'noindex'] ) ) {
384 $parserOutput->setIndexPolicy(
'noindex' );
386 if ( isset( $pageProperties[
'index'] ) ) {
387 $parserOutput->setIndexPolicy(
'index' );
391 $policy = $this->
getArticle()->getRobotPolicy(
'view', $parserOutput );
392 $pageInfo[
'header-basic'][] = [
394 $this->
msg(
'pageinfo-robot-policy' ),
395 $this->
msg(
"pageinfo-robot-{$policy['index']}" )
399 if ( $this->
getAuthority()->isAllowed(
'unwatchedpages' ) ||
400 ( $unwatchedPageThreshold !==
false &&
401 $pageCounts[
'watchers'] >= $unwatchedPageThreshold )
404 $pageInfo[
'header-basic'][] = [
405 $this->
msg(
'pageinfo-watchers' ),
406 $lang->formatNum( $pageCounts[
'watchers'] )
409 $visiting = $pageCounts[
'visitingWatchers'] ??
null;
412 $this->getAuthority()->isAllowed(
'unwatchedpages' )
414 $value = $lang->formatNum( $visiting );
416 $value = $this->
msg(
'pageinfo-few-visiting-watchers' );
418 $pageInfo[
'header-basic'][] = [
419 $this->
msg(
'pageinfo-visiting-watchers' )
424 } elseif ( $unwatchedPageThreshold !==
false ) {
425 $pageInfo[
'header-basic'][] = [
426 $this->
msg(
'pageinfo-watchers' ),
427 $this->
msg(
'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
433 $pageInfo[
'header-basic'][] = [
434 $linkRenderer->makeLink(
436 $this->
msg(
'pageinfo-redirects-name' )->text(),
441 'hideimages' => $title->getNamespace() ===
NS_FILE
444 $this->
msg(
'pageinfo-redirects-value' )
445 ->numParams( count( $title->getRedirectsHere() ) )
450 $pageInfo[
'header-basic'][] = [
451 $this->
msg(
'pageinfo-contentpage' ),
452 $this->
msg(
'pageinfo-contentpage-yes' )
457 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
460 $title->getPrefixedText() .
'/'
462 $pageInfo[
'header-basic'][] = [
463 $linkRenderer->makeLink(
465 $this->
msg(
'pageinfo-subpages-name' )->text()
468 isset( $pageCounts[
'subpages'] )
469 ? $this->
msg(
'pageinfo-subpages-value' )->numParams(
470 $pageCounts[
'subpages'][
'total'],
471 $pageCounts[
'subpages'][
'redirects'],
472 $pageCounts[
'subpages'][
'nonredirects']
473 ) : $this->
msg(
'pageinfo-subpages-value-unknown' )->rawParams(
474 $linkRenderer->makeKnownLink(
475 $title, $this->msg(
'purge' )->text(), [], [
'action' =>
'purge' ] )
483 $allCount = $category->getMemberCount();
484 $subcatCount = $category->getSubcatCount();
485 $fileCount = $category->getFileCount();
488 $pageInfo[
'category-info'] = [
490 $this->
msg(
'pageinfo-category-total' ),
491 $lang->formatNum( $allCount )
494 $this->
msg(
'pageinfo-category-pages' ),
495 $lang->formatNum( $pageCount )
498 $this->
msg(
'pageinfo-category-subcats' ),
499 $lang->formatNum( $subcatCount )
502 $this->
msg(
'pageinfo-category-files' ),
503 $lang->formatNum( $fileCount )
509 if ( $title->inNamespace(
NS_FILE ) ) {
510 $fileObj = $this->repoGroup->findFile( $title );
511 if ( $fileObj !==
false ) {
513 $output = \Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 );
514 $pageInfo[
'header-basic'][] = [
515 $this->
msg(
'pageinfo-file-hash' ),
522 $pageInfo[
'header-restrictions'] = [];
525 if ( $this->restrictionStore->isCascadeProtected( $title ) ) {
527 $sources = $this->restrictionStore->getCascadeProtectionSources( $title )[0];
529 foreach ( $sources as $sourcePageIdentity ) {
530 $cascadingFrom .= Html::rawElement(
533 $linkRenderer->makeKnownLink( $sourcePageIdentity )
537 $cascadingFrom = Html::rawElement(
'ul', [], $cascadingFrom );
538 $pageInfo[
'header-restrictions'][] = [
539 $this->
msg(
'pageinfo-protect-cascading-from' ),
545 if ( $this->restrictionStore->areRestrictionsCascading( $title ) ) {
546 $pageInfo[
'header-restrictions'][] = [
547 $this->
msg(
'pageinfo-protect-cascading' ),
548 $this->
msg(
'pageinfo-protect-cascading-yes' )
553 foreach ( $this->restrictionStore->listApplicableRestrictionTypes( $title ) as $restrictionType ) {
554 $protections = $this->restrictionStore->getRestrictions( $title, $restrictionType );
556 switch ( count( $protections ) ) {
558 $message = $this->getNamespaceProtectionMessage( $title ) ??
560 $this->
msg(
'protect-default' )->escaped();
565 $message = $this->
msg(
'protect-level-' . $protections[0] );
566 if ( !$message->isDisabled() ) {
567 $message = $message->escaped();
574 $message = $this->
msg(
"protect-fallback", $lang->commaList( $protections ) )->parse();
577 $expiry = $this->restrictionStore->getRestrictionExpiry( $title, $restrictionType );
578 $formattedexpiry = $expiry ===
null ?
'' : $this->
msg(
580 $lang->formatExpiry( $expiry,
true,
'infinity', $user )
582 $message .= $this->
msg(
'word-separator' )->escaped() . $formattedexpiry;
586 $pageInfo[
'header-restrictions'][] = [
587 $this->
msg(
"restriction-$restrictionType" ), $message
591 $pageInfo[
'header-restrictions'][] = [
593 $linkRenderer->makeKnownLink(
595 $this->
msg(
'pageinfo-view-protect-log' )->text(),
597 [
'type' =>
'protect',
'page' => $title->getPrefixedText() ]
606 $pageInfo[
'header-edits'] = [];
608 $firstRev = $this->revisionLookup->getFirstRevision( $this->
getTitle() );
609 $lastRev = $this->
getWikiPage()->getRevisionRecord();
610 $batch = $this->linkBatchFactory->newLinkBatch()
611 ->setCaller( __METHOD__ );
613 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
614 if ( $firstRevUser ) {
615 $batch->addUser( $firstRevUser );
620 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
621 if ( $lastRevUser ) {
622 $batch->addUser( $lastRevUser );
630 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
634 $firstRevUserName = $firstRevUser ? $firstRevUser->getName() :
'[HIDDEN]';
635 $pageInfo[
'header-edits'][] = [
636 $this->
msg(
'pageinfo-firstuser', $firstRevUserName ),
637 Linker::revUserTools( $firstRev )
641 $pageInfo[
'header-edits'][] = [
642 $this->
msg(
'pageinfo-firsttime' ),
643 $linkRenderer->makeKnownLink(
645 $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
647 [
'oldid' => $firstRev->getId() ]
654 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
658 $lastRevUserName = $lastRevUser ? $lastRevUser->getName() :
'[HIDDEN]';
659 $pageInfo[
'header-edits'][] = [
660 $this->
msg(
'pageinfo-lastuser', $lastRevUserName ),
661 Linker::revUserTools( $lastRev )
665 $pageInfo[
'header-edits'][] = [
666 $this->
msg(
'pageinfo-lasttime' ),
667 $linkRenderer->makeKnownLink(
669 $lang->userTimeAndDate( $this->getWikiPage()->getTimestamp(), $user ),
671 [
'oldid' => $this->getWikiPage()->getLatest() ]
677 $pageInfo[
'header-edits'][] = [
678 $this->
msg(
'pageinfo-edits' ),
679 $lang->formatNum( $pageCounts[
'edits'] )
683 if ( $pageCounts[
'authors'] > 0 ) {
684 $pageInfo[
'header-edits'][] = [
685 $this->
msg(
'pageinfo-authors' ),
686 $lang->formatNum( $pageCounts[
'authors'] )
691 $pageInfo[
'header-edits'][] = [
693 'pageinfo-recent-edits',
696 $lang->formatNum( $pageCounts[
'recent_edits'] )
700 $pageInfo[
'header-edits'][] = [
701 $this->
msg(
'pageinfo-recent-authors' ),
702 $lang->formatNum( $pageCounts[
'recent_authors'] )
706 $wordIDs = $this->magicWordFactory->getDoubleUnderscoreArray()->getNames();
709 $localizedWords = $this->contentLanguage->getMagicWords();
712 foreach ( $pageProperties as $property => $value ) {
713 if ( in_array( $property, $wordIDs ) ) {
714 $listItems[] =
Html::element(
'li', [], $localizedWords[$property][1] );
718 $localizedList = Html::rawElement(
'ul', [], implode(
'', $listItems ) );
719 $hiddenCategories = $this->
getWikiPage()->getHiddenCategories();
722 count( $listItems ) > 0 ||
723 count( $hiddenCategories ) > 0 ||
724 $pageCounts[
'transclusion'][
'from'] > 0 ||
725 $pageCounts[
'transclusion'][
'to'] > 0
728 $transcludedTemplates = $title->getTemplateLinksFrom( $options );
730 $transcludedTargets = [];
732 $transcludedTargets = $title->getTemplateLinksTo( $options );
736 $pageInfo[
'header-properties'] = [];
739 if ( count( $listItems ) > 0 ) {
740 $pageInfo[
'header-properties'][] = [
741 $this->
msg(
'pageinfo-magic-words' )->numParams( count( $listItems ) ),
747 if ( count( $hiddenCategories ) > 0 ) {
748 $pageInfo[
'header-properties'][] = [
749 $this->
msg(
'pageinfo-hidden-categories' )
750 ->numParams( count( $hiddenCategories ) ),
751 Linker::formatHiddenCategories( $hiddenCategories )
756 if ( $pageCounts[
'transclusion'][
'from'] > 0 ) {
757 if ( $pageCounts[
'transclusion'][
'from'] > count( $transcludedTemplates ) ) {
758 $more = $this->
msg(
'morenotlisted' )->escaped();
763 $templateListFormatter =
new TemplatesOnThisPageFormatter(
766 $this->linkBatchFactory,
767 $this->restrictionStore
770 $pageInfo[
'header-properties'][] = [
771 $this->
msg(
'pageinfo-templates' )
772 ->numParams( $pageCounts[
'transclusion'][
'from'] ),
773 $templateListFormatter->format( $transcludedTemplates,
false, $more )
778 if ( $pageCounts[
'transclusion'][
'to'] > count( $transcludedTargets ) ) {
779 $more = $linkRenderer->makeLink(
781 $this->
msg(
'moredotdotdot' )->text(),
783 [
'hidelinks' => 1,
'hideredirs' => 1 ]
789 $templateListFormatter =
new TemplatesOnThisPageFormatter(
792 $this->linkBatchFactory,
793 $this->restrictionStore
796 $pageInfo[
'header-properties'][] = [
797 $this->
msg(
'pageinfo-transclusions' )
798 ->numParams( $pageCounts[
'transclusion'][
'to'] ),
799 $templateListFormatter->format( $transcludedTargets,
false, $more )
814 private function getNamespaceProtectionMessage( Title $title ): ?string {
816 if ( $title->isRawHtmlMessage() ) {
817 $rights[] =
'editsitecss';
818 $rights[] =
'editsitejs';
819 } elseif ( $title->isSiteCssConfigPage() ) {
820 $rights[] =
'editsitecss';
821 } elseif ( $title->isSiteJsConfigPage() ) {
822 $rights[] =
'editsitejs';
823 } elseif ( $title->isSiteJsonConfigPage() ) {
824 $rights[] =
'editsitejson';
825 } elseif ( $title->isUserCssConfigPage() ) {
826 $rights[] =
'editusercss';
827 } elseif ( $title->isUserJsConfigPage() ) {
828 $rights[] =
'edituserjs';
829 } elseif ( $title->isUserJsonConfigPage() ) {
830 $rights[] =
'edituserjson';
831 } elseif ( $title->inNamespace(
NS_USER )
834 $rights[] =
'editalluserpages';
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 () use ( $page, $config, $fname ) {
865 $title = $page->getTitle();
866 $id = $title->getArticleID();
868 $dbr = $this->dbProvider->getReplicaDatabase();
870 $field =
'rev_actor';
871 $pageField =
'rev_page';
873 $watchedItemStore = $this->watchedItemStore;
876 $result[
'watchers'] = $watchedItemStore->
countWatchers( $title );
879 $updated = (int)wfTimestamp( TS::UNIX, $page->getTimestamp() );
880 $result[
'visitingWatchers'] = $watchedItemStore->countVisitingWatchers(
882 $updated - $config->get( MainConfigNames::WatchersMaxAge )
887 $edits = (
int)$dbr->newSelectQueryBuilder()
888 ->select(
'COUNT(*)' )
890 ->where( [
'rev_page' => $id ] )
893 $result[
'edits'] = $edits;
897 $result[
'authors'] = 0;
899 $result[
'authors'] = (int)$dbr->newSelectQueryBuilder()
900 ->select(
"COUNT(DISTINCT $field)" )
904 $dbr->bitAnd(
'rev_deleted', RevisionRecord::DELETED_USER ) .
' = 0',
914 $edits = (int)$dbr->newSelectQueryBuilder()
915 ->select(
'COUNT(rev_page)' )
917 ->where( [
'rev_page' => $id ] )
918 ->andWhere( $dbr->expr(
'rev_timestamp',
'>=', $threshold ) )
921 $result[
'recent_edits'] = $edits;
924 $result[
'recent_authors'] = (int)$dbr->newSelectQueryBuilder()
925 ->select(
"COUNT(DISTINCT $field)" )
927 ->where( [ $pageField => $id ] )
929 $dbr->expr(
'rev_timestamp',
'>=', $threshold ),
930 $dbr->bitAnd(
'rev_deleted', RevisionRecord::DELETED_USER ) .
' = 0',
936 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
937 $conds = [
'page_namespace' => $title->getNamespace() ];
938 $conds[] = $dbr->expr(
941 new LikeValue( $title->getDBkey() .
'/', $dbr->anyString() )
945 $conds[
'page_is_redirect'] = 1;
946 $result[
'subpages'][
'redirects'] = (int)$dbr->newSelectQueryBuilder()
947 ->select(
'COUNT(page_id)' )
953 $conds[
'page_is_redirect'] = 0;
954 $result[
'subpages'][
'nonredirects'] = (int)$dbr->newSelectQueryBuilder()
955 ->select(
'COUNT(page_id)' )
962 $result[
'subpages'][
'total'] = $result[
'subpages'][
'redirects']
963 + $result[
'subpages'][
'nonredirects'];
969 $result[
'transclusion'][
'to'] = 0;
971 $result[
'transclusion'][
'to'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
972 ->select(
'COUNT(tl_from)' )
973 ->from(
'templatelinks' )
974 ->where( $this->linksMigration->getLinksConditions(
'templatelinks', $title ) )
979 $result[
'transclusion'][
'from'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
980 ->select(
'COUNT(*)' )
981 ->from(
'templatelinks' )
982 ->where( [
'tl_from' => $title->getArticleID() ] )
997 return $this->msg(
'pageinfo-title' )->plaintextParams( $this->getTitle()->getPrefixedText() );
1016 return $cache->
makeKey(
'infoaction', md5( (
string)$page ), $revId, self::VERSION );