61 use TocGeneratorTrait;
63 private const VERSION = 1;
68 private readonly
Language $contentLanguage,
86 parent::__construct( $article, $context );
113 if ( $revid ===
null ) {
114 $revision = $services->getRevisionLookup()
115 ->getRevisionByTitle( $page, 0, IDBAccessObject::READ_LATEST );
116 $revid = $revision ? $revision->getId() : 0;
118 $cache = $services->getMainWANObjectCache();
120 $cache->delete( $key );
130 'mediawiki.interface.helpers.styles',
131 'mediawiki.action.styles',
140 $revRecord = $this->
getArticle()->fetchRevisionRecord();
143 return $this->
msg(
'missing-revision', $oldid )->parse();
144 } elseif ( !$revRecord->isCurrent() ) {
145 return $this->
msg(
'pageinfo-not-current' )->plain();
150 $pageInfo = $this->pageInfo();
157 foreach ( $pageInfo as $header => $infoTable ) {
161 $this->addTocSection(
id:
"mw-pageinfo-$header",
msg:
"pageinfo-$header" );
162 $content .= $this->makeHeader(
163 $this->
msg(
"pageinfo-$header" )->text(),
164 "mw-pageinfo-$header"
168 foreach ( $infoTable as $infoRow ) {
169 if ( $infoRow[0] ==
"below" ) {
170 $below = $infoRow[1] .
"\n";
173 $name = ( $infoRow[0] instanceof
Message ) ? $infoRow[0]->escaped() : $infoRow[0];
174 $value = ( $infoRow[1] instanceof
Message ) ? $infoRow[1]->escaped() : $infoRow[1];
175 $id = ( $infoRow[0] instanceof
Message ) ? $infoRow[0]->getKey() :
null;
176 $rows .= $this->getRow( $name, $value, $id ) .
"\n";
178 if ( $rows !==
'' ) {
179 $content .= Html::rawElement(
'table', [
'class' =>
'wikitable mw-page-info' ],
182 $content .=
"\n" . $below;
186 $message = $this->
msg(
'pageinfo-footer' );
187 if ( !$message->isDisabled() ) {
190 $parserOutput = $this->messageParser->parse(
195 $message->getLanguage()
197 $content .= $parserOutput->getContentHolderText();
199 if ( $parserOutput->getTOCData() ) {
200 foreach ( $parserOutput->getTOCData()->getSections() as $s ) {
201 $this->addTocSection( $s->anchor,
'rawmessage', $s->line );
207 $msg = $this->
msg(
'pageinfo-header' );
208 if ( !$msg->isDisabled() ) {
209 $this->
getOutput()->addHTML( $msg->parseAsBlock() );
214 $this->
getOutput()->addTOCPlaceholder( $this->getTocData() );
226 private function makeHeader( $header, $canonicalId ) {
227 return Html::rawElement(
229 [
'id' => Sanitizer::escapeIdForAttribute( $header ) ],
232 [
'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ],
235 htmlspecialchars( $header )
246 private function getRow( $name, $value, $id ) {
247 return Html::rawElement(
250 'id' => $id ===
null ?
null :
'mw-' . $id,
251 'style' =>
'vertical-align: top;',
253 Html::rawElement(
'td', [], $name ) .
254 Html::rawElement(
'td', [], $value )
271 private function pageInfo() {
275 $id = $title->getArticleID();
276 $config = $this->context->getConfig();
277 $linkRenderer = $this->linkRenderer;
279 $pageCounts = $this->pageCounts();
281 $pageProperties = $this->pageProps->getAllProperties( $title )[$id] ?? [];
284 $pageInfo = [
'header-basic' => [] ];
287 $displayTitle = $pageProperties[
'displaytitle'] ??
288 htmlspecialchars( $title->getPrefixedText(), ENT_NOQUOTES );
290 $pageInfo[
'header-basic'][] = [
291 $this->
msg(
'pageinfo-display-title' ),
296 $redirectTarget = $this->redirectLookup->getRedirectTarget( $this->
getWikiPage() );
297 if ( $redirectTarget !==
null ) {
298 $pageInfo[
'header-basic'][] = [
299 $this->
msg(
'pageinfo-redirectsto' ),
300 $linkRenderer->makeLink( $redirectTarget ) .
301 $this->
msg(
'word-separator' )->escaped() .
302 $this->
msg(
'parentheses' )->rawParams( $linkRenderer->makeLink(
304 $this->msg(
'pageinfo-redirectsto-info' )->text(),
306 [
'action' =>
'info' ]
312 $sortKey = $pageProperties[
'defaultsort'] ?? $title->getCategorySortkey();
313 $pageInfo[
'header-basic'][] = [
314 $this->
msg(
'pageinfo-default-sort' ),
315 htmlspecialchars( $sortKey )
319 $pageInfo[
'header-basic'][] = [
320 $this->
msg(
'pageinfo-length' ),
321 $lang->formatNum( $title->getLength() )
325 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-namespace-id' ), $title->getNamespace() ];
326 $pageNamespace = $title->getNsText();
327 if ( $pageNamespace ) {
328 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-namespace' ), $pageNamespace ];
332 $pageInfo[
'header-basic'][] = [ $this->
msg(
'pageinfo-article-id' ), $id ];
335 $pageLang = $title->getPageLanguage()->getCode();
337 $pageLangHtml = $pageLang .
' - ' .
338 $this->languageNameUtils->getLanguageName( $pageLang, $lang->getCode() );
341 && $this->getAuthority()->probablyCan(
'pagelang', $title )
343 $pageLangHtml .= $this->
msg(
'word-separator' )->escaped();
344 $pageLangHtml .= $this->
msg(
'parentheses' )->rawParams( $linkRenderer->makeLink(
346 $this->msg(
'pageinfo-language-change' )->text()
350 $pageInfo[
'header-basic'][] = [
351 $this->
msg(
'pageinfo-language' )->escaped(),
358 $perm = $title->exists() ?
'editcontentmodel' :
'createwithcontentmodel';
359 if ( $this->
getAuthority()->probablyCan( $perm, $title ) ) {
360 $modelHtml .= $this->
msg(
'word-separator' )->escaped();
361 $modelHtml .= $this->
msg(
'parentheses' )->rawParams( $linkRenderer->makeLink(
363 $this->msg(
'pageinfo-content-model-change' )->text()
367 $pageInfo[
'header-basic'][] = [
368 $this->
msg(
'pageinfo-content-model' ),
372 if ( $title->inNamespace(
NS_USER ) ) {
373 $pageUser = $this->userFactory->newFromName( $title->getRootText() );
374 if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) {
375 $pageInfo[
'header-basic'][] = [
376 $this->
msg(
'pageinfo-user-id' ),
383 $parserOutput =
new ParserOutput();
384 if ( isset( $pageProperties[
'noindex'] ) ) {
385 $parserOutput->setIndexPolicy(
'noindex' );
387 if ( isset( $pageProperties[
'index'] ) ) {
388 $parserOutput->setIndexPolicy(
'index' );
392 $policy = $this->
getArticle()->getRobotPolicy(
'view', $parserOutput );
393 $pageInfo[
'header-basic'][] = [
395 $this->
msg(
'pageinfo-robot-policy' ),
396 $this->
msg(
"pageinfo-robot-{$policy['index']}" )
400 if ( $this->
getAuthority()->isAllowed(
'unwatchedpages' ) ||
401 ( $unwatchedPageThreshold !==
false &&
402 $pageCounts[
'watchers'] >= $unwatchedPageThreshold )
405 $pageInfo[
'header-basic'][] = [
406 $this->
msg(
'pageinfo-watchers' ),
407 $lang->formatNum( $pageCounts[
'watchers'] )
410 $visiting = $pageCounts[
'visitingWatchers'] ??
null;
413 $this->getAuthority()->isAllowed(
'unwatchedpages' )
415 $value = $lang->formatNum( $visiting );
417 $value = $this->
msg(
'pageinfo-few-visiting-watchers' );
419 $pageInfo[
'header-basic'][] = [
420 $this->
msg(
'pageinfo-visiting-watchers' )
425 } elseif ( $unwatchedPageThreshold !==
false ) {
426 $pageInfo[
'header-basic'][] = [
427 $this->
msg(
'pageinfo-watchers' ),
428 $this->
msg(
'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
434 $pageInfo[
'header-basic'][] = [
435 $linkRenderer->makeLink(
437 $this->
msg(
'pageinfo-redirects-name' )->text(),
442 'hideimages' => $title->getNamespace() ===
NS_FILE
445 $this->
msg(
'pageinfo-redirects-value' )
446 ->numParams( count( $title->getRedirectsHere() ) )
451 $pageInfo[
'header-basic'][] = [
452 $this->
msg(
'pageinfo-contentpage' ),
453 $this->
msg(
'pageinfo-contentpage-yes' )
458 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
461 $title->getPrefixedText() .
'/'
463 $pageInfo[
'header-basic'][] = [
464 $linkRenderer->makeLink(
466 $this->
msg(
'pageinfo-subpages-name' )->text()
469 isset( $pageCounts[
'subpages'] )
470 ? $this->
msg(
'pageinfo-subpages-value' )->numParams(
471 $pageCounts[
'subpages'][
'total'],
472 $pageCounts[
'subpages'][
'redirects'],
473 $pageCounts[
'subpages'][
'nonredirects']
474 ) : $this->
msg(
'pageinfo-subpages-value-unknown' )->rawParams(
475 $linkRenderer->makeKnownLink(
476 $title, $this->msg(
'purge' )->text(), [], [
'action' =>
'purge' ] )
484 $allCount = $category->getMemberCount();
485 $subcatCount = $category->getSubcatCount();
486 $fileCount = $category->getFileCount();
489 $pageInfo[
'category-info'] = [
491 $this->
msg(
'pageinfo-category-total' ),
492 $lang->formatNum( $allCount )
495 $this->
msg(
'pageinfo-category-pages' ),
496 $lang->formatNum( $pageCount )
499 $this->
msg(
'pageinfo-category-subcats' ),
500 $lang->formatNum( $subcatCount )
503 $this->
msg(
'pageinfo-category-files' ),
504 $lang->formatNum( $fileCount )
510 if ( $title->inNamespace(
NS_FILE ) ) {
511 $fileObj = $this->repoGroup->findFile( $title );
512 if ( $fileObj !==
false ) {
514 $output = \Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 );
515 $pageInfo[
'header-basic'][] = [
516 $this->
msg(
'pageinfo-file-hash' ),
523 $pageInfo[
'header-restrictions'] = [];
526 if ( $this->restrictionStore->isCascadeProtected( $title ) ) {
528 $sources = $this->restrictionStore->getCascadeProtectionSources( $title )[0];
530 foreach ( $sources as $sourcePageIdentity ) {
531 $cascadingFrom .= Html::rawElement(
534 $linkRenderer->makeKnownLink( $sourcePageIdentity )
538 $cascadingFrom = Html::rawElement(
'ul', [], $cascadingFrom );
539 $pageInfo[
'header-restrictions'][] = [
540 $this->
msg(
'pageinfo-protect-cascading-from' ),
546 if ( $this->restrictionStore->areRestrictionsCascading( $title ) ) {
547 $pageInfo[
'header-restrictions'][] = [
548 $this->
msg(
'pageinfo-protect-cascading' ),
549 $this->
msg(
'pageinfo-protect-cascading-yes' )
554 foreach ( $this->restrictionStore->listApplicableRestrictionTypes( $title ) as $restrictionType ) {
555 $protections = $this->restrictionStore->getRestrictions( $title, $restrictionType );
557 switch ( count( $protections ) ) {
559 $message = $this->getNamespaceProtectionMessage( $title ) ??
561 $this->
msg(
'protect-default' )->escaped();
566 $message = $this->
msg(
'protect-level-' . $protections[0] );
567 if ( !$message->isDisabled() ) {
568 $message = $message->escaped();
575 $message = $this->
msg(
"protect-fallback", $lang->commaList( $protections ) )->parse();
578 $expiry = $this->restrictionStore->getRestrictionExpiry( $title, $restrictionType );
579 $formattedexpiry = $expiry ===
null ?
'' : $this->
msg(
581 $lang->formatExpiry( $expiry,
true,
'infinity', $user )
583 $message .= $this->
msg(
'word-separator' )->escaped() . $formattedexpiry;
587 $pageInfo[
'header-restrictions'][] = [
588 $this->
msg(
"restriction-$restrictionType" ), $message
592 $pageInfo[
'header-restrictions'][] = [
594 $linkRenderer->makeKnownLink(
596 $this->
msg(
'pageinfo-view-protect-log' )->text(),
598 [
'type' =>
'protect',
'page' => $title->getPrefixedText() ]
607 $pageInfo[
'header-edits'] = [];
609 $firstRev = $this->revisionLookup->getFirstRevision( $this->
getTitle() );
610 $lastRev = $this->
getWikiPage()->getRevisionRecord();
611 $batch = $this->linkBatchFactory->newLinkBatch()
612 ->setCaller( __METHOD__ );
614 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
615 if ( $firstRevUser ) {
616 $batch->addUser( $firstRevUser );
621 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
622 if ( $lastRevUser ) {
623 $batch->addUser( $lastRevUser );
631 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
635 $firstRevUserName = $firstRevUser ? $firstRevUser->getName() :
'[HIDDEN]';
636 $pageInfo[
'header-edits'][] = [
637 $this->
msg(
'pageinfo-firstuser', $firstRevUserName ),
638 Linker::revUserTools( $firstRev )
642 $pageInfo[
'header-edits'][] = [
643 $this->
msg(
'pageinfo-firsttime' ),
644 $linkRenderer->makeKnownLink(
646 $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
648 [
'oldid' => $firstRev->getId() ]
655 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
659 $lastRevUserName = $lastRevUser ? $lastRevUser->getName() :
'[HIDDEN]';
660 $pageInfo[
'header-edits'][] = [
661 $this->
msg(
'pageinfo-lastuser', $lastRevUserName ),
662 Linker::revUserTools( $lastRev )
666 $pageInfo[
'header-edits'][] = [
667 $this->
msg(
'pageinfo-lasttime' ),
668 $linkRenderer->makeKnownLink(
670 $lang->userTimeAndDate( $this->getWikiPage()->getTimestamp(), $user ),
672 [
'oldid' => $this->getWikiPage()->getLatest() ]
678 $pageInfo[
'header-edits'][] = [
679 $this->
msg(
'pageinfo-edits' ),
680 $lang->formatNum( $pageCounts[
'edits'] )
684 if ( $pageCounts[
'authors'] > 0 ) {
685 $pageInfo[
'header-edits'][] = [
686 $this->
msg(
'pageinfo-authors' ),
687 $lang->formatNum( $pageCounts[
'authors'] )
692 $pageInfo[
'header-edits'][] = [
694 'pageinfo-recent-edits',
697 $lang->formatNum( $pageCounts[
'recent_edits'] )
701 $pageInfo[
'header-edits'][] = [
702 $this->
msg(
'pageinfo-recent-authors' ),
703 $lang->formatNum( $pageCounts[
'recent_authors'] )
707 $wordIDs = $this->magicWordFactory->getDoubleUnderscoreArray()->getNames();
710 $localizedWords = $this->contentLanguage->getMagicWords();
713 foreach ( $pageProperties as $property => $value ) {
714 if ( in_array( $property, $wordIDs ) ) {
715 $listItems[] =
Html::element(
'li', [], $localizedWords[$property][1] );
719 $localizedList = Html::rawElement(
'ul', [], implode(
'', $listItems ) );
720 $hiddenCategories = $this->
getWikiPage()->getHiddenCategories();
723 count( $listItems ) > 0 ||
724 count( $hiddenCategories ) > 0 ||
725 $pageCounts[
'transclusion'][
'from'] > 0 ||
726 $pageCounts[
'transclusion'][
'to'] > 0
729 $transcludedTemplates = $title->getTemplateLinksFrom( $options );
731 $transcludedTargets = [];
733 $transcludedTargets = $title->getTemplateLinksTo( $options );
737 $pageInfo[
'header-properties'] = [];
740 if ( count( $listItems ) > 0 ) {
741 $pageInfo[
'header-properties'][] = [
742 $this->
msg(
'pageinfo-magic-words' )->numParams( count( $listItems ) ),
748 if ( count( $hiddenCategories ) > 0 ) {
749 $pageInfo[
'header-properties'][] = [
750 $this->
msg(
'pageinfo-hidden-categories' )
751 ->numParams( count( $hiddenCategories ) ),
752 Linker::formatHiddenCategories( $hiddenCategories )
757 if ( $pageCounts[
'transclusion'][
'from'] > 0 ) {
758 if ( $pageCounts[
'transclusion'][
'from'] > count( $transcludedTemplates ) ) {
759 $more = $this->
msg(
'morenotlisted' )->escaped();
764 $templateListFormatter =
new TemplatesOnThisPageFormatter(
767 $this->linkBatchFactory,
768 $this->restrictionStore
771 $pageInfo[
'header-properties'][] = [
772 $this->
msg(
'pageinfo-templates' )
773 ->numParams( $pageCounts[
'transclusion'][
'from'] ),
774 $templateListFormatter->format( $transcludedTemplates,
false, $more )
779 if ( $pageCounts[
'transclusion'][
'to'] > count( $transcludedTargets ) ) {
780 $more = $linkRenderer->makeLink(
782 $this->
msg(
'moredotdotdot' )->text(),
784 [
'hidelinks' => 1,
'hideredirs' => 1 ]
790 $templateListFormatter =
new TemplatesOnThisPageFormatter(
793 $this->linkBatchFactory,
794 $this->restrictionStore
797 $pageInfo[
'header-properties'][] = [
798 $this->
msg(
'pageinfo-transclusions' )
799 ->numParams( $pageCounts[
'transclusion'][
'to'] ),
800 $templateListFormatter->format( $transcludedTargets,
false, $more )
815 private function getNamespaceProtectionMessage( Title $title ): ?string {
817 if ( $title->isRawHtmlMessage() ) {
818 $rights[] =
'editsitecss';
819 $rights[] =
'editsitejs';
820 } elseif ( $title->isSiteCssConfigPage() ) {
821 $rights[] =
'editsitecss';
822 } elseif ( $title->isSiteJsConfigPage() ) {
823 $rights[] =
'editsitejs';
824 } elseif ( $title->isSiteJsonConfigPage() ) {
825 $rights[] =
'editsitejson';
826 } elseif ( $title->isUserCssConfigPage() ) {
827 $rights[] =
'editusercss';
828 } elseif ( $title->isUserJsConfigPage() ) {
829 $rights[] =
'edituserjs';
830 } elseif ( $title->isUserJsonConfigPage() ) {
831 $rights[] =
'edituserjson';
834 $right = $namespaceProtection[$title->getNamespace()] ??
null;
837 $rights = (array)$right;
841 return $this->
msg(
'protect-fallback', $this->
getLanguage()->commaList( $rights ) )->parse();
852 private function pageCounts() {
853 $page = $this->getWikiPage();
855 $config = $this->context->getConfig();
856 $cache = $this->wanObjectCache;
858 return $cache->getWithSetCallback(
859 self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ),
860 WANObjectCache::TTL_WEEK,
861 function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname ) {
862 $title = $page->getTitle();
863 $id = $title->getArticleID();
865 $dbr = $this->dbProvider->getReplicaDatabase();
868 $field =
'rev_actor';
869 $pageField =
'rev_page';
871 $watchedItemStore = $this->watchedItemStore;
874 $result[
'watchers'] = $watchedItemStore->
countWatchers( $title );
877 $updated = (int)
wfTimestamp( TS::UNIX, $page->getTimestamp() );
885 $edits = (int)$dbr->newSelectQueryBuilder()
886 ->select(
'COUNT(*)' )
888 ->where( [
'rev_page' => $id ] )
891 $result[
'edits'] = $edits;
895 $result[
'authors'] = 0;
897 $result[
'authors'] = (int)$dbr->newSelectQueryBuilder()
898 ->select(
"COUNT(DISTINCT $field)" )
900 ->where( [ $pageField => $id ] )
909 $edits = (int)$dbr->newSelectQueryBuilder()
910 ->select(
'COUNT(rev_page)' )
912 ->where( [
'rev_page' => $id ] )
913 ->andWhere( $dbr->expr(
'rev_timestamp',
'>=', $threshold ) )
916 $result[
'recent_edits'] = $edits;
919 $result[
'recent_authors'] = (int)$dbr->newSelectQueryBuilder()
920 ->select(
"COUNT(DISTINCT $field)" )
922 ->where( [ $pageField => $id ] )
923 ->andWhere( [ $dbr->expr(
'rev_timestamp',
'>=', $threshold ) ] )
928 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
929 $conds = [
'page_namespace' => $title->getNamespace() ];
930 $conds[] = $dbr->expr(
933 new LikeValue( $title->getDBkey() .
'/', $dbr->anyString() )
937 $conds[
'page_is_redirect'] = 1;
938 $result[
'subpages'][
'redirects'] = (int)$dbr->newSelectQueryBuilder()
939 ->select(
'COUNT(page_id)' )
945 $conds[
'page_is_redirect'] = 0;
946 $result[
'subpages'][
'nonredirects'] = (int)$dbr->newSelectQueryBuilder()
947 ->select(
'COUNT(page_id)' )
954 $result[
'subpages'][
'total'] = $result[
'subpages'][
'redirects']
955 + $result[
'subpages'][
'nonredirects'];
961 $result[
'transclusion'][
'to'] = 0;
963 $result[
'transclusion'][
'to'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
964 ->select(
'COUNT(tl_from)' )
965 ->from(
'templatelinks' )
966 ->where( $this->linksMigration->getLinksConditions(
'templatelinks', $title ) )
971 $result[
'transclusion'][
'from'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
972 ->select(
'COUNT(*)' )
973 ->from(
'templatelinks' )
974 ->where( [
'tl_from' => $title->getArticleID() ] )
989 return $this->msg(
'pageinfo-title' )->plaintextParams( $this->getTitle()->getPrefixedText() );
1008 return $cache->
makeKey(
'infoaction', md5( (
string)$page ), $revId, self::VERSION );