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