MediaWiki  master
DifferenceEngine.php
Go to the documentation of this file.
1 <?php
40 
64 
66 
73  private const DIFF_VERSION = '1.12';
74 
81  protected $mOldid;
82 
89  protected $mNewid;
90 
101  private $mOldRevisionRecord;
102 
111  private $mNewRevisionRecord;
112 
118  protected $mOldPage;
119 
125  protected $mNewPage;
126 
131  private $mOldTags;
132 
137  private $mNewTags;
138 
144  private $mOldContent;
145 
151  private $mNewContent;
152 
154  protected $mDiffLang;
155 
157  private $mRevisionsIdsLoaded = false;
158 
160  protected $mRevisionsLoaded = false;
161 
163  protected $mTextLoaded = 0;
164 
173  protected $isContentOverridden = false;
174 
176  protected $mCacheHit = false;
177 
184  public $enableDebugComment = false;
185 
189  protected $mReducedLineNumbers = false;
190 
192  protected $mMarkPatrolledLink = null;
193 
195  protected $unhide = false;
196 
198  protected $mRefreshCache = false;
199 
201  protected $slotDiffRenderers = null;
202 
209  protected $isSlotDiffRenderer = false;
210 
215  private $slotDiffOptions = [];
216 
220  protected $linkRenderer;
221 
225  private $contentHandlerFactory;
226 
230  private $revisionStore;
231 
233  private $hookRunner;
234 
236  private $wikiPageFactory;
237 
239  private $userOptionsLookup;
240 
242  private $commentFormatter;
243 
245  private $revisionLoadErrors = [];
246 
257  public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
258  $refreshCache = false, $unhide = false
259  ) {
260  $this->deprecatePublicProperty( 'mOldid', '1.32', __CLASS__ );
261  $this->deprecatePublicProperty( 'mNewid', '1.32', __CLASS__ );
262  $this->deprecatePublicProperty( 'mOldPage', '1.32', __CLASS__ );
263  $this->deprecatePublicProperty( 'mNewPage', '1.32', __CLASS__ );
264  $this->deprecatePublicProperty( 'mOldContent', '1.32', __CLASS__ );
265  $this->deprecatePublicProperty( 'mNewContent', '1.32', __CLASS__ );
266  $this->deprecatePublicProperty( 'mRevisionsLoaded', '1.32', __CLASS__ );
267  $this->deprecatePublicProperty( 'mTextLoaded', '1.32', __CLASS__ );
268  $this->deprecatePublicProperty( 'mCacheHit', '1.32', __CLASS__ );
269 
270  if ( $context instanceof IContextSource ) {
271  $this->setContext( $context );
272  }
273 
274  wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'" );
275 
276  $this->mOldid = $old;
277  $this->mNewid = $new;
278  $this->mRefreshCache = $refreshCache;
279  $this->unhide = $unhide;
280 
281  $services = MediaWikiServices::getInstance();
282  $this->linkRenderer = $services->getLinkRenderer();
283  $this->contentHandlerFactory = $services->getContentHandlerFactory();
284  $this->revisionStore = $services->getRevisionStore();
285  $this->hookRunner = new HookRunner( $services->getHookContainer() );
286  $this->wikiPageFactory = $services->getWikiPageFactory();
287  $this->userOptionsLookup = $services->getUserOptionsLookup();
288  $this->commentFormatter = $services->getCommentFormatter();
289  }
290 
295  protected function getSlotDiffRenderers() {
296  if ( $this->isSlotDiffRenderer ) {
297  throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
298  }
299 
300  if ( $this->slotDiffRenderers === null ) {
301  if ( !$this->loadRevisionData() ) {
302  return [];
303  }
304 
305  $slotContents = $this->getSlotContents();
306  $this->slotDiffRenderers = array_map( function ( array $contents ) {
308  $content = $contents['new'] ?: $contents['old'];
309  return $content->getContentHandler()->getSlotDiffRenderer(
310  $this->getContext(),
311  $this->slotDiffOptions
312  );
313  }, $slotContents );
314  }
315 
317  }
318 
325  public function markAsSlotDiffRenderer() {
326  $this->isSlotDiffRenderer = true;
327  }
328 
334  protected function getSlotContents() {
335  if ( $this->isContentOverridden ) {
336  return [
337  SlotRecord::MAIN => [ 'old' => $this->mOldContent, 'new' => $this->mNewContent ]
338  ];
339  } elseif ( !$this->loadRevisionData() ) {
340  return [];
341  }
342 
343  $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
344  $oldSlots = $this->mOldRevisionRecord ?
345  $this->mOldRevisionRecord->getPrimarySlots()->getSlots() :
346  [];
347  // The order here will determine the visual order of the diff. The current logic is
348  // slots of the new revision first in natural order, then deleted ones. This is ad hoc
349  // and should not be relied on - in the future we may want the ordering to depend
350  // on the page type.
351  $roles = array_keys( array_merge( $newSlots, $oldSlots ) );
352 
353  $slots = [];
354  foreach ( $roles as $role ) {
355  $slots[$role] = [
356  'old' => $this->loadSingleSlot(
357  $oldSlots[$role] ?? null,
358  'old'
359  ),
360  'new' => $this->loadSingleSlot(
361  $newSlots[$role] ?? null,
362  'new'
363  )
364  ];
365  }
366  // move main slot to front
367  if ( isset( $slots[SlotRecord::MAIN] ) ) {
368  $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
369  }
370  return $slots;
371  }
372 
380  private function loadSingleSlot( ?SlotRecord $slot, string $which ) {
381  if ( !$slot ) {
382  return null;
383  }
384  try {
385  return $slot->getContent();
386  } catch ( BadRevisionException $e ) {
387  $this->addRevisionLoadError( $which );
388  return null;
389  }
390  }
391 
397  private function addRevisionLoadError( $which ) {
398  $this->revisionLoadErrors[] = $this->msg( $which === 'new'
399  ? 'difference-bad-new-revision' : 'difference-bad-old-revision'
400  );
401  }
402 
409  public function getRevisionLoadErrors() {
410  return $this->revisionLoadErrors;
411  }
412 
417  private function hasNewRevisionLoadError() {
418  foreach ( $this->revisionLoadErrors as $error ) {
419  if ( $error->getKey() === 'difference-bad-new-revision' ) {
420  return true;
421  }
422  }
423  return false;
424  }
425 
427  public function getTitle() {
428  // T202454 avoid errors when there is no title
429  return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
430  }
431 
438  public function setReducedLineNumbers( $value = true ) {
439  $this->mReducedLineNumbers = $value;
440  }
441 
447  public function getDiffLang() {
448  # Default language in which the diff text is written.
449  $this->mDiffLang ??= $this->getTitle()->getPageLanguage();
450 
451  return $this->mDiffLang;
452  }
453 
457  public function wasCacheHit() {
458  return $this->mCacheHit;
459  }
460 
468  public function getOldid() {
469  $this->loadRevisionIds();
470 
471  return $this->mOldid;
472  }
473 
480  public function getNewid() {
481  $this->loadRevisionIds();
482 
483  return $this->mNewid;
484  }
485 
492  public function getOldRevision() {
493  return $this->mOldRevisionRecord ?: null;
494  }
495 
501  public function getNewRevision() {
502  return $this->mNewRevisionRecord;
503  }
504 
513  public function deletedLink( $id ) {
514  if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
515  $dbr = wfGetDB( DB_REPLICA );
516  $arQuery = $this->revisionStore->getArchiveQueryInfo();
517  $row = $dbr->selectRow(
518  $arQuery['tables'],
519  array_merge( $arQuery['fields'], [ 'ar_namespace', 'ar_title' ] ),
520  [ 'ar_rev_id' => $id ],
521  __METHOD__,
522  [],
523  $arQuery['joins']
524  );
525  if ( $row ) {
526  $revRecord = $this->revisionStore->newRevisionFromArchiveRow( $row );
527  $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
528 
529  return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
530  'target' => $title->getPrefixedText(),
531  'timestamp' => $revRecord->getTimestamp()
532  ] );
533  }
534  }
535 
536  return false;
537  }
538 
546  public function deletedIdMarker( $id ) {
547  $link = $this->deletedLink( $id );
548  if ( $link ) {
549  return "[$link $id]";
550  } else {
551  return (string)$id;
552  }
553  }
554 
555  private function showMissingRevision() {
556  $out = $this->getOutput();
557 
558  $missing = [];
559  if ( $this->mOldid && ( !$this->mOldRevisionRecord || !$this->mOldContent ) ) {
560  $missing[] = $this->deletedIdMarker( $this->mOldid );
561  }
562  if ( !$this->mNewRevisionRecord || !$this->mNewContent ) {
563  $missing[] = $this->deletedIdMarker( $this->mNewid );
564  }
565 
566  $out->setPageTitle( $this->msg( 'errorpagetitle' ) );
567  $msg = $this->msg( 'difference-missing-revision' )
568  ->params( $this->getLanguage()->listToText( $missing ) )
569  ->numParams( count( $missing ) )
570  ->parseAsBlock();
571  $out->addHTML( $msg );
572  }
573 
579  public function hasDeletedRevision() {
580  $this->loadRevisionData();
581  return (
582  $this->mNewRevisionRecord &&
583  $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
584  ) ||
585  (
586  $this->mOldRevisionRecord &&
587  $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
588  );
589  }
590 
597  public function getPermissionErrors( Authority $performer ) {
598  $this->loadRevisionData();
599  $permStatus = PermissionStatus::newEmpty();
600  if ( $this->mNewPage ) {
601  $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
602  }
603  if ( $this->mOldPage ) {
604  $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
605  }
606  return $permStatus->toLegacyErrorArray();
607  }
608 
614  public function hasSuppressedRevision() {
615  return $this->hasDeletedRevision() && (
616  ( $this->mOldRevisionRecord &&
617  $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
618  ( $this->mNewRevisionRecord &&
619  $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
620  );
621  }
622 
634  public function isUserAllowedToSeeRevisions( Authority $performer ) {
635  $this->loadRevisionData();
636 
637  if ( $this->mOldRevisionRecord && !$this->mOldRevisionRecord->userCan(
638  RevisionRecord::DELETED_TEXT,
639  $performer
640  ) ) {
641  return false;
642  }
643 
644  // $this->mNewRev will only be falsy if a loading error occurred
645  // (in which case the user is allowed to see).
646  return !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
647  RevisionRecord::DELETED_TEXT,
648  $performer
649  );
650  }
651 
659  public function shouldBeHiddenFromUser( Authority $performer ) {
660  return $this->hasDeletedRevision() && ( !$this->unhide ||
661  !$this->isUserAllowedToSeeRevisions( $performer ) );
662  }
663 
667  public function showDiffPage( $diffOnly = false ) {
668  # Allow frames except in certain special cases
669  $out = $this->getOutput();
670  $out->setPreventClickjacking( false );
671  $out->setRobotPolicy( 'noindex,nofollow' );
672 
673  // Allow extensions to add any extra output here
674  $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
675 
676  if ( !$this->loadRevisionData() ) {
677  if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
678  $this->showMissingRevision();
679  }
680  return;
681  }
682 
683  $user = $this->getUser();
684  $permErrors = $this->getPermissionErrors( $this->getAuthority() );
685  if ( $permErrors ) {
686  throw new PermissionsError( 'read', $permErrors );
687  }
688 
689  $rollback = '';
690 
691  $query = $this->slotDiffOptions;
692  # Carry over 'diffonly' param via navigation links
693  if ( $diffOnly != MediaWikiServices::getInstance()
694  ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
695  ) {
696  $query['diffonly'] = $diffOnly;
697  }
698  # Cascade unhide param in links for easy deletion browsing
699  if ( $this->unhide ) {
700  $query['unhide'] = 1;
701  }
702 
703  # Check if one of the revisions is deleted/suppressed
704  $deleted = $this->hasDeletedRevision();
705  $suppressed = $this->hasSuppressedRevision();
706  $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
707 
708  $revisionTools = [];
709 
710  # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
711  # a diff between a version V and its previous version V' AND the version V
712  # is the first version of that article. In that case, V' does not exist.
713  if ( $this->mOldRevisionRecord === false ) {
714  if ( $this->mNewPage ) {
715  $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
716  }
717  $samePage = true;
718  $oldHeader = '';
719  // Allow extensions to change the $oldHeader variable
720  $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
721  } else {
722  $this->hookRunner->onDifferenceEngineViewHeader( $this );
723 
724  if ( !$this->mOldPage || !$this->mNewPage ) {
725  // XXX say something to the user?
726  $samePage = false;
727  } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
728  $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
729  $samePage = true;
730  } else {
731  $out->setPageTitle( $this->msg( 'difference-title-multipage',
732  $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
733  $out->addSubtitle( $this->msg( 'difference-multipage' ) );
734  $samePage = false;
735  }
736 
737  if ( $samePage && $this->mNewPage &&
738  $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
739  ) {
740  if ( $this->mNewRevisionRecord->isCurrent() &&
741  $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
742  ) {
743  $rollbackLink = Linker::generateRollback(
744  $this->mNewRevisionRecord,
745  $this->getContext(),
746  [ 'noBrackets' ]
747  );
748  if ( $rollbackLink ) {
749  $out->setPreventClickjacking( true );
750  $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
751  }
752  }
753 
754  if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
755  $this->userCanEdit( $this->mNewRevisionRecord )
756  ) {
757  $undoLink = $this->linkRenderer->makeKnownLink(
758  $this->mNewPage,
759  $this->msg( 'editundo' )->text(),
760  [ 'title' => Linker::titleAttrib( 'undo' ) ],
761  [
762  'action' => 'edit',
763  'undoafter' => $this->mOldid,
764  'undo' => $this->mNewid
765  ]
766  );
767  $revisionTools['mw-diff-undo'] = $undoLink;
768  }
769  }
770  # Make "previous revision link"
771  $hasPrevious = $samePage && $this->mOldPage &&
772  $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
773  if ( $hasPrevious ) {
774  $prevlink = $this->linkRenderer->makeKnownLink(
775  $this->mOldPage,
776  $this->msg( 'previousdiff' )->text(),
777  [ 'id' => 'differences-prevlink' ],
778  [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query
779  );
780  } else {
781  $prevlink = "\u{00A0}";
782  }
783 
784  if ( $this->mOldRevisionRecord->isMinor() ) {
785  $oldminor = ChangesList::flag( 'minor' );
786  } else {
787  $oldminor = '';
788  }
789 
790  $oldRevRecord = $this->mOldRevisionRecord;
791 
792  $ldel = $this->revisionDeleteLink( $oldRevRecord );
793  $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
794  $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
795  $oldRevComment = $this->commentFormatter
796  ->formatRevision( $oldRevRecord, $user, !$diffOnly, !$this->unhide );
797 
798  if ( $oldRevComment === '' ) {
799  $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
800  $oldRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
801  }
802 
803  $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
804  '<div id="mw-diff-otitle2">' .
805  Linker::revUserTools( $oldRevRecord, !$this->unhide ) . '</div>' .
806  '<div id="mw-diff-otitle3">' . $oldminor . $oldRevComment . $ldel . '</div>' .
807  '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
808  '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
809 
810  // Allow extensions to change the $oldHeader variable
811  $this->hookRunner->onDifferenceEngineOldHeader(
812  $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
813  }
814 
815  $out->addJsConfigVars( [
816  'wgDiffOldId' => $this->mOldid,
817  'wgDiffNewId' => $this->mNewid,
818  ] );
819 
820  # Make "next revision link"
821  # Skip next link on the top revision
822  if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
823  $nextlink = $this->linkRenderer->makeKnownLink(
824  $this->mNewPage,
825  $this->msg( 'nextdiff' )->text(),
826  [ 'id' => 'differences-nextlink' ],
827  [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query
828  );
829  } else {
830  $nextlink = "\u{00A0}";
831  }
832 
833  if ( $this->mNewRevisionRecord->isMinor() ) {
834  $newminor = ChangesList::flag( 'minor' );
835  } else {
836  $newminor = '';
837  }
838 
839  # Handle RevisionDelete links...
840  $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
841 
842  # Allow extensions to define their own revision tools
843  $this->hookRunner->onDiffTools(
844  $this->mNewRevisionRecord,
845  $revisionTools,
846  $this->mOldRevisionRecord ?: null,
847  $user
848  );
849 
850  $formattedRevisionTools = [];
851  // Put each one in parentheses (poor man's button)
852  foreach ( $revisionTools as $key => $tool ) {
853  $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
854  $element = Html::rawElement(
855  'span',
856  [ 'class' => $toolClass ],
857  $this->msg( 'parentheses' )->rawParams( $tool )->escaped()
858  );
859  $formattedRevisionTools[] = $element;
860  }
861 
862  $newRevRecord = $this->mNewRevisionRecord;
863 
864  $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
865  ' ' . implode( ' ', $formattedRevisionTools );
866  $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
867  $newRevComment = $this->commentFormatter->formatRevision( $newRevRecord, $user, !$diffOnly, !$this->unhide );
868 
869  if ( $newRevComment === '' ) {
870  $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
871  $newRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
872  }
873 
874  $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
875  '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
876  " $rollback</div>" .
877  '<div id="mw-diff-ntitle3">' . $newminor . $newRevComment . $rdel . '</div>' .
878  '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
879  '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
880 
881  // Allow extensions to change the $newHeader variable
882  $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
883  $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
884  $rdel, $this->unhide );
885 
886  # If the diff cannot be shown due to a deleted revision, then output
887  # the diff header and links to unhide (if available)...
888  if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
889  $this->showDiffStyle();
890  $multi = $this->getMultiNotice();
891  $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
892  if ( !$allowed ) {
893  # Give explanation for why revision is not visible
894  $msg = [ $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff' ];
895  } else {
896  # Give explanation and add a link to view the diff...
897  $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
898  $msg = [
899  $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff',
900  $this->getTitle()->getFullURL( $query )
901  ];
902  }
903  $out->addHTML( Html::warningBox( $this->msg( ...$msg )->parse(), 'plainlinks' ) );
904  # Otherwise, output a regular diff...
905  } else {
906  # Add deletion notice if the user is viewing deleted content
907  $notice = '';
908  if ( $deleted ) {
909  $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
910  $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' );
911  }
912 
913  # Add an error if the content can't be loaded
914  $this->getSlotContents();
915  foreach ( $this->getRevisionLoadErrors() as $msg ) {
916  $notice .= Html::warningBox( $msg->parse() );
917  }
918 
919  $this->showDiff( $oldHeader, $newHeader, $notice );
920  if ( !$diffOnly ) {
921  $this->renderNewRevision();
922  }
923  }
924  }
925 
936  public function markPatrolledLink() {
937  if ( $this->mMarkPatrolledLink === null ) {
938  $linkInfo = $this->getMarkPatrolledLinkInfo();
939  // If false, there is no patrol link needed/allowed
940  if ( !$linkInfo || !$this->mNewPage ) {
941  $this->mMarkPatrolledLink = '';
942  } else {
943  $this->mMarkPatrolledLink = ' <span class="patrollink" data-mw="interface">[' .
944  $this->linkRenderer->makeKnownLink(
945  $this->mNewPage,
946  $this->msg( 'markaspatrolleddiff' )->text(),
947  [],
948  [
949  'action' => 'markpatrolled',
950  'rcid' => $linkInfo['rcid'],
951  ]
952  ) . ']</span>';
953  // Allow extensions to change the markpatrolled link
954  $this->hookRunner->onDifferenceEngineMarkPatrolledLink( $this,
955  $this->mMarkPatrolledLink, $linkInfo['rcid'] );
956  }
957  }
959  }
960 
968  protected function getMarkPatrolledLinkInfo() {
969  $user = $this->getUser();
970  $config = $this->getConfig();
971 
972  // Prepare a change patrol link, if applicable
973  if (
974  // Is patrolling enabled and the user allowed to?
975  $config->get( MainConfigNames::UseRCPatrol ) &&
976  $this->mNewPage &&
977  $this->getAuthority()->probablyCan( 'patrol', $this->mNewPage ) &&
978  // Only do this if the revision isn't more than 6 hours older
979  // than the Max RC age (6h because the RC might not be cleaned out regularly)
980  RecentChange::isInRCLifespan( $this->mNewRevisionRecord->getTimestamp(), 21600 )
981  ) {
982  // Look for an unpatrolled change corresponding to this diff
983  $change = RecentChange::newFromConds(
984  [
985  'rc_this_oldid' => $this->mNewid,
986  'rc_patrolled' => RecentChange::PRC_UNPATROLLED
987  ],
988  __METHOD__
989  );
990 
991  if ( $change && !$change->getPerformerIdentity()->equals( $user ) ) {
992  $rcid = $change->getAttribute( 'rc_id' );
993  } else {
994  // None found or the page has been created by the current user.
995  // If the user could patrol this it already would be patrolled
996  $rcid = 0;
997  }
998 
999  // Allow extensions to possibly change the rcid here
1000  // For example the rcid might be set to zero due to the user
1001  // being the same as the performer of the change but an extension
1002  // might still want to show it under certain conditions
1003  $this->hookRunner->onDifferenceEngineMarkPatrolledRCID( $rcid, $this, $change, $user );
1004 
1005  // Build the link
1006  if ( $rcid ) {
1007  $this->getOutput()->setPreventClickjacking( true );
1008  if ( $this->getAuthority()->isAllowed( 'writeapi' ) ) {
1009  $this->getOutput()->addModules( 'mediawiki.misc-authed-curate' );
1010  }
1011 
1012  return [ 'rcid' => $rcid ];
1013  }
1014  }
1015 
1016  // No mark as patrolled link applicable
1017  return false;
1018  }
1019 
1025  private function revisionDeleteLink( RevisionRecord $revRecord ) {
1026  $link = Linker::getRevDeleteLink(
1027  $this->getAuthority(),
1028  $revRecord,
1029  $revRecord->getPageAsLinkTarget()
1030  );
1031  if ( $link !== '' ) {
1032  $link = "\u{00A0}\u{00A0}\u{00A0}" . $link . ' ';
1033  }
1034 
1035  return $link;
1036  }
1037 
1043  public function renderNewRevision() {
1044  if ( $this->isContentOverridden ) {
1045  // The code below only works with a RevisionRecord object. We could construct a
1046  // fake RevisionRecord (here or in setContent), but since this does not seem
1047  // needed at the moment, we'll just fail for now.
1048  throw new LogicException(
1049  __METHOD__
1050  . ' is not supported after calling setContent(). Use setRevisions() instead.'
1051  );
1052  }
1053 
1054  $out = $this->getOutput();
1055  $revHeader = $this->getRevisionHeader( $this->mNewRevisionRecord );
1056  # Add "current version as of X" title
1057  $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
1058  <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
1059  # Page content may be handled by a hooked call instead...
1060  if ( $this->hookRunner->onArticleContentOnDiff( $this, $out ) ) {
1061  $this->loadNewText();
1062  if ( !$this->mNewPage ) {
1063  // New revision is unsaved; bail out.
1064  // TODO in theory rendering the new revision is a meaningful thing to do
1065  // even if it's unsaved, but a lot of untangling is required to do it safely.
1066  return;
1067  }
1068  if ( $this->hasNewRevisionLoadError() ) {
1069  // There was an error loading the new revision
1070  return;
1071  }
1072 
1073  $out->setRevisionId( $this->mNewid );
1074  $out->setRevisionIsCurrent( $this->mNewRevisionRecord->isCurrent() );
1075  $out->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() );
1076  $out->setArticleFlag( true );
1077 
1078  if ( !$this->hookRunner->onArticleRevisionViewCustom(
1079  $this->mNewRevisionRecord, $this->mNewPage, $this->mOldid, $out )
1080  ) {
1081  // Handled by extension
1082  // NOTE: sync with hooks called in Article::view()
1083  } else {
1084  // Normal page
1085  if ( $this->getTitle()->equals( $this->mNewPage ) ) {
1086  // If the Title stored in the context is the same as the one
1087  // of the new revision, we can use its associated WikiPage
1088  // object.
1089  $wikiPage = $this->getWikiPage();
1090  } else {
1091  // Otherwise we need to create our own WikiPage object
1092  $wikiPage = $this->wikiPageFactory->newFromTitle( $this->mNewPage );
1093  }
1094 
1095  $parserOutput = $this->getParserOutput( $wikiPage, $this->mNewRevisionRecord );
1096 
1097  # WikiPage::getParserOutput() should not return false, but just in case
1098  if ( $parserOutput ) {
1099  // Allow extensions to change parser output here
1100  if ( $this->hookRunner->onDifferenceEngineRenderRevisionAddParserOutput(
1101  $this, $out, $parserOutput, $wikiPage )
1102  ) {
1103  $out->addParserOutput( $parserOutput, [
1104  'enableSectionEditLinks' => $this->mNewRevisionRecord->isCurrent()
1105  && $this->getAuthority()->probablyCan(
1106  'edit',
1107  $this->mNewRevisionRecord->getPage()
1108  ),
1109  'absoluteURLs' => $this->slotDiffOptions['expand-url'] ?? false
1110  ] );
1111  }
1112  }
1113  }
1114  }
1115 
1116  // Allow extensions to optionally not show the final patrolled link
1117  if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1118  # Add redundant patrol link on bottom...
1119  $out->addHTML( $this->markPatrolledLink() );
1120  }
1121  }
1122 
1129  protected function getParserOutput( WikiPage $page, RevisionRecord $revRecord ) {
1130  if ( !$revRecord->getId() ) {
1131  // WikiPage::getParserOutput wants a revision ID. Passing 0 will incorrectly show
1132  // the current revision, so fail instead. If need be, WikiPage::getParserOutput
1133  // could be made to accept a RevisionRecord instead of the id.
1134  return false;
1135  }
1136 
1137  $parserOptions = $page->makeParserOptions( $this->getContext() );
1138  $parserOptions->setRenderReason( 'diff-page' );
1139  return $page->getParserOutput( $parserOptions, $revRecord->getId() );
1140  }
1141 
1152  public function showDiff( $otitle, $ntitle, $notice = '' ) {
1153  // Allow extensions to affect the output here
1154  $this->hookRunner->onDifferenceEngineShowDiff( $this );
1155 
1156  $diff = $this->getDiff( $otitle, $ntitle, $notice );
1157  if ( $diff === false ) {
1158  $this->showMissingRevision();
1159  return false;
1160  }
1161 
1162  $this->showDiffStyle();
1163  if ( $this->slotDiffOptions['expand-url'] ?? false ) {
1164  $diff = Linker::expandLocalLinks( $diff );
1165  }
1166  $this->getOutput()->addHTML( $diff );
1167  return true;
1168  }
1169 
1173  public function showDiffStyle() {
1174  if ( !$this->isSlotDiffRenderer ) {
1175  $this->getOutput()->addModules( 'mediawiki.diff' );
1176  $this->getOutput()->addModuleStyles( [
1177  'mediawiki.interface.helpers.styles',
1178  'mediawiki.diff.styles'
1179  ] );
1180  foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1181  $slotDiffRenderer->addModules( $this->getOutput() );
1182  }
1183  }
1184  }
1185 
1195  public function getDiff( $otitle, $ntitle, $notice = '' ) {
1196  $body = $this->getDiffBody();
1197  if ( $body === false ) {
1198  return false;
1199  }
1200 
1201  $multi = $this->getMultiNotice();
1202  // Display a message when the diff is empty
1203  if ( $body === '' ) {
1204  $notice .= '<div class="mw-diff-empty">' .
1205  $this->msg( 'diff-empty' )->parse() .
1206  "</div>\n";
1207  }
1208 
1209  return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
1210  }
1211 
1217  public function getDiffBody() {
1218  $this->mCacheHit = true;
1219  // Check if the diff should be hidden from this user
1220  if ( !$this->isContentOverridden ) {
1221  if ( !$this->loadRevisionData() ) {
1222  return false;
1223  } elseif ( $this->mOldRevisionRecord &&
1224  !$this->mOldRevisionRecord->userCan(
1225  RevisionRecord::DELETED_TEXT,
1226  $this->getAuthority()
1227  )
1228  ) {
1229  return false;
1230  } elseif ( $this->mNewRevisionRecord &&
1231  !$this->mNewRevisionRecord->userCan(
1232  RevisionRecord::DELETED_TEXT,
1233  $this->getAuthority()
1234  ) ) {
1235  return false;
1236  }
1237  // Short-circuit
1238  if ( $this->mOldRevisionRecord === false || (
1239  $this->mOldRevisionRecord &&
1240  $this->mNewRevisionRecord &&
1241  $this->mOldRevisionRecord->getId() &&
1242  $this->mOldRevisionRecord->getId() == $this->mNewRevisionRecord->getId()
1243  ) ) {
1244  if ( $this->hookRunner->onDifferenceEngineShowEmptyOldContent( $this ) ) {
1245  return '';
1246  }
1247  }
1248  }
1249 
1250  // Cacheable?
1251  $key = false;
1252  $services = MediaWikiServices::getInstance();
1253  $cache = $services->getMainWANObjectCache();
1254  $stats = $services->getStatsdDataFactory();
1255  if ( $this->mOldid && $this->mNewid ) {
1256  $key = $cache->makeKey( ...$this->getDiffBodyCacheKeyParams() );
1257 
1258  // Try cache
1259  if ( !$this->mRefreshCache ) {
1260  $difftext = $cache->get( $key );
1261  if ( is_string( $difftext ) ) {
1262  $stats->updateCount( 'diff_cache.hit', 1 );
1263  $difftext = $this->localiseDiff( $difftext );
1264  $difftext .= "\n<!-- diff cache key $key -->\n";
1265 
1266  return $difftext;
1267  }
1268  } // don't try to load but save the result
1269  }
1270  $this->mCacheHit = false;
1271 
1272  // Loadtext is permission safe, this just clears out the diff
1273  if ( !$this->loadText() ) {
1274  return false;
1275  }
1276 
1277  $difftext = '';
1278  // We've checked for revdelete at the beginning of this method; it's OK to ignore
1279  // read permissions here.
1280  $slotContents = $this->getSlotContents();
1281  foreach ( $this->getSlotDiffRenderers() as $role => $slotDiffRenderer ) {
1282  $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'],
1283  $slotContents[$role]['new'] );
1284  if ( $slotDiff && $role !== SlotRecord::MAIN ) {
1285  // FIXME: ask SlotRoleHandler::getSlotNameMessage
1286  $slotTitle = $role;
1287  $difftext .= $this->getSlotHeader( $slotTitle );
1288  }
1289  $difftext .= $slotDiff;
1290  }
1291 
1292  // Save to cache for 7 days
1293  if ( !$this->hookRunner->onAbortDiffCache( $this ) ) {
1294  $stats->updateCount( 'diff_cache.uncacheable', 1 );
1295  } elseif ( $key !== false ) {
1296  $stats->updateCount( 'diff_cache.miss', 1 );
1297  $cache->set( $key, $difftext, 7 * 86400 );
1298  } else {
1299  $stats->updateCount( 'diff_cache.uncacheable', 1 );
1300  }
1301  // localise line numbers and title attribute text
1302  $difftext = $this->localiseDiff( $difftext );
1303 
1304  return $difftext;
1305  }
1306 
1313  public function getDiffBodyForRole( $role ) {
1314  $diffRenderers = $this->getSlotDiffRenderers();
1315  if ( !isset( $diffRenderers[$role] ) ) {
1316  return false;
1317  }
1318 
1319  $slotContents = $this->getSlotContents();
1320  $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'],
1321  $slotContents[$role]['new'] );
1322  if ( !$slotDiff ) {
1323  return false;
1324  }
1325 
1326  if ( $role !== SlotRecord::MAIN ) {
1327  // TODO use human-readable role name at least
1328  $slotTitle = $role;
1329  $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff;
1330  }
1331 
1332  return $this->localiseDiff( $slotDiff );
1333  }
1334 
1342  protected function getSlotHeader( $headerText ) {
1343  // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1344  $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1345  $userLang = $this->getLanguage()->getHtmlCode();
1346  return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-header', 'lang' => $userLang ],
1347  Html::element( 'th', [ 'colspan' => $columnCount ], $headerText ) );
1348  }
1349 
1364  protected function getDiffBodyCacheKeyParams() {
1365  if ( !$this->mOldid || !$this->mNewid ) {
1366  throw new MWException( 'mOldid and mNewid must be set to get diff cache key.' );
1367  }
1368 
1369  $engine = $this->getEngine();
1370  $params = [
1371  'diff',
1372  $engine === 'php' ? false : $engine, // Back compat
1373  self::DIFF_VERSION,
1374  "old-{$this->mOldid}",
1375  "rev-{$this->mNewid}"
1376  ];
1377 
1378  if ( $engine === 'wikidiff2' ) {
1379  $params[] = phpversion( 'wikidiff2' );
1380  }
1381 
1382  if ( !$this->isSlotDiffRenderer ) {
1383  foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1384  $params = array_merge( $params, $slotDiffRenderer->getExtraCacheKeys() );
1385  }
1386  }
1387 
1388  return $params;
1389  }
1390 
1398  public function getExtraCacheKeys() {
1399  // This method is called when the DifferenceEngine is used for a slot diff. We only care
1400  // about special things, not the revision IDs, which are added to the cache key by the
1401  // page-level DifferenceEngine, and which might not have a valid value for this object.
1402  $this->mOldid = 123456789;
1403  $this->mNewid = 987654321;
1404 
1405  // This will repeat a bunch of unnecessary key fields for each slot. Not nice but harmless.
1406  $params = $this->getDiffBodyCacheKeyParams();
1407 
1408  // Try to get rid of the standard keys to keep the cache key human-readable:
1409  // call the getDiffBodyCacheKeyParams implementation of the base class, and if
1410  // the child class includes the same keys, drop them.
1411  // Uses an obscure PHP feature where static calls to non-static methods are allowed
1412  // as long as we are already in a non-static method of the same class, and the call context
1413  // ($this) will be inherited.
1414  // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
1415  $standardParams = DifferenceEngine::getDiffBodyCacheKeyParams();
1416  if ( array_slice( $params, 0, count( $standardParams ) ) === $standardParams ) {
1417  $params = array_slice( $params, count( $standardParams ) );
1418  }
1419 
1420  return $params;
1421  }
1422 
1427  public function setSlotDiffOptions( $options ) {
1428  $this->slotDiffOptions = $options;
1429  }
1430 
1444  public function generateContentDiffBody( Content $old, Content $new ) {
1445  $slotDiffRenderer = $new->getContentHandler()->getSlotDiffRenderer( $this->getContext() );
1446  if (
1447  $slotDiffRenderer instanceof DifferenceEngineSlotDiffRenderer
1448  && $this->isSlotDiffRenderer
1449  ) {
1450  // Oops, we are just about to enter an infinite loop (the slot-level DifferenceEngine
1451  // called a DifferenceEngineSlotDiffRenderer that wraps the same DifferenceEngine class).
1452  // This will happen when a content model has no custom slot diff renderer, it does have
1453  // a custom difference engine, but that does not override this method.
1454  throw new Exception( get_class( $this ) . ': could not maintain backwards compatibility. '
1455  . 'Please use a SlotDiffRenderer.' );
1456  }
1457  return $slotDiffRenderer->getDiff( $old, $new ) . $this->getDebugString();
1458  }
1459 
1472  public function generateTextDiffBody( $otext, $ntext ) {
1473  $slotDiffRenderer = $this->contentHandlerFactory
1474  ->getContentHandler( CONTENT_MODEL_TEXT )
1475  ->getSlotDiffRenderer( $this->getContext() );
1476  if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1477  // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1478  // This is too unlikely to happen to bother handling properly.
1479  throw new Exception( 'The slot diff renderer for text content should be a '
1480  . 'TextSlotDiffRenderer subclass' );
1481  }
1482  return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1483  }
1484 
1491  public static function getEngine() {
1492  $diffEngine = MediaWikiServices::getInstance()->getMainConfig()
1493  ->get( MainConfigNames::DiffEngine );
1494  $externalDiffEngine = MediaWikiServices::getInstance()->getMainConfig()
1495  ->get( MainConfigNames::ExternalDiffEngine );
1496 
1497  if ( $diffEngine === null ) {
1498  $engines = [ 'external', 'wikidiff2', 'php' ];
1499  } else {
1500  $engines = [ $diffEngine ];
1501  }
1502 
1503  $failureReason = null;
1504  foreach ( $engines as $engine ) {
1505  switch ( $engine ) {
1506  case 'external':
1507  if ( is_string( $externalDiffEngine ) ) {
1508  if ( is_executable( $externalDiffEngine ) ) {
1509  return $externalDiffEngine;
1510  }
1511  $failureReason = 'ExternalDiffEngine config points to a non-executable';
1512  if ( $diffEngine === null ) {
1513  wfDebug( "$failureReason, ignoring" );
1514  }
1515  } else {
1516  $failureReason = 'ExternalDiffEngine config is set to a non-string value';
1517  if ( $diffEngine === null && $externalDiffEngine ) {
1518  wfWarn( "$failureReason, ignoring" );
1519  }
1520  }
1521  break;
1522 
1523  case 'wikidiff2':
1524  if ( function_exists( 'wikidiff2_do_diff' ) ) {
1525  return 'wikidiff2';
1526  }
1527  $failureReason = 'wikidiff2 is not available';
1528  break;
1529 
1530  case 'php':
1531  // Always available.
1532  return 'php';
1533 
1534  default:
1535  throw new DomainException( 'Invalid value for $wgDiffEngine: ' . $engine );
1536  }
1537  }
1538  throw new UnexpectedValueException( "Cannot use diff engine '$engine': $failureReason" );
1539  }
1540 
1549  protected function debug( $generator = "internal" ) {
1550  if ( !$this->enableDebugComment ) {
1551  return '';
1552  }
1553  $data = [ $generator ];
1554  if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) ) {
1555  $data[] = wfHostname();
1556  }
1557  $data[] = wfTimestamp( TS_DB );
1558 
1559  return "<!-- diff generator: " .
1560  implode( " ", array_map( "htmlspecialchars", $data ) ) .
1561  " -->\n";
1562  }
1563 
1567  private function getDebugString() {
1568  $engine = self::getEngine();
1569  if ( $engine === 'wikidiff2' ) {
1570  return $this->debug( 'wikidiff2' );
1571  } elseif ( $engine === 'php' ) {
1572  return $this->debug( 'native PHP' );
1573  } else {
1574  return $this->debug( "external $engine" );
1575  }
1576  }
1577 
1584  private function localiseDiff( $text ) {
1585  $text = $this->localiseLineNumbers( $text );
1586  if ( $this->getEngine() === 'wikidiff2' &&
1587  version_compare( phpversion( 'wikidiff2' ), '1.5.1', '>=' )
1588  ) {
1589  $text = $this->addLocalisedTitleTooltips( $text );
1590  }
1591  return $text;
1592  }
1593 
1601  public function localiseLineNumbers( $text ) {
1602  return preg_replace_callback(
1603  '/<!--LINE (\d+)-->/',
1604  function ( array $matches ) {
1605  if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
1606  return '';
1607  }
1608  return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
1609  },
1610  $text
1611  );
1612  }
1613 
1620  private function addLocalisedTitleTooltips( $text ) {
1621  return preg_replace_callback(
1622  '/class="mw-diff-movedpara-(left|right)"/',
1623  function ( array $matches ) {
1624  $key = $matches[1] === 'right' ?
1625  'diff-paragraph-moved-toold' :
1626  'diff-paragraph-moved-tonew';
1627  return $matches[0] . ' title="' . $this->msg( $key )->escaped() . '"';
1628  },
1629  $text
1630  );
1631  }
1632 
1638  public function getMultiNotice() {
1639  // The notice only make sense if we are diffing two saved revisions of the same page.
1640  if (
1641  !$this->mOldRevisionRecord || !$this->mNewRevisionRecord
1642  || !$this->mOldPage || !$this->mNewPage
1643  || !$this->mOldPage->equals( $this->mNewPage )
1644  || $this->mOldRevisionRecord->getId() === null
1645  || $this->mNewRevisionRecord->getId() === null
1646  // (T237709) Deleted revs might have different page IDs
1647  || $this->mNewPage->getArticleID() !== $this->mOldRevisionRecord->getPageId()
1648  || $this->mNewPage->getArticleID() !== $this->mNewRevisionRecord->getPageId()
1649  ) {
1650  return '';
1651  }
1652 
1653  if ( $this->mOldRevisionRecord->getTimestamp() > $this->mNewRevisionRecord->getTimestamp() ) {
1654  $oldRevRecord = $this->mNewRevisionRecord; // flip
1655  $newRevRecord = $this->mOldRevisionRecord; // flip
1656  } else { // normal case
1657  $oldRevRecord = $this->mOldRevisionRecord;
1658  $newRevRecord = $this->mNewRevisionRecord;
1659  }
1660 
1661  // Don't show the notice if too many rows must be scanned
1662  // @todo show some special message for that case
1663  $nEdits = $this->revisionStore->countRevisionsBetween(
1664  $this->mNewPage->getArticleID(),
1665  $oldRevRecord,
1666  $newRevRecord,
1667  1000
1668  );
1669  if ( $nEdits > 0 && $nEdits <= 1000 ) {
1670  $limit = 100; // use diff-multi-manyusers if too many users
1671  try {
1672  $users = $this->revisionStore->getAuthorsBetween(
1673  $this->mNewPage->getArticleID(),
1674  $oldRevRecord,
1675  $newRevRecord,
1676  null,
1677  $limit
1678  );
1679  $numUsers = count( $users );
1680 
1681  $newRevUser = $newRevRecord->getUser( RevisionRecord::RAW );
1682  $newRevUserText = $newRevUser ? $newRevUser->getName() : '';
1683  if ( $numUsers == 1 && $users[0]->getName() == $newRevUserText ) {
1684  $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1685  }
1686  } catch ( InvalidArgumentException $e ) {
1687  $numUsers = 0;
1688  }
1689 
1690  return self::intermediateEditsMsg( $nEdits, $numUsers, $limit );
1691  }
1692 
1693  return '';
1694  }
1695 
1705  public static function intermediateEditsMsg( $numEdits, $numUsers, $limit ) {
1706  if ( $numUsers === 0 ) {
1707  $msg = 'diff-multi-sameuser';
1708  } elseif ( $numUsers > $limit ) {
1709  $msg = 'diff-multi-manyusers';
1710  $numUsers = $limit;
1711  } else {
1712  $msg = 'diff-multi-otherusers';
1713  }
1714 
1715  return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1716  }
1717 
1722  private function userCanEdit( RevisionRecord $revRecord ) {
1723  if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1724  return false;
1725  }
1726 
1727  return true;
1728  }
1729 
1739  public function getRevisionHeader( RevisionRecord $rev, $complete = '' ) {
1740  $lang = $this->getLanguage();
1741  $user = $this->getUser();
1742  $revtimestamp = $rev->getTimestamp();
1743  $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1744  $dateofrev = $lang->userDate( $revtimestamp, $user );
1745  $timeofrev = $lang->userTime( $revtimestamp, $user );
1746 
1747  $header = $this->msg(
1748  $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1749  $timestamp,
1750  $dateofrev,
1751  $timeofrev
1752  );
1753 
1754  if ( $complete !== 'complete' ) {
1755  return $header->escaped();
1756  }
1757 
1758  $title = $rev->getPageAsLinkTarget();
1759 
1760  if ( $this->userCanEdit( $rev ) ) {
1761  $header = $this->linkRenderer->makeKnownLink(
1762  $title,
1763  $header->text(),
1764  [],
1765  [ 'oldid' => $rev->getId() ]
1766  );
1767  $editQuery = [ 'action' => 'edit' ];
1768  if ( !$rev->isCurrent() ) {
1769  $editQuery['oldid'] = $rev->getId();
1770  }
1771 
1772  $key = $this->getAuthority()->probablyCan( 'edit', $rev->getPage() ) ? 'editold' : 'viewsourceold';
1773  $msg = $this->msg( $key )->text();
1774  $editLink = $this->msg( 'parentheses' )->rawParams(
1775  $this->linkRenderer->makeKnownLink( $title, $msg, [], $editQuery ) )->escaped();
1776  $header .= ' ' . Html::rawElement(
1777  'span',
1778  [ 'class' => 'mw-diff-edit' ],
1779  $editLink
1780  );
1781  } else {
1782  $header = $header->escaped();
1783  }
1784 
1785  if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1786  return Html::rawElement(
1787  'span',
1788  [ 'class' => Linker::getRevisionDeletedClass( $rev ) ],
1789  $header
1790  );
1791  }
1792 
1793  return $header;
1794  }
1795 
1808  public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
1809  // shared.css sets diff in interface language/dir, but the actual content
1810  // is often in a different language, mostly the page content language/dir
1811  $header = Html::openElement( 'table', [
1812  'class' => [
1813  'diff',
1814  'diff-contentalign-' . $this->getDiffLang()->alignStart(),
1815  'diff-editfont-' . $this->userOptionsLookup->getOption(
1816  $this->getUser(),
1817  'editfont'
1818  )
1819  ],
1820  'data-mw' => 'interface',
1821  ] );
1822  $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
1823 
1824  if ( !$diff && !$otitle ) {
1825  $header .= "
1826  <tr class=\"diff-title\" lang=\"{$userLang}\">
1827  <td class=\"diff-ntitle\">{$ntitle}</td>
1828  </tr>";
1829  $multiColspan = 1;
1830  } else {
1831  if ( $diff ) { // Safari/Chrome show broken output if cols not used
1832  $header .= "
1833  <col class=\"diff-marker\" />
1834  <col class=\"diff-content\" />
1835  <col class=\"diff-marker\" />
1836  <col class=\"diff-content\" />";
1837  $colspan = 2;
1838  $multiColspan = 4;
1839  } else {
1840  $colspan = 1;
1841  $multiColspan = 2;
1842  }
1843  if ( $otitle || $ntitle ) {
1844  // FIXME Hardcoding values from TableDiffFormatter.
1845  $deletedClass = 'diff-side-deleted';
1846  $addedClass = 'diff-side-added';
1847  $header .= "
1848  <tr class=\"diff-title\" lang=\"{$userLang}\">
1849  <td colspan=\"$colspan\" class=\"diff-otitle {$deletedClass}\">{$otitle}</td>
1850  <td colspan=\"$colspan\" class=\"diff-ntitle {$addedClass}\">{$ntitle}</td>
1851  </tr>";
1852  }
1853  }
1854 
1855  if ( $multi != '' ) {
1856  $header .= "<tr><td colspan=\"{$multiColspan}\" " .
1857  "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
1858  }
1859  if ( $notice != '' ) {
1860  $header .= "<tr><td colspan=\"{$multiColspan}\" " .
1861  "class=\"diff-notice\" lang=\"{$userLang}\">{$notice}</td></tr>";
1862  }
1863 
1864  return $header . $diff . "</table>";
1865  }
1866 
1874  public function setContent( Content $oldContent, Content $newContent ) {
1875  $this->mOldContent = $oldContent;
1876  $this->mNewContent = $newContent;
1877 
1878  $this->mTextLoaded = 2;
1879  $this->mRevisionsLoaded = true;
1880  $this->isContentOverridden = true;
1881  $this->slotDiffRenderers = null;
1882  }
1883 
1889  public function setRevisions(
1890  ?RevisionRecord $oldRevision, RevisionRecord $newRevision
1891  ) {
1892  if ( $oldRevision ) {
1893  $this->mOldRevisionRecord = $oldRevision;
1894  $this->mOldid = $oldRevision->getId();
1895  $this->mOldPage = Title::newFromLinkTarget( $oldRevision->getPageAsLinkTarget() );
1896  // This method is meant for edit diffs and such so there is no reason to provide a
1897  // revision that's not readable to the user, but check it just in case.
1898  $this->mOldContent = $oldRevision->getContent( SlotRecord::MAIN,
1899  RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
1900  if ( !$this->mOldContent ) {
1901  $this->addRevisionLoadError( 'old' );
1902  }
1903  } else {
1904  $this->mOldPage = null;
1905  $this->mOldRevisionRecord = $this->mOldid = false;
1906  }
1907  $this->mNewRevisionRecord = $newRevision;
1908  $this->mNewid = $newRevision->getId();
1909  $this->mNewPage = Title::newFromLinkTarget( $newRevision->getPageAsLinkTarget() );
1910  $this->mNewContent = $newRevision->getContent( SlotRecord::MAIN,
1911  RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
1912  if ( !$this->mNewContent ) {
1913  $this->addRevisionLoadError( 'new' );
1914  }
1915 
1916  $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true;
1917  $this->mTextLoaded = $oldRevision ? 2 : 1;
1918  $this->isContentOverridden = false;
1919  $this->slotDiffRenderers = null;
1920  }
1921 
1928  public function setTextLanguage( Language $lang ) {
1929  $this->mDiffLang = $lang;
1930  }
1931 
1944  public function mapDiffPrevNext( $old, $new ) {
1945  if ( $new === 'prev' ) {
1946  // Show diff between revision $old and the previous one. Get previous one from DB.
1947  $newid = intval( $old );
1948  $oldid = false;
1949  $newRev = $this->revisionStore->getRevisionById( $newid );
1950  if ( $newRev ) {
1951  $oldRev = $this->revisionStore->getPreviousRevision( $newRev );
1952  if ( $oldRev ) {
1953  $oldid = $oldRev->getId();
1954  }
1955  }
1956  } elseif ( $new === 'next' ) {
1957  // Show diff between revision $old and the next one. Get next one from DB.
1958  $oldid = intval( $old );
1959  $newid = false;
1960  $oldRev = $this->revisionStore->getRevisionById( $oldid );
1961  if ( $oldRev ) {
1962  $newRev = $this->revisionStore->getNextRevision( $oldRev );
1963  if ( $newRev ) {
1964  $newid = $newRev->getId();
1965  }
1966  }
1967  } else {
1968  $oldid = intval( $old );
1969  $newid = intval( $new );
1970  }
1971 
1972  // @phan-suppress-next-line PhanTypeMismatchReturn getId does not return null here
1973  return [ $oldid, $newid ];
1974  }
1975 
1976  private function loadRevisionIds() {
1977  if ( $this->mRevisionsIdsLoaded ) {
1978  return;
1979  }
1980 
1981  $this->mRevisionsIdsLoaded = true;
1982 
1983  $old = $this->mOldid;
1984  $new = $this->mNewid;
1985 
1986  [ $this->mOldid, $this->mNewid ] = self::mapDiffPrevNext( $old, $new );
1987  if ( $new === 'next' && $this->mNewid === false ) {
1988  # if no result, NewId points to the newest old revision. The only newer
1989  # revision is cur, which is "0".
1990  $this->mNewid = 0;
1991  }
1992 
1993  $this->hookRunner->onNewDifferenceEngine(
1994  // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
1995  $this->getTitle(), $this->mOldid, $this->mNewid, $old, $new );
1996  }
1997 
2011  public function loadRevisionData() {
2012  if ( $this->mRevisionsLoaded ) {
2013  return $this->isContentOverridden ||
2014  ( $this->mOldRevisionRecord !== null && $this->mNewRevisionRecord !== null );
2015  }
2016 
2017  // Whether it succeeds or fails, we don't want to try again
2018  $this->mRevisionsLoaded = true;
2019 
2020  $this->loadRevisionIds();
2021 
2022  // Load the new RevisionRecord object
2023  if ( $this->mNewid ) {
2024  $this->mNewRevisionRecord = $this->revisionStore->getRevisionById( $this->mNewid );
2025  } else {
2026  $this->mNewRevisionRecord = $this->revisionStore->getRevisionByTitle( $this->getTitle() );
2027  }
2028 
2029  if ( !$this->mNewRevisionRecord instanceof RevisionRecord ) {
2030  return false;
2031  }
2032 
2033  // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
2034  $this->mNewid = $this->mNewRevisionRecord->getId();
2035  $this->mNewPage = $this->mNewid ?
2036  Title::newFromLinkTarget( $this->mNewRevisionRecord->getPageAsLinkTarget() ) :
2037  null;
2038 
2039  // Load the old RevisionRecord object
2040  $this->mOldRevisionRecord = false;
2041  if ( $this->mOldid ) {
2042  $this->mOldRevisionRecord = $this->revisionStore->getRevisionById( $this->mOldid );
2043  } elseif ( $this->mOldid === 0 ) {
2044  $revRecord = $this->revisionStore->getPreviousRevision( $this->mNewRevisionRecord );
2045  // No previous revision; mark to show as first-version only.
2046  $this->mOldid = $revRecord ? $revRecord->getId() : false;
2047  $this->mOldRevisionRecord = $revRecord ?? false;
2048  } /* elseif ( $this->mOldid === false ) leave mOldRevisionRecord false; */
2049 
2050  if ( $this->mOldRevisionRecord === null ) {
2051  return false;
2052  }
2053 
2054  if ( $this->mOldRevisionRecord && $this->mOldRevisionRecord->getId() ) {
2055  $this->mOldPage = Title::newFromLinkTarget(
2056  $this->mOldRevisionRecord->getPageAsLinkTarget()
2057  );
2058  } else {
2059  $this->mOldPage = null;
2060  }
2061 
2062  // Load tags information for both revisions
2063  $dbr = wfGetDB( DB_REPLICA );
2064  $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
2065  if ( $this->mOldid !== false ) {
2066  $tagIds = $dbr->selectFieldValues(
2067  'change_tag',
2068  'ct_tag_id',
2069  [ 'ct_rev_id' => $this->mOldid ],
2070  __METHOD__
2071  );
2072  $tags = [];
2073  foreach ( $tagIds as $tagId ) {
2074  try {
2075  $tags[] = $changeTagDefStore->getName( (int)$tagId );
2076  } catch ( NameTableAccessException $exception ) {
2077  continue;
2078  }
2079  }
2080  $this->mOldTags = implode( ',', $tags );
2081  } else {
2082  $this->mOldTags = false;
2083  }
2084 
2085  $tagIds = $dbr->selectFieldValues(
2086  'change_tag',
2087  'ct_tag_id',
2088  [ 'ct_rev_id' => $this->mNewid ],
2089  __METHOD__
2090  );
2091  $tags = [];
2092  foreach ( $tagIds as $tagId ) {
2093  try {
2094  $tags[] = $changeTagDefStore->getName( (int)$tagId );
2095  } catch ( NameTableAccessException $exception ) {
2096  continue;
2097  }
2098  }
2099  $this->mNewTags = implode( ',', $tags );
2100 
2101  return true;
2102  }
2103 
2112  public function loadText() {
2113  if ( $this->mTextLoaded == 2 ) {
2114  return $this->loadRevisionData() &&
2115  ( $this->mOldRevisionRecord === false || $this->mOldContent )
2116  && $this->mNewContent;
2117  }
2118 
2119  // Whether it succeeds or fails, we don't want to try again
2120  $this->mTextLoaded = 2;
2121 
2122  if ( !$this->loadRevisionData() ) {
2123  return false;
2124  }
2125 
2126  if ( $this->mOldRevisionRecord ) {
2127  $this->mOldContent = $this->mOldRevisionRecord->getContent(
2128  SlotRecord::MAIN,
2129  RevisionRecord::FOR_THIS_USER,
2130  $this->getAuthority()
2131  );
2132  if ( $this->mOldContent === null ) {
2133  return false;
2134  }
2135  }
2136 
2137  $this->mNewContent = $this->mNewRevisionRecord->getContent(
2138  SlotRecord::MAIN,
2139  RevisionRecord::FOR_THIS_USER,
2140  $this->getAuthority()
2141  );
2142  $this->hookRunner->onDifferenceEngineLoadTextAfterNewContentIsLoaded( $this );
2143  if ( $this->mNewContent === null ) {
2144  return false;
2145  }
2146 
2147  return true;
2148  }
2149 
2155  public function loadNewText() {
2156  if ( $this->mTextLoaded >= 1 ) {
2157  return $this->loadRevisionData();
2158  }
2159 
2160  $this->mTextLoaded = 1;
2161 
2162  if ( !$this->loadRevisionData() ) {
2163  return false;
2164  }
2165 
2166  $this->mNewContent = $this->mNewRevisionRecord->getContent(
2167  SlotRecord::MAIN,
2168  RevisionRecord::FOR_THIS_USER,
2169  $this->getAuthority()
2170  );
2171 
2172  $this->hookRunner->onDifferenceEngineAfterLoadNewText( $this );
2173 
2174  return true;
2175  }
2176 
2177 }
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, $page, MessageLocalizer $localizer=null)
Creates HTML for the given tags.
Definition: ChangeTags.php:196
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.
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.
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.
static intermediateEditsMsg( $numEdits, $numUsers, $limit)
Get a notice about how many intermediate edits and users there are.
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.
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:236
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:214
static warningBox( $html, $className='')
Return a warning box.
Definition: Html.php:775
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
Definition: Html.php:256
Base class for language-specific code.
Definition: Language.php:56
MediaWiki exception.
Definition: MWException.php:30
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:561
Class that generates HTML anchor link elements for pages.
Some internal bits split of from Skin.php.
Definition: Linker.php:65
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.
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.
static newFromLinkTarget(LinkTarget $linkTarget, $forceClone='')
Returns a Title given a LinkTarget.
Definition: Title.php:285
static makeTitleSafe( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:667
static makeTitle( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:641
Base representation for an editable wiki page.
Definition: WikiPage.php:72
makeParserOptions( $context)
Get parser options suitable for rendering the primary article wikitext.
Definition: WikiPage.php:1946
getParserOutput(?ParserOptions $parserOptions=null, $oldid=null, $noCache=false)
Get a ParserOutput for the given ParserOptions and revision ID.
Definition: WikiPage.php:1282
Base interface for representing page content.
Definition: Content.php:36
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