MediaWiki  master
DifferenceEngine.php
Go to the documentation of this file.
1 <?php
43 
67 
69 
76  private const DIFF_VERSION = '1.12';
77 
84  protected $mOldid;
85 
92  protected $mNewid;
93 
104  private $mOldRevisionRecord;
105 
114  private $mNewRevisionRecord;
115 
121  protected $mOldPage;
122 
128  protected $mNewPage;
129 
134  private $mOldTags;
135 
140  private $mNewTags;
141 
147  private $mOldContent;
148 
154  private $mNewContent;
155 
157  protected $mDiffLang;
158 
160  private $mRevisionsIdsLoaded = false;
161 
163  protected $mRevisionsLoaded = false;
164 
166  protected $mTextLoaded = 0;
167 
176  protected $isContentOverridden = false;
177 
179  protected $mCacheHit = false;
180 
187  public $enableDebugComment = false;
188 
192  protected $mReducedLineNumbers = false;
193 
195  protected $mMarkPatrolledLink = null;
196 
198  protected $unhide = false;
199 
201  protected $mRefreshCache = false;
202 
204  protected $slotDiffRenderers = null;
205 
212  protected $isSlotDiffRenderer = false;
213 
218  private $slotDiffOptions = [];
219 
223  protected $linkRenderer;
224 
228  private $contentHandlerFactory;
229 
233  private $revisionStore;
234 
236  private $hookRunner;
237 
239  private $wikiPageFactory;
240 
242  private $userOptionsLookup;
243 
245  private $commentFormatter;
246 
248  private $revisionLoadErrors = [];
249 
260  public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
261  $refreshCache = false, $unhide = false
262  ) {
263  $this->deprecatePublicProperty( 'mOldid', '1.32', __CLASS__ );
264  $this->deprecatePublicProperty( 'mNewid', '1.32', __CLASS__ );
265  $this->deprecatePublicProperty( 'mOldPage', '1.32', __CLASS__ );
266  $this->deprecatePublicProperty( 'mNewPage', '1.32', __CLASS__ );
267  $this->deprecatePublicProperty( 'mOldContent', '1.32', __CLASS__ );
268  $this->deprecatePublicProperty( 'mNewContent', '1.32', __CLASS__ );
269  $this->deprecatePublicProperty( 'mRevisionsLoaded', '1.32', __CLASS__ );
270  $this->deprecatePublicProperty( 'mTextLoaded', '1.32', __CLASS__ );
271  $this->deprecatePublicProperty( 'mCacheHit', '1.32', __CLASS__ );
272 
273  if ( $context instanceof IContextSource ) {
274  $this->setContext( $context );
275  }
276 
277  wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'" );
278 
279  $this->mOldid = $old;
280  $this->mNewid = $new;
281  $this->mRefreshCache = $refreshCache;
282  $this->unhide = $unhide;
283 
284  $services = MediaWikiServices::getInstance();
285  $this->linkRenderer = $services->getLinkRenderer();
286  $this->contentHandlerFactory = $services->getContentHandlerFactory();
287  $this->revisionStore = $services->getRevisionStore();
288  $this->hookRunner = new HookRunner( $services->getHookContainer() );
289  $this->wikiPageFactory = $services->getWikiPageFactory();
290  $this->userOptionsLookup = $services->getUserOptionsLookup();
291  $this->commentFormatter = $services->getCommentFormatter();
292  }
293 
298  protected function getSlotDiffRenderers() {
299  if ( $this->isSlotDiffRenderer ) {
300  throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
301  }
302 
303  if ( $this->slotDiffRenderers === null ) {
304  if ( !$this->loadRevisionData() ) {
305  return [];
306  }
307 
308  $slotContents = $this->getSlotContents();
309  $this->slotDiffRenderers = array_map( function ( array $contents ) {
311  $content = $contents['new'] ?: $contents['old'];
312  return $content->getContentHandler()->getSlotDiffRenderer(
313  $this->getContext(),
314  $this->slotDiffOptions
315  );
316  }, $slotContents );
317  }
318 
320  }
321 
328  public function markAsSlotDiffRenderer() {
329  $this->isSlotDiffRenderer = true;
330  }
331 
337  protected function getSlotContents() {
338  if ( $this->isContentOverridden ) {
339  return [
340  SlotRecord::MAIN => [ 'old' => $this->mOldContent, 'new' => $this->mNewContent ]
341  ];
342  } elseif ( !$this->loadRevisionData() ) {
343  return [];
344  }
345 
346  $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
347  $oldSlots = $this->mOldRevisionRecord ?
348  $this->mOldRevisionRecord->getPrimarySlots()->getSlots() :
349  [];
350  // The order here will determine the visual order of the diff. The current logic is
351  // slots of the new revision first in natural order, then deleted ones. This is ad hoc
352  // and should not be relied on - in the future we may want the ordering to depend
353  // on the page type.
354  $roles = array_keys( array_merge( $newSlots, $oldSlots ) );
355 
356  $slots = [];
357  foreach ( $roles as $role ) {
358  $slots[$role] = [
359  'old' => $this->loadSingleSlot(
360  $oldSlots[$role] ?? null,
361  'old'
362  ),
363  'new' => $this->loadSingleSlot(
364  $newSlots[$role] ?? null,
365  'new'
366  )
367  ];
368  }
369  // move main slot to front
370  if ( isset( $slots[SlotRecord::MAIN] ) ) {
371  $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
372  }
373  return $slots;
374  }
375 
383  private function loadSingleSlot( ?SlotRecord $slot, string $which ) {
384  if ( !$slot ) {
385  return null;
386  }
387  try {
388  return $slot->getContent();
389  } catch ( BadRevisionException $e ) {
390  $this->addRevisionLoadError( $which );
391  return null;
392  }
393  }
394 
400  private function addRevisionLoadError( $which ) {
401  $this->revisionLoadErrors[] = $this->msg( $which === 'new'
402  ? 'difference-bad-new-revision' : 'difference-bad-old-revision'
403  );
404  }
405 
412  public function getRevisionLoadErrors() {
413  return $this->revisionLoadErrors;
414  }
415 
420  private function hasNewRevisionLoadError() {
421  foreach ( $this->revisionLoadErrors as $error ) {
422  if ( $error->getKey() === 'difference-bad-new-revision' ) {
423  return true;
424  }
425  }
426  return false;
427  }
428 
430  public function getTitle() {
431  // T202454 avoid errors when there is no title
432  return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
433  }
434 
441  public function setReducedLineNumbers( $value = true ) {
442  $this->mReducedLineNumbers = $value;
443  }
444 
450  public function getDiffLang() {
451  # Default language in which the diff text is written.
452  $this->mDiffLang ??= $this->getTitle()->getPageLanguage();
453 
454  return $this->mDiffLang;
455  }
456 
460  public function wasCacheHit() {
461  return $this->mCacheHit;
462  }
463 
471  public function getOldid() {
472  $this->loadRevisionIds();
473 
474  return $this->mOldid;
475  }
476 
483  public function getNewid() {
484  $this->loadRevisionIds();
485 
486  return $this->mNewid;
487  }
488 
495  public function getOldRevision() {
496  return $this->mOldRevisionRecord ?: null;
497  }
498 
504  public function getNewRevision() {
505  return $this->mNewRevisionRecord;
506  }
507 
516  public function deletedLink( $id ) {
517  if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
518  $dbr = wfGetDB( DB_REPLICA );
519  $arQuery = $this->revisionStore->getArchiveQueryInfo();
520  $row = $dbr->selectRow(
521  $arQuery['tables'],
522  array_merge( $arQuery['fields'], [ 'ar_namespace', 'ar_title' ] ),
523  [ 'ar_rev_id' => $id ],
524  __METHOD__,
525  [],
526  $arQuery['joins']
527  );
528  if ( $row ) {
529  $revRecord = $this->revisionStore->newRevisionFromArchiveRow( $row );
530  $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
531 
532  return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
533  'target' => $title->getPrefixedText(),
534  'timestamp' => $revRecord->getTimestamp()
535  ] );
536  }
537  }
538 
539  return false;
540  }
541 
549  public function deletedIdMarker( $id ) {
550  $link = $this->deletedLink( $id );
551  if ( $link ) {
552  return "[$link $id]";
553  } else {
554  return (string)$id;
555  }
556  }
557 
558  private function showMissingRevision() {
559  $out = $this->getOutput();
560 
561  $missing = [];
562  if ( $this->mOldid && ( !$this->mOldRevisionRecord || !$this->mOldContent ) ) {
563  $missing[] = $this->deletedIdMarker( $this->mOldid );
564  }
565  if ( !$this->mNewRevisionRecord || !$this->mNewContent ) {
566  $missing[] = $this->deletedIdMarker( $this->mNewid );
567  }
568 
569  $out->setPageTitle( $this->msg( 'errorpagetitle' ) );
570  $msg = $this->msg( 'difference-missing-revision' )
571  ->params( $this->getLanguage()->listToText( $missing ) )
572  ->numParams( count( $missing ) )
573  ->parseAsBlock();
574  $out->addHTML( $msg );
575  }
576 
582  public function hasDeletedRevision() {
583  $this->loadRevisionData();
584  return (
585  $this->mNewRevisionRecord &&
586  $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
587  ) ||
588  (
589  $this->mOldRevisionRecord &&
590  $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
591  );
592  }
593 
600  public function getPermissionErrors( Authority $performer ) {
601  $this->loadRevisionData();
602  $permStatus = PermissionStatus::newEmpty();
603  if ( $this->mNewPage ) {
604  $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
605  }
606  if ( $this->mOldPage ) {
607  $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
608  }
609  return $permStatus->toLegacyErrorArray();
610  }
611 
617  public function hasSuppressedRevision() {
618  return $this->hasDeletedRevision() && (
619  ( $this->mOldRevisionRecord &&
620  $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
621  ( $this->mNewRevisionRecord &&
622  $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
623  );
624  }
625 
637  public function isUserAllowedToSeeRevisions( Authority $performer ) {
638  $this->loadRevisionData();
639 
640  if ( $this->mOldRevisionRecord && !$this->mOldRevisionRecord->userCan(
641  RevisionRecord::DELETED_TEXT,
642  $performer
643  ) ) {
644  return false;
645  }
646 
647  // $this->mNewRev will only be falsy if a loading error occurred
648  // (in which case the user is allowed to see).
649  return !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
650  RevisionRecord::DELETED_TEXT,
651  $performer
652  );
653  }
654 
662  public function shouldBeHiddenFromUser( Authority $performer ) {
663  return $this->hasDeletedRevision() && ( !$this->unhide ||
664  !$this->isUserAllowedToSeeRevisions( $performer ) );
665  }
666 
670  public function showDiffPage( $diffOnly = false ) {
671  # Allow frames except in certain special cases
672  $out = $this->getOutput();
673  $out->setPreventClickjacking( false );
674  $out->setRobotPolicy( 'noindex,nofollow' );
675 
676  // Allow extensions to add any extra output here
677  $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
678 
679  if ( !$this->loadRevisionData() ) {
680  if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
681  $this->showMissingRevision();
682  }
683  return;
684  }
685 
686  $user = $this->getUser();
687  $permErrors = $this->getPermissionErrors( $this->getAuthority() );
688  if ( $permErrors ) {
689  throw new PermissionsError( 'read', $permErrors );
690  }
691 
692  $rollback = '';
693 
694  $query = $this->slotDiffOptions;
695  # Carry over 'diffonly' param via navigation links
696  if ( $diffOnly != MediaWikiServices::getInstance()
697  ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
698  ) {
699  $query['diffonly'] = $diffOnly;
700  }
701  # Cascade unhide param in links for easy deletion browsing
702  if ( $this->unhide ) {
703  $query['unhide'] = 1;
704  }
705 
706  # Check if one of the revisions is deleted/suppressed
707  $deleted = $this->hasDeletedRevision();
708  $suppressed = $this->hasSuppressedRevision();
709  $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
710 
711  $revisionTools = [];
712 
713  # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
714  # a diff between a version V and its previous version V' AND the version V
715  # is the first version of that article. In that case, V' does not exist.
716  if ( $this->mOldRevisionRecord === false ) {
717  if ( $this->mNewPage ) {
718  $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
719  }
720  $samePage = true;
721  $oldHeader = '';
722  // Allow extensions to change the $oldHeader variable
723  $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
724  } else {
725  $this->hookRunner->onDifferenceEngineViewHeader( $this );
726 
727  if ( !$this->mOldPage || !$this->mNewPage ) {
728  // XXX say something to the user?
729  $samePage = false;
730  } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
731  $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
732  $samePage = true;
733  } else {
734  $out->setPageTitle( $this->msg( 'difference-title-multipage',
735  $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
736  $out->addSubtitle( $this->msg( 'difference-multipage' ) );
737  $samePage = false;
738  }
739 
740  if ( $samePage && $this->mNewPage &&
741  $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
742  ) {
743  if ( $this->mNewRevisionRecord->isCurrent() &&
744  $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
745  ) {
746  $rollbackLink = Linker::generateRollback(
747  $this->mNewRevisionRecord,
748  $this->getContext(),
749  [ 'noBrackets' ]
750  );
751  if ( $rollbackLink ) {
752  $out->setPreventClickjacking( true );
753  $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
754  }
755  }
756 
757  if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
758  $this->userCanEdit( $this->mNewRevisionRecord )
759  ) {
760  $undoLink = $this->linkRenderer->makeKnownLink(
761  $this->mNewPage,
762  $this->msg( 'editundo' )->text(),
763  [ 'title' => Linker::titleAttrib( 'undo' ) ],
764  [
765  'action' => 'edit',
766  'undoafter' => $this->mOldid,
767  'undo' => $this->mNewid
768  ]
769  );
770  $revisionTools['mw-diff-undo'] = $undoLink;
771  }
772  }
773  # Make "previous revision link"
774  $hasPrevious = $samePage && $this->mOldPage &&
775  $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
776  if ( $hasPrevious ) {
777  $prevlink = $this->linkRenderer->makeKnownLink(
778  $this->mOldPage,
779  $this->msg( 'previousdiff' )->text(),
780  [ 'id' => 'differences-prevlink' ],
781  [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query
782  );
783  } else {
784  $prevlink = "\u{00A0}";
785  }
786 
787  if ( $this->mOldRevisionRecord->isMinor() ) {
788  $oldminor = ChangesList::flag( 'minor' );
789  } else {
790  $oldminor = '';
791  }
792 
793  $oldRevRecord = $this->mOldRevisionRecord;
794 
795  $ldel = $this->revisionDeleteLink( $oldRevRecord );
796  $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
797  $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
798  $oldRevComment = $this->commentFormatter
799  ->formatRevision( $oldRevRecord, $user, !$diffOnly, !$this->unhide );
800 
801  if ( $oldRevComment === '' ) {
802  $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
803  $oldRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
804  }
805 
806  $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
807  '<div id="mw-diff-otitle2">' .
808  Linker::revUserTools( $oldRevRecord, !$this->unhide ) . '</div>' .
809  '<div id="mw-diff-otitle3">' . $oldminor . $oldRevComment . $ldel . '</div>' .
810  '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
811  '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
812 
813  // Allow extensions to change the $oldHeader variable
814  $this->hookRunner->onDifferenceEngineOldHeader(
815  $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
816  }
817 
818  $out->addJsConfigVars( [
819  'wgDiffOldId' => $this->mOldid,
820  'wgDiffNewId' => $this->mNewid,
821  ] );
822 
823  # Make "next revision link"
824  # Skip next link on the top revision
825  if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
826  $nextlink = $this->linkRenderer->makeKnownLink(
827  $this->mNewPage,
828  $this->msg( 'nextdiff' )->text(),
829  [ 'id' => 'differences-nextlink' ],
830  [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query
831  );
832  } else {
833  $nextlink = "\u{00A0}";
834  }
835 
836  if ( $this->mNewRevisionRecord->isMinor() ) {
837  $newminor = ChangesList::flag( 'minor' );
838  } else {
839  $newminor = '';
840  }
841 
842  # Handle RevisionDelete links...
843  $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
844 
845  # Allow extensions to define their own revision tools
846  $this->hookRunner->onDiffTools(
847  $this->mNewRevisionRecord,
848  $revisionTools,
849  $this->mOldRevisionRecord ?: null,
850  $user
851  );
852 
853  $formattedRevisionTools = [];
854  // Put each one in parentheses (poor man's button)
855  foreach ( $revisionTools as $key => $tool ) {
856  $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
857  $element = Html::rawElement(
858  'span',
859  [ 'class' => $toolClass ],
860  $this->msg( 'parentheses' )->rawParams( $tool )->escaped()
861  );
862  $formattedRevisionTools[] = $element;
863  }
864 
865  $newRevRecord = $this->mNewRevisionRecord;
866 
867  $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
868  ' ' . implode( ' ', $formattedRevisionTools );
869  $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
870  $newRevComment = $this->commentFormatter->formatRevision( $newRevRecord, $user, !$diffOnly, !$this->unhide );
871 
872  if ( $newRevComment === '' ) {
873  $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
874  $newRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
875  }
876 
877  $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
878  '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
879  " $rollback</div>" .
880  '<div id="mw-diff-ntitle3">' . $newminor . $newRevComment . $rdel . '</div>' .
881  '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
882  '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
883 
884  // Allow extensions to change the $newHeader variable
885  $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
886  $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
887  $rdel, $this->unhide );
888 
889  # If the diff cannot be shown due to a deleted revision, then output
890  # the diff header and links to unhide (if available)...
891  if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
892  $this->showDiffStyle();
893  $multi = $this->getMultiNotice();
894  $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
895  if ( !$allowed ) {
896  # Give explanation for why revision is not visible
897  $msg = [ $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff' ];
898  } else {
899  # Give explanation and add a link to view the diff...
900  $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
901  $msg = [
902  $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff',
903  $this->getTitle()->getFullURL( $query )
904  ];
905  }
906  $out->addHTML( Html::warningBox( $this->msg( ...$msg )->parse(), 'plainlinks' ) );
907  # Otherwise, output a regular diff...
908  } else {
909  # Add deletion notice if the user is viewing deleted content
910  $notice = '';
911  if ( $deleted ) {
912  $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
913  $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' );
914  }
915 
916  # Add an error if the content can't be loaded
917  $this->getSlotContents();
918  foreach ( $this->getRevisionLoadErrors() as $msg ) {
919  $notice .= Html::warningBox( $msg->parse() );
920  }
921 
922  $this->showDiff( $oldHeader, $newHeader, $notice );
923  if ( !$diffOnly ) {
924  $this->renderNewRevision();
925  }
926  }
927  }
928 
939  public function markPatrolledLink() {
940  if ( $this->mMarkPatrolledLink === null ) {
941  $linkInfo = $this->getMarkPatrolledLinkInfo();
942  // If false, there is no patrol link needed/allowed
943  if ( !$linkInfo || !$this->mNewPage ) {
944  $this->mMarkPatrolledLink = '';
945  } else {
946  $this->mMarkPatrolledLink = ' <span class="patrollink" data-mw="interface">[' .
947  $this->linkRenderer->makeKnownLink(
948  $this->mNewPage,
949  $this->msg( 'markaspatrolleddiff' )->text(),
950  [],
951  [
952  'action' => 'markpatrolled',
953  'rcid' => $linkInfo['rcid'],
954  ]
955  ) . ']</span>';
956  // Allow extensions to change the markpatrolled link
957  $this->hookRunner->onDifferenceEngineMarkPatrolledLink( $this,
958  $this->mMarkPatrolledLink, $linkInfo['rcid'] );
959  }
960  }
962  }
963 
971  protected function getMarkPatrolledLinkInfo() {
972  $user = $this->getUser();
973  $config = $this->getConfig();
974 
975  // Prepare a change patrol link, if applicable
976  if (
977  // Is patrolling enabled and the user allowed to?
978  $config->get( MainConfigNames::UseRCPatrol ) &&
979  $this->mNewPage &&
980  $this->getAuthority()->probablyCan( 'patrol', $this->mNewPage ) &&
981  // Only do this if the revision isn't more than 6 hours older
982  // than the Max RC age (6h because the RC might not be cleaned out regularly)
983  RecentChange::isInRCLifespan( $this->mNewRevisionRecord->getTimestamp(), 21600 )
984  ) {
985  // Look for an unpatrolled change corresponding to this diff
986  $change = RecentChange::newFromConds(
987  [
988  'rc_this_oldid' => $this->mNewid,
989  'rc_patrolled' => RecentChange::PRC_UNPATROLLED
990  ],
991  __METHOD__
992  );
993 
994  if ( $change && !$change->getPerformerIdentity()->equals( $user ) ) {
995  $rcid = $change->getAttribute( 'rc_id' );
996  } else {
997  // None found or the page has been created by the current user.
998  // If the user could patrol this it already would be patrolled
999  $rcid = 0;
1000  }
1001 
1002  // Allow extensions to possibly change the rcid here
1003  // For example the rcid might be set to zero due to the user
1004  // being the same as the performer of the change but an extension
1005  // might still want to show it under certain conditions
1006  $this->hookRunner->onDifferenceEngineMarkPatrolledRCID( $rcid, $this, $change, $user );
1007 
1008  // Build the link
1009  if ( $rcid ) {
1010  $this->getOutput()->setPreventClickjacking( true );
1011  if ( $this->getAuthority()->isAllowed( 'writeapi' ) ) {
1012  $this->getOutput()->addModules( 'mediawiki.misc-authed-curate' );
1013  }
1014 
1015  return [ 'rcid' => $rcid ];
1016  }
1017  }
1018 
1019  // No mark as patrolled link applicable
1020  return false;
1021  }
1022 
1028  private function revisionDeleteLink( RevisionRecord $revRecord ) {
1029  $link = Linker::getRevDeleteLink(
1030  $this->getAuthority(),
1031  $revRecord,
1032  $revRecord->getPageAsLinkTarget()
1033  );
1034  if ( $link !== '' ) {
1035  $link = "\u{00A0}\u{00A0}\u{00A0}" . $link . ' ';
1036  }
1037 
1038  return $link;
1039  }
1040 
1046  public function renderNewRevision() {
1047  if ( $this->isContentOverridden ) {
1048  // The code below only works with a RevisionRecord object. We could construct a
1049  // fake RevisionRecord (here or in setContent), but since this does not seem
1050  // needed at the moment, we'll just fail for now.
1051  throw new LogicException(
1052  __METHOD__
1053  . ' is not supported after calling setContent(). Use setRevisions() instead.'
1054  );
1055  }
1056 
1057  $out = $this->getOutput();
1058  $revHeader = $this->getRevisionHeader( $this->mNewRevisionRecord );
1059  # Add "current version as of X" title
1060  $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
1061  <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
1062  # Page content may be handled by a hooked call instead...
1063  if ( $this->hookRunner->onArticleContentOnDiff( $this, $out ) ) {
1064  $this->loadNewText();
1065  if ( !$this->mNewPage ) {
1066  // New revision is unsaved; bail out.
1067  // TODO in theory rendering the new revision is a meaningful thing to do
1068  // even if it's unsaved, but a lot of untangling is required to do it safely.
1069  return;
1070  }
1071  if ( $this->hasNewRevisionLoadError() ) {
1072  // There was an error loading the new revision
1073  return;
1074  }
1075 
1076  $out->setRevisionId( $this->mNewid );
1077  $out->setRevisionIsCurrent( $this->mNewRevisionRecord->isCurrent() );
1078  $out->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() );
1079  $out->setArticleFlag( true );
1080 
1081  if ( !$this->hookRunner->onArticleRevisionViewCustom(
1082  $this->mNewRevisionRecord, $this->mNewPage, $this->mOldid, $out )
1083  ) {
1084  // Handled by extension
1085  // NOTE: sync with hooks called in Article::view()
1086  } else {
1087  // Normal page
1088  if ( $this->getTitle()->equals( $this->mNewPage ) ) {
1089  // If the Title stored in the context is the same as the one
1090  // of the new revision, we can use its associated WikiPage
1091  // object.
1092  $wikiPage = $this->getWikiPage();
1093  } else {
1094  // Otherwise we need to create our own WikiPage object
1095  $wikiPage = $this->wikiPageFactory->newFromTitle( $this->mNewPage );
1096  }
1097 
1098  $parserOutput = $this->getParserOutput( $wikiPage, $this->mNewRevisionRecord );
1099 
1100  # WikiPage::getParserOutput() should not return false, but just in case
1101  if ( $parserOutput ) {
1102  // Allow extensions to change parser output here
1103  if ( $this->hookRunner->onDifferenceEngineRenderRevisionAddParserOutput(
1104  $this, $out, $parserOutput, $wikiPage )
1105  ) {
1106  $out->addParserOutput( $parserOutput, [
1107  'enableSectionEditLinks' => $this->mNewRevisionRecord->isCurrent()
1108  && $this->getAuthority()->probablyCan(
1109  'edit',
1110  $this->mNewRevisionRecord->getPage()
1111  ),
1112  'absoluteURLs' => $this->slotDiffOptions['expand-url'] ?? false
1113  ] );
1114  }
1115  }
1116  }
1117  }
1118 
1119  // Allow extensions to optionally not show the final patrolled link
1120  if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1121  # Add redundant patrol link on bottom...
1122  $out->addHTML( $this->markPatrolledLink() );
1123  }
1124  }
1125 
1132  protected function getParserOutput( WikiPage $page, RevisionRecord $revRecord ) {
1133  if ( !$revRecord->getId() ) {
1134  // WikiPage::getParserOutput wants a revision ID. Passing 0 will incorrectly show
1135  // the current revision, so fail instead. If need be, WikiPage::getParserOutput
1136  // could be made to accept a RevisionRecord instead of the id.
1137  return false;
1138  }
1139 
1140  $parserOptions = $page->makeParserOptions( $this->getContext() );
1141  $parserOptions->setRenderReason( 'diff-page' );
1142  return $page->getParserOutput( $parserOptions, $revRecord->getId() );
1143  }
1144 
1155  public function showDiff( $otitle, $ntitle, $notice = '' ) {
1156  // Allow extensions to affect the output here
1157  $this->hookRunner->onDifferenceEngineShowDiff( $this );
1158 
1159  $diff = $this->getDiff( $otitle, $ntitle, $notice );
1160  if ( $diff === false ) {
1161  $this->showMissingRevision();
1162  return false;
1163  }
1164 
1165  $this->showDiffStyle();
1166  if ( $this->slotDiffOptions['expand-url'] ?? false ) {
1167  $diff = Linker::expandLocalLinks( $diff );
1168  }
1169  $this->getOutput()->addHTML( $diff );
1170  return true;
1171  }
1172 
1176  public function showDiffStyle() {
1177  if ( !$this->isSlotDiffRenderer ) {
1178  $this->getOutput()->addModules( 'mediawiki.diff' );
1179  $this->getOutput()->addModuleStyles( [
1180  'mediawiki.interface.helpers.styles',
1181  'mediawiki.diff.styles'
1182  ] );
1183  foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1184  $slotDiffRenderer->addModules( $this->getOutput() );
1185  }
1186  }
1187  }
1188 
1198  public function getDiff( $otitle, $ntitle, $notice = '' ) {
1199  $body = $this->getDiffBody();
1200  if ( $body === false ) {
1201  return false;
1202  }
1203 
1204  $multi = $this->getMultiNotice();
1205  // Display a message when the diff is empty
1206  if ( $body === '' ) {
1207  $notice .= '<div class="mw-diff-empty">' .
1208  $this->msg( 'diff-empty' )->parse() .
1209  "</div>\n";
1210  }
1211 
1212  return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
1213  }
1214 
1220  public function getDiffBody() {
1221  $this->mCacheHit = true;
1222  // Check if the diff should be hidden from this user
1223  if ( !$this->isContentOverridden ) {
1224  if ( !$this->loadRevisionData() ) {
1225  return false;
1226  } elseif ( $this->mOldRevisionRecord &&
1227  !$this->mOldRevisionRecord->userCan(
1228  RevisionRecord::DELETED_TEXT,
1229  $this->getAuthority()
1230  )
1231  ) {
1232  return false;
1233  } elseif ( $this->mNewRevisionRecord &&
1234  !$this->mNewRevisionRecord->userCan(
1235  RevisionRecord::DELETED_TEXT,
1236  $this->getAuthority()
1237  ) ) {
1238  return false;
1239  }
1240  // Short-circuit
1241  if ( $this->mOldRevisionRecord === false || (
1242  $this->mOldRevisionRecord &&
1243  $this->mNewRevisionRecord &&
1244  $this->mOldRevisionRecord->getId() &&
1245  $this->mOldRevisionRecord->getId() == $this->mNewRevisionRecord->getId()
1246  ) ) {
1247  if ( $this->hookRunner->onDifferenceEngineShowEmptyOldContent( $this ) ) {
1248  return '';
1249  }
1250  }
1251  }
1252 
1253  // Cacheable?
1254  $key = false;
1255  $services = MediaWikiServices::getInstance();
1256  $cache = $services->getMainWANObjectCache();
1257  $stats = $services->getStatsdDataFactory();
1258  if ( $this->mOldid && $this->mNewid ) {
1259  $key = $cache->makeKey( ...$this->getDiffBodyCacheKeyParams() );
1260 
1261  // Try cache
1262  if ( !$this->mRefreshCache ) {
1263  $difftext = $cache->get( $key );
1264  if ( is_string( $difftext ) ) {
1265  $stats->updateCount( 'diff_cache.hit', 1 );
1266  $difftext = $this->localiseDiff( $difftext );
1267  $difftext .= "\n<!-- diff cache key $key -->\n";
1268 
1269  return $difftext;
1270  }
1271  } // don't try to load but save the result
1272  }
1273  $this->mCacheHit = false;
1274 
1275  // Loadtext is permission safe, this just clears out the diff
1276  if ( !$this->loadText() ) {
1277  return false;
1278  }
1279 
1280  $difftext = '';
1281  // We've checked for revdelete at the beginning of this method; it's OK to ignore
1282  // read permissions here.
1283  $slotContents = $this->getSlotContents();
1284  foreach ( $this->getSlotDiffRenderers() as $role => $slotDiffRenderer ) {
1285  $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'],
1286  $slotContents[$role]['new'] );
1287  if ( $slotDiff && $role !== SlotRecord::MAIN ) {
1288  // FIXME: ask SlotRoleHandler::getSlotNameMessage
1289  $slotTitle = $role;
1290  $difftext .= $this->getSlotHeader( $slotTitle );
1291  }
1292  $difftext .= $slotDiff;
1293  }
1294 
1295  // Save to cache for 7 days
1296  if ( !$this->hookRunner->onAbortDiffCache( $this ) ) {
1297  $stats->updateCount( 'diff_cache.uncacheable', 1 );
1298  } elseif ( $key !== false ) {
1299  $stats->updateCount( 'diff_cache.miss', 1 );
1300  $cache->set( $key, $difftext, 7 * 86400 );
1301  } else {
1302  $stats->updateCount( 'diff_cache.uncacheable', 1 );
1303  }
1304  // localise line numbers and title attribute text
1305  $difftext = $this->localiseDiff( $difftext );
1306 
1307  return $difftext;
1308  }
1309 
1316  public function getDiffBodyForRole( $role ) {
1317  $diffRenderers = $this->getSlotDiffRenderers();
1318  if ( !isset( $diffRenderers[$role] ) ) {
1319  return false;
1320  }
1321 
1322  $slotContents = $this->getSlotContents();
1323  $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'],
1324  $slotContents[$role]['new'] );
1325  if ( !$slotDiff ) {
1326  return false;
1327  }
1328 
1329  if ( $role !== SlotRecord::MAIN ) {
1330  // TODO use human-readable role name at least
1331  $slotTitle = $role;
1332  $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff;
1333  }
1334 
1335  return $this->localiseDiff( $slotDiff );
1336  }
1337 
1345  protected function getSlotHeader( $headerText ) {
1346  // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1347  $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1348  $userLang = $this->getLanguage()->getHtmlCode();
1349  return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-header', 'lang' => $userLang ],
1350  Html::element( 'th', [ 'colspan' => $columnCount ], $headerText ) );
1351  }
1352 
1367  protected function getDiffBodyCacheKeyParams() {
1368  if ( !$this->mOldid || !$this->mNewid ) {
1369  throw new MWException( 'mOldid and mNewid must be set to get diff cache key.' );
1370  }
1371 
1372  $engine = $this->getEngine();
1373  $params = [
1374  'diff',
1375  $engine === 'php' ? false : $engine, // Back compat
1376  self::DIFF_VERSION,
1377  "old-{$this->mOldid}",
1378  "rev-{$this->mNewid}"
1379  ];
1380 
1381  if ( $engine === 'wikidiff2' ) {
1382  $params[] = phpversion( 'wikidiff2' );
1383  }
1384 
1385  if ( !$this->isSlotDiffRenderer ) {
1386  foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1387  $params = array_merge( $params, $slotDiffRenderer->getExtraCacheKeys() );
1388  }
1389  }
1390 
1391  return $params;
1392  }
1393 
1401  public function getExtraCacheKeys() {
1402  // This method is called when the DifferenceEngine is used for a slot diff. We only care
1403  // about special things, not the revision IDs, which are added to the cache key by the
1404  // page-level DifferenceEngine, and which might not have a valid value for this object.
1405  $this->mOldid = 123456789;
1406  $this->mNewid = 987654321;
1407 
1408  // This will repeat a bunch of unnecessary key fields for each slot. Not nice but harmless.
1409  $params = $this->getDiffBodyCacheKeyParams();
1410 
1411  // Try to get rid of the standard keys to keep the cache key human-readable:
1412  // call the getDiffBodyCacheKeyParams implementation of the base class, and if
1413  // the child class includes the same keys, drop them.
1414  // Uses an obscure PHP feature where static calls to non-static methods are allowed
1415  // as long as we are already in a non-static method of the same class, and the call context
1416  // ($this) will be inherited.
1417  // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
1418  $standardParams = DifferenceEngine::getDiffBodyCacheKeyParams();
1419  if ( array_slice( $params, 0, count( $standardParams ) ) === $standardParams ) {
1420  $params = array_slice( $params, count( $standardParams ) );
1421  }
1422 
1423  return $params;
1424  }
1425 
1430  public function setSlotDiffOptions( $options ) {
1431  $this->slotDiffOptions = $options;
1432  }
1433 
1447  public function generateContentDiffBody( Content $old, Content $new ) {
1448  $slotDiffRenderer = $new->getContentHandler()->getSlotDiffRenderer( $this->getContext() );
1449  if (
1450  $slotDiffRenderer instanceof DifferenceEngineSlotDiffRenderer
1451  && $this->isSlotDiffRenderer
1452  ) {
1453  // Oops, we are just about to enter an infinite loop (the slot-level DifferenceEngine
1454  // called a DifferenceEngineSlotDiffRenderer that wraps the same DifferenceEngine class).
1455  // This will happen when a content model has no custom slot diff renderer, it does have
1456  // a custom difference engine, but that does not override this method.
1457  throw new Exception( get_class( $this ) . ': could not maintain backwards compatibility. '
1458  . 'Please use a SlotDiffRenderer.' );
1459  }
1460  return $slotDiffRenderer->getDiff( $old, $new ) . $this->getDebugString();
1461  }
1462 
1475  public function generateTextDiffBody( $otext, $ntext ) {
1476  $slotDiffRenderer = $this->contentHandlerFactory
1477  ->getContentHandler( CONTENT_MODEL_TEXT )
1478  ->getSlotDiffRenderer( $this->getContext() );
1479  if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1480  // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1481  // This is too unlikely to happen to bother handling properly.
1482  throw new Exception( 'The slot diff renderer for text content should be a '
1483  . 'TextSlotDiffRenderer subclass' );
1484  }
1485  return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1486  }
1487 
1494  public static function getEngine() {
1495  $diffEngine = MediaWikiServices::getInstance()->getMainConfig()
1496  ->get( MainConfigNames::DiffEngine );
1497  $externalDiffEngine = MediaWikiServices::getInstance()->getMainConfig()
1498  ->get( MainConfigNames::ExternalDiffEngine );
1499 
1500  if ( $diffEngine === null ) {
1501  $engines = [ 'external', 'wikidiff2', 'php' ];
1502  } else {
1503  $engines = [ $diffEngine ];
1504  }
1505 
1506  $failureReason = null;
1507  foreach ( $engines as $engine ) {
1508  switch ( $engine ) {
1509  case 'external':
1510  if ( is_string( $externalDiffEngine ) ) {
1511  if ( is_executable( $externalDiffEngine ) ) {
1512  return $externalDiffEngine;
1513  }
1514  $failureReason = 'ExternalDiffEngine config points to a non-executable';
1515  if ( $diffEngine === null ) {
1516  wfDebug( "$failureReason, ignoring" );
1517  }
1518  } else {
1519  $failureReason = 'ExternalDiffEngine config is set to a non-string value';
1520  if ( $diffEngine === null && $externalDiffEngine ) {
1521  wfWarn( "$failureReason, ignoring" );
1522  }
1523  }
1524  break;
1525 
1526  case 'wikidiff2':
1527  if ( function_exists( 'wikidiff2_do_diff' ) ) {
1528  return 'wikidiff2';
1529  }
1530  $failureReason = 'wikidiff2 is not available';
1531  break;
1532 
1533  case 'php':
1534  // Always available.
1535  return 'php';
1536 
1537  default:
1538  throw new DomainException( 'Invalid value for $wgDiffEngine: ' . $engine );
1539  }
1540  }
1541  throw new UnexpectedValueException( "Cannot use diff engine '$engine': $failureReason" );
1542  }
1543 
1552  protected function debug( $generator = "internal" ) {
1553  if ( !$this->enableDebugComment ) {
1554  return '';
1555  }
1556  $data = [ $generator ];
1557  if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) ) {
1558  $data[] = wfHostname();
1559  }
1560  $data[] = wfTimestamp( TS_DB );
1561 
1562  return "<!-- diff generator: " .
1563  implode( " ", array_map( "htmlspecialchars", $data ) ) .
1564  " -->\n";
1565  }
1566 
1570  private function getDebugString() {
1571  $engine = self::getEngine();
1572  if ( $engine === 'wikidiff2' ) {
1573  return $this->debug( 'wikidiff2' );
1574  } elseif ( $engine === 'php' ) {
1575  return $this->debug( 'native PHP' );
1576  } else {
1577  return $this->debug( "external $engine" );
1578  }
1579  }
1580 
1587  private function localiseDiff( $text ) {
1588  $text = $this->localiseLineNumbers( $text );
1589  if ( $this->getEngine() === 'wikidiff2' &&
1590  version_compare( phpversion( 'wikidiff2' ), '1.5.1', '>=' )
1591  ) {
1592  $text = $this->addLocalisedTitleTooltips( $text );
1593  }
1594  return $text;
1595  }
1596 
1604  public function localiseLineNumbers( $text ) {
1605  return preg_replace_callback(
1606  '/<!--LINE (\d+)-->/',
1607  function ( array $matches ) {
1608  if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
1609  return '';
1610  }
1611  return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
1612  },
1613  $text
1614  );
1615  }
1616 
1623  private function addLocalisedTitleTooltips( $text ) {
1624  return preg_replace_callback(
1625  '/class="mw-diff-movedpara-(left|right)"/',
1626  function ( array $matches ) {
1627  $key = $matches[1] === 'right' ?
1628  'diff-paragraph-moved-toold' :
1629  'diff-paragraph-moved-tonew';
1630  return $matches[0] . ' title="' . $this->msg( $key )->escaped() . '"';
1631  },
1632  $text
1633  );
1634  }
1635 
1641  public function getMultiNotice() {
1642  // The notice only make sense if we are diffing two saved revisions of the same page.
1643  if (
1644  !$this->mOldRevisionRecord || !$this->mNewRevisionRecord
1645  || !$this->mOldPage || !$this->mNewPage
1646  || !$this->mOldPage->equals( $this->mNewPage )
1647  || $this->mOldRevisionRecord->getId() === null
1648  || $this->mNewRevisionRecord->getId() === null
1649  // (T237709) Deleted revs might have different page IDs
1650  || $this->mNewPage->getArticleID() !== $this->mOldRevisionRecord->getPageId()
1651  || $this->mNewPage->getArticleID() !== $this->mNewRevisionRecord->getPageId()
1652  ) {
1653  return '';
1654  }
1655 
1656  if ( $this->mOldRevisionRecord->getTimestamp() > $this->mNewRevisionRecord->getTimestamp() ) {
1657  $oldRevRecord = $this->mNewRevisionRecord; // flip
1658  $newRevRecord = $this->mOldRevisionRecord; // flip
1659  } else { // normal case
1660  $oldRevRecord = $this->mOldRevisionRecord;
1661  $newRevRecord = $this->mNewRevisionRecord;
1662  }
1663 
1664  // Don't show the notice if too many rows must be scanned
1665  // @todo show some special message for that case
1666  $nEdits = $this->revisionStore->countRevisionsBetween(
1667  $this->mNewPage->getArticleID(),
1668  $oldRevRecord,
1669  $newRevRecord,
1670  1000
1671  );
1672  if ( $nEdits > 0 && $nEdits <= 1000 ) {
1673  // Use an invalid username to get the wiki's default gender (as fallback)
1674  $newRevUserForGender = '[HIDDEN]';
1675  $limit = 100; // use diff-multi-manyusers if too many users
1676  try {
1677  $users = $this->revisionStore->getAuthorsBetween(
1678  $this->mNewPage->getArticleID(),
1679  $oldRevRecord,
1680  $newRevRecord,
1681  null,
1682  $limit
1683  );
1684  $numUsers = count( $users );
1685 
1686  $newRevUser = $newRevRecord->getUser( RevisionRecord::RAW );
1687  $newRevUserText = $newRevUser ? $newRevUser->getName() : '';
1688  $newRevUserSafe = $newRevRecord->getUser(
1689  RevisionRecord::FOR_THIS_USER,
1690  $this->getAuthority()
1691  );
1692  $newRevUserForGender = $newRevUserSafe ? $newRevUserSafe->getName() : '[HIDDEN]';
1693  if ( $numUsers == 1 && $users[0]->getName() == $newRevUserText ) {
1694  $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1695  }
1696  } catch ( InvalidArgumentException $e ) {
1697  $numUsers = 0;
1698  }
1699 
1700  return self::intermediateEditsMsg( $nEdits, $numUsers, $limit, $newRevUserForGender );
1701  }
1702 
1703  return '';
1704  }
1705 
1716  public static function intermediateEditsMsg( $numEdits, $numUsers, $limit, $lastUser = '[HIDDEN]' ) {
1717  if ( $numUsers === 0 ) {
1718  $msg = 'diff-multi-sameuser';
1719  return wfMessage( $msg )
1720  ->numParams( $numEdits, $numUsers )
1721  ->params( $lastUser )
1722  ->parse();
1723  } elseif ( $numUsers > $limit ) {
1724  $msg = 'diff-multi-manyusers';
1725  $numUsers = $limit;
1726  } else {
1727  $msg = 'diff-multi-otherusers';
1728  }
1729 
1730  return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1731  }
1732 
1737  private function userCanEdit( RevisionRecord $revRecord ) {
1738  if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1739  return false;
1740  }
1741 
1742  return true;
1743  }
1744 
1754  public function getRevisionHeader( RevisionRecord $rev, $complete = '' ) {
1755  $lang = $this->getLanguage();
1756  $user = $this->getUser();
1757  $revtimestamp = $rev->getTimestamp();
1758  $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1759  $dateofrev = $lang->userDate( $revtimestamp, $user );
1760  $timeofrev = $lang->userTime( $revtimestamp, $user );
1761 
1762  $header = $this->msg(
1763  $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1764  $timestamp,
1765  $dateofrev,
1766  $timeofrev
1767  );
1768 
1769  if ( $complete !== 'complete' ) {
1770  return $header->escaped();
1771  }
1772 
1773  $title = $rev->getPageAsLinkTarget();
1774 
1775  if ( $this->userCanEdit( $rev ) ) {
1776  $header = $this->linkRenderer->makeKnownLink(
1777  $title,
1778  $header->text(),
1779  [],
1780  [ 'oldid' => $rev->getId() ]
1781  );
1782  $editQuery = [ 'action' => 'edit' ];
1783  if ( !$rev->isCurrent() ) {
1784  $editQuery['oldid'] = $rev->getId();
1785  }
1786 
1787  $key = $this->getAuthority()->probablyCan( 'edit', $rev->getPage() ) ? 'editold' : 'viewsourceold';
1788  $msg = $this->msg( $key )->text();
1789  $editLink = $this->msg( 'parentheses' )->rawParams(
1790  $this->linkRenderer->makeKnownLink( $title, $msg, [], $editQuery ) )->escaped();
1791  $header .= ' ' . Html::rawElement(
1792  'span',
1793  [ 'class' => 'mw-diff-edit' ],
1794  $editLink
1795  );
1796  } else {
1797  $header = $header->escaped();
1798  }
1799 
1800  if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1801  return Html::rawElement(
1802  'span',
1803  [ 'class' => Linker::getRevisionDeletedClass( $rev ) ],
1804  $header
1805  );
1806  }
1807 
1808  return $header;
1809  }
1810 
1823  public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
1824  // shared.css sets diff in interface language/dir, but the actual content
1825  // is often in a different language, mostly the page content language/dir
1826  $header = Html::openElement( 'table', [
1827  'class' => [
1828  'diff',
1829  // The following classes are used here:
1830  // * diff-contentalign-left
1831  // * diff-contentalign-right
1832  'diff-contentalign-' . $this->getDiffLang()->alignStart(),
1833  // The following classes are used here:
1834  // * diff-editfont-monospace
1835  // * diff-editfont-sans-serif
1836  // * diff-editfont-serif
1837  'diff-editfont-' . $this->userOptionsLookup->getOption(
1838  $this->getUser(),
1839  'editfont'
1840  )
1841  ],
1842  'data-mw' => 'interface',
1843  ] );
1844  $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
1845 
1846  if ( !$diff && !$otitle ) {
1847  $header .= "
1848  <tr class=\"diff-title\" lang=\"{$userLang}\">
1849  <td class=\"diff-ntitle\">{$ntitle}</td>
1850  </tr>";
1851  $multiColspan = 1;
1852  } else {
1853  if ( $diff ) { // Safari/Chrome show broken output if cols not used
1854  $header .= "
1855  <col class=\"diff-marker\" />
1856  <col class=\"diff-content\" />
1857  <col class=\"diff-marker\" />
1858  <col class=\"diff-content\" />";
1859  $colspan = 2;
1860  $multiColspan = 4;
1861  } else {
1862  $colspan = 1;
1863  $multiColspan = 2;
1864  }
1865  if ( $otitle || $ntitle ) {
1866  // FIXME Hardcoding values from TableDiffFormatter.
1867  $deletedClass = 'diff-side-deleted';
1868  $addedClass = 'diff-side-added';
1869  $header .= "
1870  <tr class=\"diff-title\" lang=\"{$userLang}\">
1871  <td colspan=\"$colspan\" class=\"diff-otitle {$deletedClass}\">{$otitle}</td>
1872  <td colspan=\"$colspan\" class=\"diff-ntitle {$addedClass}\">{$ntitle}</td>
1873  </tr>";
1874  }
1875  }
1876 
1877  if ( $multi != '' ) {
1878  $header .= "<tr><td colspan=\"{$multiColspan}\" " .
1879  "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
1880  }
1881  if ( $notice != '' ) {
1882  $header .= "<tr><td colspan=\"{$multiColspan}\" " .
1883  "class=\"diff-notice\" lang=\"{$userLang}\">{$notice}</td></tr>";
1884  }
1885 
1886  return $header . $diff . "</table>";
1887  }
1888 
1896  public function setContent( Content $oldContent, Content $newContent ) {
1897  $this->mOldContent = $oldContent;
1898  $this->mNewContent = $newContent;
1899 
1900  $this->mTextLoaded = 2;
1901  $this->mRevisionsLoaded = true;
1902  $this->isContentOverridden = true;
1903  $this->slotDiffRenderers = null;
1904  }
1905 
1911  public function setRevisions(
1912  ?RevisionRecord $oldRevision, RevisionRecord $newRevision
1913  ) {
1914  if ( $oldRevision ) {
1915  $this->mOldRevisionRecord = $oldRevision;
1916  $this->mOldid = $oldRevision->getId();
1917  $this->mOldPage = Title::newFromLinkTarget( $oldRevision->getPageAsLinkTarget() );
1918  // This method is meant for edit diffs and such so there is no reason to provide a
1919  // revision that's not readable to the user, but check it just in case.
1920  $this->mOldContent = $oldRevision->getContent( SlotRecord::MAIN,
1921  RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
1922  if ( !$this->mOldContent ) {
1923  $this->addRevisionLoadError( 'old' );
1924  }
1925  } else {
1926  $this->mOldPage = null;
1927  $this->mOldRevisionRecord = $this->mOldid = false;
1928  }
1929  $this->mNewRevisionRecord = $newRevision;
1930  $this->mNewid = $newRevision->getId();
1931  $this->mNewPage = Title::newFromLinkTarget( $newRevision->getPageAsLinkTarget() );
1932  $this->mNewContent = $newRevision->getContent( SlotRecord::MAIN,
1933  RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
1934  if ( !$this->mNewContent ) {
1935  $this->addRevisionLoadError( 'new' );
1936  }
1937 
1938  $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true;
1939  $this->mTextLoaded = $oldRevision ? 2 : 1;
1940  $this->isContentOverridden = false;
1941  $this->slotDiffRenderers = null;
1942  }
1943 
1950  public function setTextLanguage( Language $lang ) {
1951  $this->mDiffLang = $lang;
1952  }
1953 
1966  public function mapDiffPrevNext( $old, $new ) {
1967  if ( $new === 'prev' ) {
1968  // Show diff between revision $old and the previous one. Get previous one from DB.
1969  $newid = intval( $old );
1970  $oldid = false;
1971  $newRev = $this->revisionStore->getRevisionById( $newid );
1972  if ( $newRev ) {
1973  $oldRev = $this->revisionStore->getPreviousRevision( $newRev );
1974  if ( $oldRev ) {
1975  $oldid = $oldRev->getId();
1976  }
1977  }
1978  } elseif ( $new === 'next' ) {
1979  // Show diff between revision $old and the next one. Get next one from DB.
1980  $oldid = intval( $old );
1981  $newid = false;
1982  $oldRev = $this->revisionStore->getRevisionById( $oldid );
1983  if ( $oldRev ) {
1984  $newRev = $this->revisionStore->getNextRevision( $oldRev );
1985  if ( $newRev ) {
1986  $newid = $newRev->getId();
1987  }
1988  }
1989  } else {
1990  $oldid = intval( $old );
1991  $newid = intval( $new );
1992  }
1993 
1994  // @phan-suppress-next-line PhanTypeMismatchReturn getId does not return null here
1995  return [ $oldid, $newid ];
1996  }
1997 
1998  private function loadRevisionIds() {
1999  if ( $this->mRevisionsIdsLoaded ) {
2000  return;
2001  }
2002 
2003  $this->mRevisionsIdsLoaded = true;
2004 
2005  $old = $this->mOldid;
2006  $new = $this->mNewid;
2007 
2008  [ $this->mOldid, $this->mNewid ] = self::mapDiffPrevNext( $old, $new );
2009  if ( $new === 'next' && $this->mNewid === false ) {
2010  # if no result, NewId points to the newest old revision. The only newer
2011  # revision is cur, which is "0".
2012  $this->mNewid = 0;
2013  }
2014 
2015  $this->hookRunner->onNewDifferenceEngine(
2016  // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
2017  $this->getTitle(), $this->mOldid, $this->mNewid, $old, $new );
2018  }
2019 
2033  public function loadRevisionData() {
2034  if ( $this->mRevisionsLoaded ) {
2035  return $this->isContentOverridden ||
2036  ( $this->mOldRevisionRecord !== null && $this->mNewRevisionRecord !== null );
2037  }
2038 
2039  // Whether it succeeds or fails, we don't want to try again
2040  $this->mRevisionsLoaded = true;
2041 
2042  $this->loadRevisionIds();
2043 
2044  // Load the new RevisionRecord object
2045  if ( $this->mNewid ) {
2046  $this->mNewRevisionRecord = $this->revisionStore->getRevisionById( $this->mNewid );
2047  } else {
2048  $this->mNewRevisionRecord = $this->revisionStore->getRevisionByTitle( $this->getTitle() );
2049  }
2050 
2051  if ( !$this->mNewRevisionRecord instanceof RevisionRecord ) {
2052  return false;
2053  }
2054 
2055  // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
2056  $this->mNewid = $this->mNewRevisionRecord->getId();
2057  $this->mNewPage = $this->mNewid ?
2058  Title::newFromLinkTarget( $this->mNewRevisionRecord->getPageAsLinkTarget() ) :
2059  null;
2060 
2061  // Load the old RevisionRecord object
2062  $this->mOldRevisionRecord = false;
2063  if ( $this->mOldid ) {
2064  $this->mOldRevisionRecord = $this->revisionStore->getRevisionById( $this->mOldid );
2065  } elseif ( $this->mOldid === 0 ) {
2066  $revRecord = $this->revisionStore->getPreviousRevision( $this->mNewRevisionRecord );
2067  // No previous revision; mark to show as first-version only.
2068  $this->mOldid = $revRecord ? $revRecord->getId() : false;
2069  $this->mOldRevisionRecord = $revRecord ?? false;
2070  } /* elseif ( $this->mOldid === false ) leave mOldRevisionRecord false; */
2071 
2072  if ( $this->mOldRevisionRecord === null ) {
2073  return false;
2074  }
2075 
2076  if ( $this->mOldRevisionRecord && $this->mOldRevisionRecord->getId() ) {
2077  $this->mOldPage = Title::newFromLinkTarget(
2078  $this->mOldRevisionRecord->getPageAsLinkTarget()
2079  );
2080  } else {
2081  $this->mOldPage = null;
2082  }
2083 
2084  // Load tags information for both revisions
2085  $dbr = wfGetDB( DB_REPLICA );
2086  $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
2087  if ( $this->mOldid !== false ) {
2088  $tagIds = $dbr->selectFieldValues(
2089  'change_tag',
2090  'ct_tag_id',
2091  [ 'ct_rev_id' => $this->mOldid ],
2092  __METHOD__
2093  );
2094  $tags = [];
2095  foreach ( $tagIds as $tagId ) {
2096  try {
2097  $tags[] = $changeTagDefStore->getName( (int)$tagId );
2098  } catch ( NameTableAccessException $exception ) {
2099  continue;
2100  }
2101  }
2102  $this->mOldTags = implode( ',', $tags );
2103  } else {
2104  $this->mOldTags = false;
2105  }
2106 
2107  $tagIds = $dbr->selectFieldValues(
2108  'change_tag',
2109  'ct_tag_id',
2110  [ 'ct_rev_id' => $this->mNewid ],
2111  __METHOD__
2112  );
2113  $tags = [];
2114  foreach ( $tagIds as $tagId ) {
2115  try {
2116  $tags[] = $changeTagDefStore->getName( (int)$tagId );
2117  } catch ( NameTableAccessException $exception ) {
2118  continue;
2119  }
2120  }
2121  $this->mNewTags = implode( ',', $tags );
2122 
2123  return true;
2124  }
2125 
2134  public function loadText() {
2135  if ( $this->mTextLoaded == 2 ) {
2136  return $this->loadRevisionData() &&
2137  ( $this->mOldRevisionRecord === false || $this->mOldContent )
2138  && $this->mNewContent;
2139  }
2140 
2141  // Whether it succeeds or fails, we don't want to try again
2142  $this->mTextLoaded = 2;
2143 
2144  if ( !$this->loadRevisionData() ) {
2145  return false;
2146  }
2147 
2148  if ( $this->mOldRevisionRecord ) {
2149  $this->mOldContent = $this->mOldRevisionRecord->getContent(
2150  SlotRecord::MAIN,
2151  RevisionRecord::FOR_THIS_USER,
2152  $this->getAuthority()
2153  );
2154  if ( $this->mOldContent === null ) {
2155  return false;
2156  }
2157  }
2158 
2159  $this->mNewContent = $this->mNewRevisionRecord->getContent(
2160  SlotRecord::MAIN,
2161  RevisionRecord::FOR_THIS_USER,
2162  $this->getAuthority()
2163  );
2164  $this->hookRunner->onDifferenceEngineLoadTextAfterNewContentIsLoaded( $this );
2165  if ( $this->mNewContent === null ) {
2166  return false;
2167  }
2168 
2169  return true;
2170  }
2171 
2177  public function loadNewText() {
2178  if ( $this->mTextLoaded >= 1 ) {
2179  return $this->loadRevisionData();
2180  }
2181 
2182  $this->mTextLoaded = 1;
2183 
2184  if ( !$this->loadRevisionData() ) {
2185  return false;
2186  }
2187 
2188  $this->mNewContent = $this->mNewRevisionRecord->getContent(
2189  SlotRecord::MAIN,
2190  RevisionRecord::FOR_THIS_USER,
2191  $this->getAuthority()
2192  );
2193 
2194  $this->hookRunner->onDifferenceEngineAfterLoadNewText( $this );
2195 
2196  return true;
2197  }
2198 
2199 }
const NS_SPECIAL
Definition: Defines.php:53
const CONTENT_MODEL_TEXT
Definition: Defines.php:214
deprecatePublicProperty( $property, $version, $class=null, $component=null)
Mark a property as deprecated.
trait DeprecationHelper
Use this trait in classes which have properties for which public access is deprecated or implementati...
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfWarn( $msg, $callerOffset=1, $level=E_USER_NOTICE)
Send a warning either to the debug log or in a PHP error depending on $wgDevelopmentWarnings.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
wfHostname()
Get host name of the current machine, for use in error reporting.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
$matches
static formatSummaryRow( $tags, $unused, MessageLocalizer $localizer=null)
Creates HTML for the given tags.
Definition: ChangeTags.php:162
static flag( $flag, IContextSource $context=null)
Make an "<abbr>" element for a given change flag.
The simplest way of implementing IContextSource is to hold a RequestContext as a member variable and ...
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
getWikiPage()
Get the WikiPage object.
getContext()
Get the base IContextSource object.
setContext(IContextSource $context)
B/C adapter for turning a DifferenceEngine into a SlotDiffRenderer.
DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
Language StubUserLang $mDiffLang
bool $enableDebugComment
Set this to true to add debug info to the HTML output.
bool $unhide
Show rev_deleted content if allowed.
bool $isContentOverridden
Was the content overridden via setContent()? If the content was overridden, most internal state (e....
getExtraCacheKeys()
Implements DifferenceEngineSlotDiffRenderer::getExtraCacheKeys().
markAsSlotDiffRenderer()
Mark this DifferenceEngine as a slot renderer (as opposed to a page renderer).
getSlotHeader( $headerText)
Get a slot header for inclusion in a diff body (as a table row).
setSlotDiffOptions( $options)
hasDeletedRevision()
Checks whether one of the given Revisions was deleted.
int $mTextLoaded
How many text blobs have been loaded, 0, 1 or 2?
deletedIdMarker( $id)
Build a wikitext link toward a deleted revision, if viewable.
SlotDiffRenderer[] null $slotDiffRenderers
DifferenceEngine classes for the slots, keyed by role name.
getDiffBodyForRole( $role)
Get the diff table body for one slot, without header.
getOldid()
Get the ID of old revision (left pane) of the diff.
setRevisions(?RevisionRecord $oldRevision, RevisionRecord $newRevision)
Use specified text instead of loading from the database.
bool $isSlotDiffRenderer
Temporary hack for B/C while slot diff related methods of DifferenceEngine are being deprecated.
generateTextDiffBody( $otext, $ntext)
Generate a diff, no caching.
loadNewText()
Load the text of the new revision, not the old one.
showDiffPage( $diffOnly=false)
loadText()
Load the text of the revisions, as well as revision data.
int string false null $mNewid
Revision ID for the new revision.
mapDiffPrevNext( $old, $new)
Maps a revision pair definition as accepted by DifferenceEngine constructor to a pair of actual integ...
getPermissionErrors(Authority $performer)
Get the permission errors associated with the revisions for the current diff.
getDiffBody()
Get the diff table body, without header.
getTitle()
1.18 Stability: stableto override Title|null
getParserOutput(WikiPage $page, RevisionRecord $revRecord)
loadRevisionData()
Load revision metadata for the specified revisions.
static getEngine()
Process DiffEngine config and get a sensible, usable engine.
bool $mRevisionsLoaded
Have the revisions been loaded.
getNewRevision()
Get the right side of the diff.
showDiff( $otitle, $ntitle, $notice='')
Get the diff text, send it to the OutputPage object Returns false if the diff could not be generated,...
localiseLineNumbers( $text)
Replace line numbers with the text in the user's language.
getSlotContents()
Get the old and new content objects for all slots.
string $mMarkPatrolledLink
Link to action=markpatrolled.
deletedLink( $id)
Look up a special:Undelete link to the given deleted revision id, as a workaround for being unable to...
bool $mReducedLineNumbers
If true, line X is not displayed when X is 1, for example to increase readability and conserve space ...
__construct( $context=null, $old=0, $new=0, $rcid=0, $refreshCache=false, $unhide=false)
#-
Title null $mNewPage
Title of new revision or null if the new revision does not exist or does not belong to a page.
bool $mCacheHit
Was the diff fetched from cache?
getMultiNotice()
If there are revisions between the ones being compared, return a note saying so.
isUserAllowedToSeeRevisions(Authority $performer)
Checks whether the current user has permission for accessing the revisions of the diff.
int false null $mOldid
Revision ID for the old revision.
debug( $generator="internal")
Generate a debug comment indicating diff generating time, server node, and generator backend.
addHeader( $diff, $otitle, $ntitle, $multi='', $notice='')
Add the header to a diff body.
bool $mRefreshCache
Refresh the diff cache.
LinkRenderer $linkRenderer
getDiffBodyCacheKeyParams()
Get the cache key parameters.
getDiff( $otitle, $ntitle, $notice='')
Get complete diff table, including header.
static intermediateEditsMsg( $numEdits, $numUsers, $limit, $lastUser='[HIDDEN]')
Get a notice about how many intermediate edits and users there are.
getNewid()
Get the ID of new revision (right pane) of the diff.
renderNewRevision()
Show the new revision of the page.
setContent(Content $oldContent, Content $newContent)
Use specified text instead of loading from the database.
setTextLanguage(Language $lang)
Set the language in which the diff text is written.
generateContentDiffBody(Content $old, Content $new)
Generate a diff, no caching.
shouldBeHiddenFromUser(Authority $performer)
Checks whether the diff should be hidden from the current user This is based on whether the user is a...
getRevisionHeader(RevisionRecord $rev, $complete='')
Get a header for a specified revision.
getMarkPatrolledLinkInfo()
Returns an array of meta data needed to build a "mark as patrolled" link and adds a JS module to the ...
setReducedLineNumbers( $value=true)
Set reduced line numbers mode.
Title null $mOldPage
Title of old revision or null if the old revision does not exist or does not belong to a page.
getDiffLang()
Get the language of the difference engine, defaults to page content language.
showDiffStyle()
Add style sheets for diff display.
markPatrolledLink()
Build a link to mark a change as patrolled.
getRevisionLoadErrors()
If errors were encountered while loading the revision contents, this will return an array of Messages...
hasSuppressedRevision()
Checks whether one of the given Revisions was suppressed.
getOldRevision()
Get the left side of the diff.
Base class for language-specific code.
Definition: Language.php:57
MediaWiki exception.
Definition: MWException.php:32
This is the main service interface for converting single-line comments from various DB comment fields...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:565
This class is a collection of static functions that serve two purposes:
Definition: Html.php:55
Class that generates HTML for internal links.
Some internal bits split of from Skin.php.
Definition: Linker.php:67
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Service for creating WikiPage objects.
A StatusValue for permission errors.
Exception raised when the text of a revision is permanently missing or corrupt.
Page revision base class.
getPage()
Returns the page this revision belongs to.
isCurrent()
Checks whether the revision record is a stored current revision.
getTimestamp()
MCR migration note: this replaced Revision::getTimestamp.
getContent( $role, $audience=self::FOR_PUBLIC, Authority $performer=null)
Returns the Content of the given slot of this revision.
getPageAsLinkTarget()
Returns the title of the page this revision is associated with as a LinkTarget object.
userCan( $field, Authority $performer)
Determine if the give authority is allowed to view a particular field of this revision,...
isDeleted( $field)
MCR migration note: this replaced Revision::isDeleted.
getId( $wikiId=self::LOCAL)
Get revision ID.
Service for looking up page revisions.
Value object representing a content slot associated with a page revision.
Definition: SlotRecord.php:40
getContent()
Returns the Content of the given slot.
Definition: SlotRecord.php:319
Exception representing a failure to look up a row from a name table.
Stub object for the user language.
Represents a title within MediaWiki.
Definition: Title.php:82
Provides access to user options.
Show an error when a user tries to do something they do not have the necessary permissions for.
const PRC_UNPATROLLED
static isInRCLifespan( $timestamp, $tolerance=0)
Check whether the given timestamp is new enough to have a RC row with a given tolerance as the recent...
static newFromConds( $conds, $fname=__METHOD__, $dbType=DB_REPLICA)
Find the first recent change matching some specific conditions.
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,...
Renders a slot diff by doing a text diff on the native representation.
Base representation for an editable wiki page.
Definition: WikiPage.php:75
makeParserOptions( $context)
Get parser options suitable for rendering the primary article wikitext.
Definition: WikiPage.php:1945
getParserOutput(?ParserOptions $parserOptions=null, $oldid=null, $noCache=false)
Get a ParserOutput for the given ParserOptions and revision ID.
Definition: WikiPage.php:1281
Base interface for representing page content.
Definition: Content.php:37
getContentHandler()
Convenience method that returns the ContentHandler singleton for handling the content model that this...
Interface for objects which can provide a MediaWiki context on request.
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
authorizeRead(string $action, PageIdentity $target, PermissionStatus $status=null)
Authorize read access.
const DB_REPLICA
Definition: defines.php:26
$content
Definition: router.php:76
if(!isset( $args[0])) $lang
$header