MediaWiki master
InfoAction.php
Go to the documentation of this file.
1<?php
12namespace MediaWiki\Actions;
13
21use MediaWiki\Html\TocGeneratorTrait;
53use Wikimedia\Timestamp\TimestampFormat as TS;
54
61 use TocGeneratorTrait;
62
63 private const VERSION = 1;
64
65 public function __construct(
66 Article $article,
67 IContextSource $context,
68 private readonly Language $contentLanguage,
69 private readonly LanguageNameUtils $languageNameUtils,
70 private readonly LinkBatchFactory $linkBatchFactory,
71 private readonly LinkRenderer $linkRenderer,
72 private readonly IConnectionProvider $dbProvider,
73 private readonly MagicWordFactory $magicWordFactory,
74 private readonly MessageParser $messageParser,
75 private readonly NamespaceInfo $namespaceInfo,
76 private readonly PageProps $pageProps,
77 private readonly RepoGroup $repoGroup,
78 private readonly RevisionLookup $revisionLookup,
79 private readonly WANObjectCache $wanObjectCache,
80 private readonly WatchedItemStoreInterface $watchedItemStore,
81 private readonly RedirectLookup $redirectLookup,
82 private readonly RestrictionStore $restrictionStore,
83 private readonly LinksMigration $linksMigration,
84 private readonly UserFactory $userFactory,
85 ) {
86 parent::__construct( $article, $context );
87 }
88
90 public function getName() {
91 return 'info';
92 }
93
95 public function requiresUnblock() {
96 return false;
97 }
98
100 public function requiresWrite() {
101 return false;
102 }
103
111 public static function invalidateCache( PageIdentity $page, $revid = null ) {
112 $services = MediaWikiServices::getInstance();
113 if ( $revid === null ) {
114 $revision = $services->getRevisionLookup()
115 ->getRevisionByTitle( $page, 0, IDBAccessObject::READ_LATEST );
116 $revid = $revision ? $revision->getId() : 0;
117 }
118 $cache = $services->getMainWANObjectCache();
119 $key = self::getCacheKey( $cache, $page, $revid ?? 0 );
120 $cache->delete( $key );
121 }
122
128 public function onView() {
129 $this->getOutput()->addModuleStyles( [
130 'mediawiki.interface.helpers.styles',
131 'mediawiki.action.styles',
132 ] );
133
134 // "Help" button
135 $this->addHelpLink( 'Page information' );
136
137 // Validate revision
138 $oldid = $this->getArticle()->getOldID();
139 if ( $oldid ) {
140 $revRecord = $this->getArticle()->fetchRevisionRecord();
141
142 if ( !$revRecord ) {
143 return $this->msg( 'missing-revision', $oldid )->parse();
144 } elseif ( !$revRecord->isCurrent() ) {
145 return $this->msg( 'pageinfo-not-current' )->plain();
146 }
147 }
148
149 // Get page information
150 $pageInfo = $this->pageInfo();
151
152 // Allow extensions to add additional information
153 $this->getHookRunner()->onInfoAction( $this->getContext(), $pageInfo );
154
155 $content = '';
156 // Render page information
157 foreach ( $pageInfo as $header => $infoTable ) {
158 // Messages:
159 // pageinfo-header-basic, pageinfo-header-edits, pageinfo-header-restrictions,
160 // pageinfo-header-properties, pageinfo-category-info
161 $this->addTocSection( id: "mw-pageinfo-$header", msg: "pageinfo-$header" );
162 $content .= $this->makeHeader(
163 $this->msg( "pageinfo-$header" )->text(),
164 "mw-pageinfo-$header"
165 ) . "\n";
166 $rows = '';
167 $below = "";
168 foreach ( $infoTable as $infoRow ) {
169 if ( $infoRow[0] == "below" ) {
170 $below = $infoRow[1] . "\n";
171 continue;
172 }
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";
177 }
178 if ( $rows !== '' ) {
179 $content .= Html::rawElement( 'table', [ 'class' => 'wikitable mw-page-info' ],
180 "\n" . $rows );
181 }
182 $content .= "\n" . $below;
183 }
184
185 // Page footer
186 $message = $this->msg( 'pageinfo-footer' );
187 if ( !$message->isDisabled() ) {
188 // Parse the message like this in order to include custom headings in the TOC.
189 // In the future when T66969 is resolved, perhaps we can make this simpler.
190 $parserOutput = $this->messageParser->parse(
191 $message->plain(),
192 $this->getTitle(),
193 /*linestart*/ true,
194 /*interface*/ true,
195 $message->getLanguage()
196 );
197 $content .= $parserOutput->getContentHolderText();
198
199 if ( $parserOutput->getTOCData() ) {
200 foreach ( $parserOutput->getTOCData()->getSections() as $s ) {
201 $this->addTocSection( $s->anchor, 'rawmessage', $s->line );
202 }
203 }
204 }
205
206 // Insert page header above the TOC (on skins using the old TOC style)
207 $msg = $this->msg( 'pageinfo-header' );
208 if ( !$msg->isDisabled() ) {
209 $this->getOutput()->addHTML( $msg->parseAsBlock() );
210 }
211
212 // Add TOC (this must be done after the addTocSection() calls for compatibility
213 // with old TOC style, e.g. on Minerva or Monobook).
214 $this->getOutput()->addTOCPlaceholder( $this->getTocData() );
215
216 return $content;
217 }
218
226 private function makeHeader( $header, $canonicalId ) {
227 return Html::rawElement(
228 'h2',
229 [ 'id' => Sanitizer::escapeIdForAttribute( $header ) ],
231 'span',
232 [ 'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ],
233 ''
234 ) .
235 htmlspecialchars( $header )
236 );
237 }
238
246 private function getRow( $name, $value, $id ) {
247 return Html::rawElement(
248 'tr',
249 [
250 'id' => $id === null ? null : 'mw-' . $id,
251 'style' => 'vertical-align: top;',
252 ],
253 Html::rawElement( 'td', [], $name ) .
254 Html::rawElement( 'td', [], $value )
255 );
256 }
257
271 private function pageInfo() {
272 $user = $this->getUser();
273 $lang = $this->getLanguage();
274 $title = $this->getTitle();
275 $id = $title->getArticleID();
276 $config = $this->context->getConfig();
277 $linkRenderer = $this->linkRenderer;
278
279 $pageCounts = $this->pageCounts();
280
281 $pageProperties = $this->pageProps->getAllProperties( $title )[$id] ?? [];
282
283 // Basic information
284 $pageInfo = [ 'header-basic' => [] ];
285
286 // Display title
287 $displayTitle = $pageProperties['displaytitle'] ??
288 htmlspecialchars( $title->getPrefixedText(), ENT_NOQUOTES );
289
290 $pageInfo['header-basic'][] = [
291 $this->msg( 'pageinfo-display-title' ),
292 $displayTitle
293 ];
294
295 // Is it a redirect? If so, where to?
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(
303 $redirectTarget,
304 $this->msg( 'pageinfo-redirectsto-info' )->text(),
305 [],
306 [ 'action' => 'info' ]
307 ) )->escaped()
308 ];
309 }
310
311 // Default sort key
312 $sortKey = $pageProperties['defaultsort'] ?? $title->getCategorySortkey();
313 $pageInfo['header-basic'][] = [
314 $this->msg( 'pageinfo-default-sort' ),
315 htmlspecialchars( $sortKey )
316 ];
317
318 // Page length (in bytes)
319 $pageInfo['header-basic'][] = [
320 $this->msg( 'pageinfo-length' ),
321 $lang->formatNum( $title->getLength() )
322 ];
323
324 // Page namespace
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 ];
329 }
330
331 // Page ID (number not localised, as it's a database ID)
332 $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-article-id' ), $id ];
333
334 // Language in which the page content is (supposed to be) written
335 $pageLang = $title->getPageLanguage()->getCode();
336
337 $pageLangHtml = $pageLang . ' - ' .
338 $this->languageNameUtils->getLanguageName( $pageLang, $lang->getCode() );
339 // Link to Special:PageLanguage with pre-filled page title if user has permissions
340 if ( $config->get( MainConfigNames::PageLanguageUseDB )
341 && $this->getAuthority()->probablyCan( 'pagelang', $title )
342 ) {
343 $pageLangHtml .= $this->msg( 'word-separator' )->escaped();
344 $pageLangHtml .= $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
345 SpecialPage::getTitleValueFor( 'PageLanguage', $title->getPrefixedText() ),
346 $this->msg( 'pageinfo-language-change' )->text()
347 ) )->escaped();
348 }
349
350 $pageInfo['header-basic'][] = [
351 $this->msg( 'pageinfo-language' )->escaped(),
352 $pageLangHtml
353 ];
354
355 // Content model of the page
356 $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->getContentModel() ) );
357 // If the user can change it, add a link to Special:ChangeContentModel
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(
362 SpecialPage::getTitleValueFor( 'ChangeContentModel', $title->getPrefixedText() ),
363 $this->msg( 'pageinfo-content-model-change' )->text()
364 ) )->escaped();
365 }
366
367 $pageInfo['header-basic'][] = [
368 $this->msg( 'pageinfo-content-model' ),
369 $modelHtml
370 ];
371
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' ),
377 $pageUser->getId()
378 ];
379 }
380 }
381
382 // Search engine status
383 $parserOutput = new ParserOutput();
384 if ( isset( $pageProperties['noindex'] ) ) {
385 $parserOutput->setIndexPolicy( 'noindex' );
386 }
387 if ( isset( $pageProperties['index'] ) ) {
388 $parserOutput->setIndexPolicy( 'index' );
389 }
390
391 // Use robot policy logic
392 $policy = $this->getArticle()->getRobotPolicy( 'view', $parserOutput );
393 $pageInfo['header-basic'][] = [
394 // Messages: pageinfo-robot-index, pageinfo-robot-noindex
395 $this->msg( 'pageinfo-robot-policy' ),
396 $this->msg( "pageinfo-robot-{$policy['index']}" )
397 ];
398
399 $unwatchedPageThreshold = $config->get( MainConfigNames::UnwatchedPageThreshold );
400 if ( $this->getAuthority()->isAllowed( 'unwatchedpages' ) ||
401 ( $unwatchedPageThreshold !== false &&
402 $pageCounts['watchers'] >= $unwatchedPageThreshold )
403 ) {
404 // Number of page watchers
405 $pageInfo['header-basic'][] = [
406 $this->msg( 'pageinfo-watchers' ),
407 $lang->formatNum( $pageCounts['watchers'] )
408 ];
409
410 $visiting = $pageCounts['visitingWatchers'] ?? null;
411 if ( $visiting !== null && $config->get( MainConfigNames::ShowUpdatedMarker ) ) {
412 if ( $visiting > $config->get( MainConfigNames::UnwatchedPageSecret ) ||
413 $this->getAuthority()->isAllowed( 'unwatchedpages' )
414 ) {
415 $value = $lang->formatNum( $visiting );
416 } else {
417 $value = $this->msg( 'pageinfo-few-visiting-watchers' );
418 }
419 $pageInfo['header-basic'][] = [
420 $this->msg( 'pageinfo-visiting-watchers' )
421 ->numParams( ceil( $config->get( MainConfigNames::WatchersMaxAge ) / 86400 ) ),
422 $value
423 ];
424 }
425 } elseif ( $unwatchedPageThreshold !== false ) {
426 $pageInfo['header-basic'][] = [
427 $this->msg( 'pageinfo-watchers' ),
428 $this->msg( 'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
429 ];
430 }
431
432 // Redirects to this page
433 $whatLinksHere = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() );
434 $pageInfo['header-basic'][] = [
435 $linkRenderer->makeLink(
436 $whatLinksHere,
437 $this->msg( 'pageinfo-redirects-name' )->text(),
438 [],
439 [
440 'hidelinks' => 1,
441 'hidetrans' => 1,
442 'hideimages' => $title->getNamespace() === NS_FILE
443 ]
444 ),
445 $this->msg( 'pageinfo-redirects-value' )
446 ->numParams( count( $title->getRedirectsHere() ) )
447 ];
448
449 // Is it counted as a content page?
450 if ( $this->getWikiPage()->isCountable() ) {
451 $pageInfo['header-basic'][] = [
452 $this->msg( 'pageinfo-contentpage' ),
453 $this->msg( 'pageinfo-contentpage-yes' )
454 ];
455 }
456
457 // Subpages of this page, if subpages are enabled for the current NS
458 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
459 $prefixIndex = SpecialPage::getTitleFor(
460 'Prefixindex',
461 $title->getPrefixedText() . '/'
462 );
463 $pageInfo['header-basic'][] = [
464 $linkRenderer->makeLink(
465 $prefixIndex,
466 $this->msg( 'pageinfo-subpages-name' )->text()
467 ),
468 // $wgNamespacesWithSubpages can be changed and this can be unset (T340749)
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' ] )
477 )
478 ];
479 }
480
481 if ( $title->inNamespace( NS_CATEGORY ) ) {
482 $category = Category::newFromTitle( $title );
483
484 $allCount = $category->getMemberCount();
485 $subcatCount = $category->getSubcatCount();
486 $fileCount = $category->getFileCount();
487 $pageCount = $category->getPageCount( Category::COUNT_CONTENT_PAGES );
488
489 $pageInfo['category-info'] = [
490 [
491 $this->msg( 'pageinfo-category-total' ),
492 $lang->formatNum( $allCount )
493 ],
494 [
495 $this->msg( 'pageinfo-category-pages' ),
496 $lang->formatNum( $pageCount )
497 ],
498 [
499 $this->msg( 'pageinfo-category-subcats' ),
500 $lang->formatNum( $subcatCount )
501 ],
502 [
503 $this->msg( 'pageinfo-category-files' ),
504 $lang->formatNum( $fileCount )
505 ]
506 ];
507 }
508
509 // Display image SHA-1 value
510 if ( $title->inNamespace( NS_FILE ) ) {
511 $fileObj = $this->repoGroup->findFile( $title );
512 if ( $fileObj !== false ) {
513 // Convert the base-36 sha1 value obtained from database to base-16
514 $output = \Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 );
515 $pageInfo['header-basic'][] = [
516 $this->msg( 'pageinfo-file-hash' ),
517 $output
518 ];
519 }
520 }
521
522 // Page protection
523 $pageInfo['header-restrictions'] = [];
524
525 // Is this page affected by the cascading protection of something which includes it?
526 if ( $this->restrictionStore->isCascadeProtected( $title ) ) {
527 $cascadingFrom = '';
528 $sources = $this->restrictionStore->getCascadeProtectionSources( $title )[0];
529
530 foreach ( $sources as $sourcePageIdentity ) {
531 $cascadingFrom .= Html::rawElement(
532 'li',
533 [],
534 $linkRenderer->makeKnownLink( $sourcePageIdentity )
535 );
536 }
537
538 $cascadingFrom = Html::rawElement( 'ul', [], $cascadingFrom );
539 $pageInfo['header-restrictions'][] = [
540 $this->msg( 'pageinfo-protect-cascading-from' ),
541 $cascadingFrom
542 ];
543 }
544
545 // Is out protection set to cascade to other pages?
546 if ( $this->restrictionStore->areRestrictionsCascading( $title ) ) {
547 $pageInfo['header-restrictions'][] = [
548 $this->msg( 'pageinfo-protect-cascading' ),
549 $this->msg( 'pageinfo-protect-cascading-yes' )
550 ];
551 }
552
553 // Page protection
554 foreach ( $this->restrictionStore->listApplicableRestrictionTypes( $title ) as $restrictionType ) {
555 $protections = $this->restrictionStore->getRestrictions( $title, $restrictionType );
556
557 switch ( count( $protections ) ) {
558 case 0:
559 $message = $this->getNamespaceProtectionMessage( $title ) ??
560 // Allow all users by default
561 $this->msg( 'protect-default' )->escaped();
562 break;
563
564 case 1:
565 // Messages: protect-level-autoconfirmed, protect-level-sysop
566 $message = $this->msg( 'protect-level-' . $protections[0] );
567 if ( !$message->isDisabled() ) {
568 $message = $message->escaped();
569 break;
570 }
571 // Intentional fall-through if message is disabled (or non-existent)
572
573 default:
574 // Require "$1" permission
575 $message = $this->msg( "protect-fallback", $lang->commaList( $protections ) )->parse();
576 break;
577 }
578 $expiry = $this->restrictionStore->getRestrictionExpiry( $title, $restrictionType );
579 $formattedexpiry = $expiry === null ? '' : $this->msg(
580 'parentheses',
581 $lang->formatExpiry( $expiry, true, 'infinity', $user )
582 )->escaped();
583 $message .= $this->msg( 'word-separator' )->escaped() . $formattedexpiry;
584
585 // Messages: restriction-edit, restriction-move, restriction-create,
586 // restriction-upload
587 $pageInfo['header-restrictions'][] = [
588 $this->msg( "restriction-$restrictionType" ), $message
589 ];
590 }
591 $protectLog = SpecialPage::getTitleFor( 'Log' );
592 $pageInfo['header-restrictions'][] = [
593 'below',
594 $linkRenderer->makeKnownLink(
595 $protectLog,
596 $this->msg( 'pageinfo-view-protect-log' )->text(),
597 [],
598 [ 'type' => 'protect', 'page' => $title->getPrefixedText() ]
599 ),
600 ];
601
602 if ( !$this->getWikiPage()->exists() ) {
603 return $pageInfo;
604 }
605
606 // Edit history
607 $pageInfo['header-edits'] = [];
608
609 $firstRev = $this->revisionLookup->getFirstRevision( $this->getTitle() );
610 $lastRev = $this->getWikiPage()->getRevisionRecord();
611 $batch = $this->linkBatchFactory->newLinkBatch()
612 ->setCaller( __METHOD__ );
613 if ( $firstRev ) {
614 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
615 if ( $firstRevUser ) {
616 $batch->addUser( $firstRevUser );
617 }
618 }
619
620 if ( $lastRev ) {
621 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
622 if ( $lastRevUser ) {
623 $batch->addUser( $lastRevUser );
624 }
625 }
626
627 $batch->execute();
628
629 if ( $firstRev ) {
630 // Page creator
631 $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
632 // Check if the username is available – it may have been suppressed, in
633 // which case use the invalid user name '[HIDDEN]' to get the wiki's
634 // default user gender.
635 $firstRevUserName = $firstRevUser ? $firstRevUser->getName() : '[HIDDEN]';
636 $pageInfo['header-edits'][] = [
637 $this->msg( 'pageinfo-firstuser', $firstRevUserName ),
638 Linker::revUserTools( $firstRev )
639 ];
640
641 // Date of page creation
642 $pageInfo['header-edits'][] = [
643 $this->msg( 'pageinfo-firsttime' ),
644 $linkRenderer->makeKnownLink(
645 $title,
646 $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
647 [],
648 [ 'oldid' => $firstRev->getId() ]
649 )
650 ];
651 }
652
653 if ( $lastRev ) {
654 // Latest editor
655 $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
656 // Check if the username is available – it may have been suppressed, in
657 // which case use the invalid user name '[HIDDEN]' to get the wiki's
658 // default user gender.
659 $lastRevUserName = $lastRevUser ? $lastRevUser->getName() : '[HIDDEN]';
660 $pageInfo['header-edits'][] = [
661 $this->msg( 'pageinfo-lastuser', $lastRevUserName ),
662 Linker::revUserTools( $lastRev )
663 ];
664
665 // Date of latest edit
666 $pageInfo['header-edits'][] = [
667 $this->msg( 'pageinfo-lasttime' ),
668 $linkRenderer->makeKnownLink(
669 $title,
670 $lang->userTimeAndDate( $this->getWikiPage()->getTimestamp(), $user ),
671 [],
672 [ 'oldid' => $this->getWikiPage()->getLatest() ]
673 )
674 ];
675 }
676
677 // Total number of edits
678 $pageInfo['header-edits'][] = [
679 $this->msg( 'pageinfo-edits' ),
680 $lang->formatNum( $pageCounts['edits'] )
681 ];
682
683 // Total number of distinct authors
684 if ( $pageCounts['authors'] > 0 ) {
685 $pageInfo['header-edits'][] = [
686 $this->msg( 'pageinfo-authors' ),
687 $lang->formatNum( $pageCounts['authors'] )
688 ];
689 }
690
691 // Recent number of edits (within past 30 days)
692 $pageInfo['header-edits'][] = [
693 $this->msg(
694 'pageinfo-recent-edits',
695 $lang->formatDuration( $config->get( MainConfigNames::RCMaxAge ) )
696 ),
697 $lang->formatNum( $pageCounts['recent_edits'] )
698 ];
699
700 // Recent number of distinct authors
701 $pageInfo['header-edits'][] = [
702 $this->msg( 'pageinfo-recent-authors' ),
703 $lang->formatNum( $pageCounts['recent_authors'] )
704 ];
705
706 // Array of magic word IDs
707 $wordIDs = $this->magicWordFactory->getDoubleUnderscoreArray()->getNames();
708
709 // Array of IDs => localized magic words
710 $localizedWords = $this->contentLanguage->getMagicWords();
711
712 $listItems = [];
713 foreach ( $pageProperties as $property => $value ) {
714 if ( in_array( $property, $wordIDs ) ) {
715 $listItems[] = Html::element( 'li', [], $localizedWords[$property][1] );
716 }
717 }
718
719 $localizedList = Html::rawElement( 'ul', [], implode( '', $listItems ) );
720 $hiddenCategories = $this->getWikiPage()->getHiddenCategories();
721
722 if (
723 count( $listItems ) > 0 ||
724 count( $hiddenCategories ) > 0 ||
725 $pageCounts['transclusion']['from'] > 0 ||
726 $pageCounts['transclusion']['to'] > 0
727 ) {
728 $options = [ 'LIMIT' => $config->get( MainConfigNames::PageInfoTransclusionLimit ) ];
729 $transcludedTemplates = $title->getTemplateLinksFrom( $options );
730 if ( $config->get( MainConfigNames::MiserMode ) ) {
731 $transcludedTargets = [];
732 } else {
733 $transcludedTargets = $title->getTemplateLinksTo( $options );
734 }
735
736 // Page properties
737 $pageInfo['header-properties'] = [];
738
739 // Magic words
740 if ( count( $listItems ) > 0 ) {
741 $pageInfo['header-properties'][] = [
742 $this->msg( 'pageinfo-magic-words' )->numParams( count( $listItems ) ),
743 $localizedList
744 ];
745 }
746
747 // Hidden categories
748 if ( count( $hiddenCategories ) > 0 ) {
749 $pageInfo['header-properties'][] = [
750 $this->msg( 'pageinfo-hidden-categories' )
751 ->numParams( count( $hiddenCategories ) ),
752 Linker::formatHiddenCategories( $hiddenCategories )
753 ];
754 }
755
756 // Transcluded templates
757 if ( $pageCounts['transclusion']['from'] > 0 ) {
758 if ( $pageCounts['transclusion']['from'] > count( $transcludedTemplates ) ) {
759 $more = $this->msg( 'morenotlisted' )->escaped();
760 } else {
761 $more = null;
762 }
763
764 $templateListFormatter = new TemplatesOnThisPageFormatter(
765 $this->getContext(),
766 $linkRenderer,
767 $this->linkBatchFactory,
768 $this->restrictionStore
769 );
770
771 $pageInfo['header-properties'][] = [
772 $this->msg( 'pageinfo-templates' )
773 ->numParams( $pageCounts['transclusion']['from'] ),
774 $templateListFormatter->format( $transcludedTemplates, false, $more )
775 ];
776 }
777
778 if ( !$config->get( MainConfigNames::MiserMode ) && $pageCounts['transclusion']['to'] > 0 ) {
779 if ( $pageCounts['transclusion']['to'] > count( $transcludedTargets ) ) {
780 $more = $linkRenderer->makeLink(
781 $whatLinksHere,
782 $this->msg( 'moredotdotdot' )->text(),
783 [],
784 [ 'hidelinks' => 1, 'hideredirs' => 1 ]
785 );
786 } else {
787 $more = null;
788 }
789
790 $templateListFormatter = new TemplatesOnThisPageFormatter(
791 $this->getContext(),
792 $linkRenderer,
793 $this->linkBatchFactory,
794 $this->restrictionStore
795 );
796
797 $pageInfo['header-properties'][] = [
798 $this->msg( 'pageinfo-transclusions' )
799 ->numParams( $pageCounts['transclusion']['to'] ),
800 $templateListFormatter->format( $transcludedTargets, false, $more )
801 ];
802 }
803 }
804
805 return $pageInfo;
806 }
807
815 private function getNamespaceProtectionMessage( Title $title ): ?string {
816 $rights = [];
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';
832 } else {
833 $namespaceProtection = $this->context->getConfig()->get( MainConfigNames::NamespaceProtection );
834 $right = $namespaceProtection[$title->getNamespace()] ?? null;
835 if ( $right ) {
836 // a single string as the value is allowed as well as an array
837 $rights = (array)$right;
838 }
839 }
840 if ( $rights ) {
841 return $this->msg( 'protect-fallback', $this->getLanguage()->commaList( $rights ) )->parse();
842 } else {
843 return null;
844 }
845 }
846
852 private function pageCounts() {
853 $page = $this->getWikiPage();
854 $fname = __METHOD__;
855 $config = $this->context->getConfig();
856 $cache = $this->wanObjectCache;
857
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();
864
865 $dbr = $this->dbProvider->getReplicaDatabase();
866 $setOpts += Database::getCacheSetOptions( $dbr );
867
868 $field = 'rev_actor';
869 $pageField = 'rev_page';
870
871 $watchedItemStore = $this->watchedItemStore;
872
873 $result = [];
874 $result['watchers'] = $watchedItemStore->countWatchers( $title );
875
876 if ( $config->get( MainConfigNames::ShowUpdatedMarker ) ) {
877 $updated = (int)wfTimestamp( TS::UNIX, $page->getTimestamp() );
878 $result['visitingWatchers'] = $watchedItemStore->countVisitingWatchers(
879 $title,
880 $updated - $config->get( MainConfigNames::WatchersMaxAge )
881 );
882 }
883
884 // Total number of edits
885 $edits = (int)$dbr->newSelectQueryBuilder()
886 ->select( 'COUNT(*)' )
887 ->from( 'revision' )
888 ->where( [ 'rev_page' => $id ] )
889 ->caller( $fname )
890 ->fetchField();
891 $result['edits'] = $edits;
892
893 // Total number of distinct authors
894 if ( $config->get( MainConfigNames::MiserMode ) ) {
895 $result['authors'] = 0;
896 } else {
897 $result['authors'] = (int)$dbr->newSelectQueryBuilder()
898 ->select( "COUNT(DISTINCT $field)" )
899 ->from( 'revision' )
900 ->where( [ $pageField => $id ] )
901 ->caller( $fname )
902 ->fetchField();
903 }
904
905 // "Recent" threshold defined by RCMaxAge setting
906 $threshold = $dbr->timestamp( time() - $config->get( MainConfigNames::RCMaxAge ) );
907
908 // Recent number of edits
909 $edits = (int)$dbr->newSelectQueryBuilder()
910 ->select( 'COUNT(rev_page)' )
911 ->from( 'revision' )
912 ->where( [ 'rev_page' => $id ] )
913 ->andWhere( $dbr->expr( 'rev_timestamp', '>=', $threshold ) )
914 ->caller( $fname )
915 ->fetchField();
916 $result['recent_edits'] = $edits;
917
918 // Recent number of distinct authors
919 $result['recent_authors'] = (int)$dbr->newSelectQueryBuilder()
920 ->select( "COUNT(DISTINCT $field)" )
921 ->from( 'revision' )
922 ->where( [ $pageField => $id ] )
923 ->andWhere( [ $dbr->expr( 'rev_timestamp', '>=', $threshold ) ] )
924 ->caller( $fname )
925 ->fetchField();
926
927 // Subpages (if enabled)
928 if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
929 $conds = [ 'page_namespace' => $title->getNamespace() ];
930 $conds[] = $dbr->expr(
931 'page_title',
933 new LikeValue( $title->getDBkey() . '/', $dbr->anyString() )
934 );
935
936 // Subpages of this page (redirects)
937 $conds['page_is_redirect'] = 1;
938 $result['subpages']['redirects'] = (int)$dbr->newSelectQueryBuilder()
939 ->select( 'COUNT(page_id)' )
940 ->from( 'page' )
941 ->where( $conds )
942 ->caller( $fname )
943 ->fetchField();
944 // Subpages of this page (non-redirects)
945 $conds['page_is_redirect'] = 0;
946 $result['subpages']['nonredirects'] = (int)$dbr->newSelectQueryBuilder()
947 ->select( 'COUNT(page_id)' )
948 ->from( 'page' )
949 ->where( $conds )
950 ->caller( $fname )
951 ->fetchField();
952
953 // Subpages of this page (total)
954 $result['subpages']['total'] = $result['subpages']['redirects']
955 + $result['subpages']['nonredirects'];
956 }
957
958 $dbrTemplateLinks = $this->dbProvider->getReplicaDatabase( TemplateLinksTable::VIRTUAL_DOMAIN );
959 // Counts for the number of transclusion links (to/from)
960 if ( $config->get( MainConfigNames::MiserMode ) ) {
961 $result['transclusion']['to'] = 0;
962 } else {
963 $result['transclusion']['to'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
964 ->select( 'COUNT(tl_from)' )
965 ->from( 'templatelinks' )
966 ->where( $this->linksMigration->getLinksConditions( 'templatelinks', $title ) )
967 ->caller( $fname )
968 ->fetchField();
969 }
970
971 $result['transclusion']['from'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
972 ->select( 'COUNT(*)' )
973 ->from( 'templatelinks' )
974 ->where( [ 'tl_from' => $title->getArticleID() ] )
975 ->caller( $fname )
976 ->fetchField();
977
978 return $result;
979 }
980 );
981 }
982
988 protected function getPageTitle() {
989 return $this->msg( 'pageinfo-title' )->plaintextParams( $this->getTitle()->getPrefixedText() );
990 }
991
997 protected function getDescription() {
998 return '';
999 }
1000
1007 protected static function getCacheKey( WANObjectCache $cache, PageIdentity $page, $revId ) {
1008 return $cache->makeKey( 'infoaction', md5( (string)$page ), $revId, self::VERSION );
1009 }
1010}
1011
1013class_alias( InfoAction::class, 'InfoAction' );
const NS_USER
Definition Defines.php:53
const NS_FILE
Definition Defines.php:57
const NS_CATEGORY
Definition Defines.php:65
wfTimestamp( $outputtype=TS::UNIX, $ts=0)
Get a timestamp string in one of various formats.
getContext()
Get the IContextSource in use here.
Definition Action.php:103
getWikiPage()
Get a WikiPage object.
Definition Action.php:172
getUser()
Shortcut to get the User being used for this instance.
Definition Action.php:133
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
Definition Action.php:207
getTitle()
Shortcut to get the Title object from the page.
Definition Action.php:193
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
Definition Action.php:422
getLanguage()
Shortcut to get the user Language being used for this instance.
Definition Action.php:162
getArticle()
Get a Article object.
Definition Action.php:183
getOutput()
Get the OutputPage being used for this instance.
Definition Action.php:123
getAuthority()
Shortcut to get the Authority executing this instance.
Definition Action.php:143
An action which just does something, without showing a form first.
Displays information about a page.
onView()
Shows page information on GET request.
__construct(Article $article, IContextSource $context, private readonly Language $contentLanguage, private readonly LanguageNameUtils $languageNameUtils, private readonly LinkBatchFactory $linkBatchFactory, private readonly LinkRenderer $linkRenderer, private readonly IConnectionProvider $dbProvider, private readonly MagicWordFactory $magicWordFactory, private readonly MessageParser $messageParser, private readonly NamespaceInfo $namespaceInfo, private readonly PageProps $pageProps, private readonly RepoGroup $repoGroup, private readonly RevisionLookup $revisionLookup, private readonly WANObjectCache $wanObjectCache, private readonly WatchedItemStoreInterface $watchedItemStore, private readonly RedirectLookup $redirectLookup, private readonly RestrictionStore $restrictionStore, private readonly LinksMigration $linksMigration, private readonly UserFactory $userFactory,)
requiresUnblock()
Whether this action can still be executed by a blocked user.Implementations of this methods must alwa...
static invalidateCache(PageIdentity $page, $revid=null)
Clear the info cache for a given Title.
requiresWrite()
Indicates whether this action page write access to the wiki.Subclasses must override this method to r...
static getCacheKey(WANObjectCache $cache, PageIdentity $page, $revId)
getName()
Return the name of the action this object responds to.1.17string Lowercase name
getDescription()
Returns the description that goes below the "<h1>" tag.
getPageTitle()
Returns the name that goes in the "<h1>" page title.
Category objects are immutable, strictly speaking.
Definition Category.php:29
static newFromTitle(PageIdentity $page)
Factory function.
Definition Category.php:171
Base class for content handling.
static getLocalizedName( $name, ?Language $lang=null)
Returns the localized name for a given content model.
Handles formatting for the "templates used on this page" lists.
Prioritized list of file repositories.
Definition RepoGroup.php:30
This class is a collection of static functions that serve two purposes:
Definition Html.php:44
A service that provides utilities to do with language names and codes.
Base class for language-specific code.
Definition Language.php:65
Service for transformation of interface message text.
Class that generates HTML for internal links.
Some internal bits split of from Skin.php.
Definition Linker.php:47
Service for compat reading of links tables.
A class containing constants representing the names of configuration variables.
const UnwatchedPageSecret
Name constant for the UnwatchedPageSecret setting, for use with Config::get()
const RCMaxAge
Name constant for the RCMaxAge setting, for use with Config::get()
const NamespaceProtection
Name constant for the NamespaceProtection setting, for use with Config::get()
const WatchersMaxAge
Name constant for the WatchersMaxAge setting, for use with Config::get()
const PageInfoTransclusionLimit
Name constant for the PageInfoTransclusionLimit setting, for use with Config::get()
const UnwatchedPageThreshold
Name constant for the UnwatchedPageThreshold setting, for use with Config::get()
const MiserMode
Name constant for the MiserMode setting, for use with Config::get()
const ShowUpdatedMarker
Name constant for the ShowUpdatedMarker setting, for use with Config::get()
const PageLanguageUseDB
Name constant for the PageLanguageUseDB setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:144
Legacy class representing an editable page and handling UI for some page actions.
Definition Article.php:65
Factory for LinkBatch objects to batch query page metadata.
Gives access to properties of a page.
Definition PageProps.php:20
Store information about magic words, and create/cache MagicWord objects.
ParserOutput is a rendering of a Content object or a message.
HTML sanitizer for MediaWiki.
Definition Sanitizer.php:32
Page revision base class.
Parent class for all special pages.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
static getTitleValueFor( $name, $subpage=false, $fragment='')
Get a localised TitleValue object for a specified special page name.
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:69
Create User objects.
Multi-datacenter aware caching interface.
makeKey( $keygroup,... $components)
static getCacheSetOptions(?IReadableDatabase ... $dbs)
Merge the result of getSessionLagStatus() for several DBs using the most pessimistic values to estima...
Content of like value.
Definition LikeValue.php:14
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(PageReference $target, $threshold)
Number of page watchers who also visited a "recent" edit.
Provide primary and replica IDatabase connections.
Interface for database access objects.
element(SerializerNode $parent, SerializerNode $node, $contents)