MediaWiki REL1_39
DifferenceEngine.php
Go to the documentation of this file.
1<?php
37
61
62 use DeprecationHelper;
63
70 private const DIFF_VERSION = '1.12';
71
78 protected $mOldid;
79
86 protected $mNewid;
87
98 private $mOldRevisionRecord;
99
108 private $mNewRevisionRecord;
109
115 protected $mOldPage;
116
122 protected $mNewPage;
123
128 private $mOldTags;
129
134 private $mNewTags;
135
141 private $mOldContent;
142
148 private $mNewContent;
149
151 protected $mDiffLang;
152
154 private $mRevisionsIdsLoaded = false;
155
157 protected $mRevisionsLoaded = false;
158
160 protected $mTextLoaded = 0;
161
170 protected $isContentOverridden = false;
171
173 protected $mCacheHit = false;
174
181 public $enableDebugComment = false;
182
186 protected $mReducedLineNumbers = false;
187
189 protected $mMarkPatrolledLink = null;
190
192 protected $unhide = false;
193
195 protected $mRefreshCache = false;
196
198 protected $slotDiffRenderers = null;
199
206 protected $isSlotDiffRenderer = false;
207
212 private $slotDiffOptions = [];
213
217 protected $linkRenderer;
218
222 private $contentHandlerFactory;
223
227 private $revisionStore;
228
230 private $hookRunner;
231
233 private $wikiPageFactory;
234
236 private $userOptionsLookup;
237
248 public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
249 $refreshCache = false, $unhide = false
250 ) {
251 $this->deprecatePublicProperty( 'mOldid', '1.32', __CLASS__ );
252 $this->deprecatePublicProperty( 'mNewid', '1.32', __CLASS__ );
253 $this->deprecatePublicProperty( 'mOldPage', '1.32', __CLASS__ );
254 $this->deprecatePublicProperty( 'mNewPage', '1.32', __CLASS__ );
255 $this->deprecatePublicProperty( 'mOldContent', '1.32', __CLASS__ );
256 $this->deprecatePublicProperty( 'mNewContent', '1.32', __CLASS__ );
257 $this->deprecatePublicProperty( 'mRevisionsLoaded', '1.32', __CLASS__ );
258 $this->deprecatePublicProperty( 'mTextLoaded', '1.32', __CLASS__ );
259 $this->deprecatePublicProperty( 'mCacheHit', '1.32', __CLASS__ );
260
261 if ( $context instanceof IContextSource ) {
262 $this->setContext( $context );
263 }
264
265 wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'" );
266
267 $this->mOldid = $old;
268 $this->mNewid = $new;
269 $this->mRefreshCache = $refreshCache;
270 $this->unhide = $unhide;
271
272 $services = MediaWikiServices::getInstance();
273 $this->linkRenderer = $services->getLinkRenderer();
274 $this->contentHandlerFactory = $services->getContentHandlerFactory();
275 $this->revisionStore = $services->getRevisionStore();
276 $this->hookRunner = new HookRunner( $services->getHookContainer() );
277 $this->wikiPageFactory = $services->getWikiPageFactory();
278 $this->userOptionsLookup = $services->getUserOptionsLookup();
279 }
280
285 protected function getSlotDiffRenderers() {
286 if ( $this->isSlotDiffRenderer ) {
287 throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
288 }
289
290 if ( $this->slotDiffRenderers === null ) {
291 if ( !$this->loadRevisionData() ) {
292 return [];
293 }
294
295 $slotContents = $this->getSlotContents();
296 $this->slotDiffRenderers = array_map( function ( array $contents ) {
298 $content = $contents['new'] ?: $contents['old'];
299 return $content->getContentHandler()->getSlotDiffRenderer(
300 $this->getContext(),
301 $this->slotDiffOptions
302 );
303 }, $slotContents );
304 }
305
306 return $this->slotDiffRenderers;
307 }
308
315 public function markAsSlotDiffRenderer() {
316 $this->isSlotDiffRenderer = true;
317 }
318
324 protected function getSlotContents() {
325 if ( $this->isContentOverridden ) {
326 return [
327 SlotRecord::MAIN => [ 'old' => $this->mOldContent, 'new' => $this->mNewContent ]
328 ];
329 } elseif ( !$this->loadRevisionData() ) {
330 return [];
331 }
332
333 $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
334 $oldSlots = $this->mOldRevisionRecord ?
335 $this->mOldRevisionRecord->getPrimarySlots()->getSlots() :
336 [];
337 // The order here will determine the visual order of the diff. The current logic is
338 // slots of the new revision first in natural order, then deleted ones. This is ad hoc
339 // and should not be relied on - in the future we may want the ordering to depend
340 // on the page type.
341 $roles = array_keys( array_merge( $newSlots, $oldSlots ) );
342
343 $slots = [];
344 foreach ( $roles as $role ) {
345 $slots[$role] = [
346 'old' => isset( $oldSlots[$role] ) ? $oldSlots[$role]->getContent() : null,
347 'new' => isset( $newSlots[$role] ) ? $newSlots[$role]->getContent() : null,
348 ];
349 }
350 // move main slot to front
351 if ( isset( $slots[SlotRecord::MAIN] ) ) {
352 $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
353 }
354 return $slots;
355 }
356
358 public function getTitle() {
359 // T202454 avoid errors when there is no title
360 return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
361 }
362
369 public function setReducedLineNumbers( $value = true ) {
370 $this->mReducedLineNumbers = $value;
371 }
372
378 public function getDiffLang() {
379 if ( $this->mDiffLang === null ) {
380 # Default language in which the diff text is written.
381 $this->mDiffLang = $this->getTitle()->getPageLanguage();
382 }
383
384 return $this->mDiffLang;
385 }
386
390 public function wasCacheHit() {
391 return $this->mCacheHit;
392 }
393
401 public function getOldid() {
402 $this->loadRevisionIds();
403
404 return $this->mOldid;
405 }
406
413 public function getNewid() {
414 $this->loadRevisionIds();
415
416 return $this->mNewid;
417 }
418
425 public function getOldRevision() {
426 return $this->mOldRevisionRecord ?: null;
427 }
428
434 public function getNewRevision() {
435 return $this->mNewRevisionRecord;
436 }
437
446 public function deletedLink( $id ) {
447 if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
449 $arQuery = $this->revisionStore->getArchiveQueryInfo();
450 $row = $dbr->selectRow(
451 $arQuery['tables'],
452 array_merge( $arQuery['fields'], [ 'ar_namespace', 'ar_title' ] ),
453 [ 'ar_rev_id' => $id ],
454 __METHOD__,
455 [],
456 $arQuery['joins']
457 );
458 if ( $row ) {
459 $revRecord = $this->revisionStore->newRevisionFromArchiveRow( $row );
460 $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
461
462 return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
463 'target' => $title->getPrefixedText(),
464 'timestamp' => $revRecord->getTimestamp()
465 ] );
466 }
467 }
468
469 return false;
470 }
471
479 public function deletedIdMarker( $id ) {
480 $link = $this->deletedLink( $id );
481 if ( $link ) {
482 return "[$link $id]";
483 } else {
484 return (string)$id;
485 }
486 }
487
488 private function showMissingRevision() {
489 $out = $this->getOutput();
490
491 $missing = [];
492 if ( $this->mOldid && ( !$this->mOldRevisionRecord || !$this->mOldContent ) ) {
493 $missing[] = $this->deletedIdMarker( $this->mOldid );
494 }
495 if ( !$this->mNewRevisionRecord || !$this->mNewContent ) {
496 $missing[] = $this->deletedIdMarker( $this->mNewid );
497 }
498
499 $out->setPageTitle( $this->msg( 'errorpagetitle' ) );
500 $msg = $this->msg( 'difference-missing-revision' )
501 ->params( $this->getLanguage()->listToText( $missing ) )
502 ->numParams( count( $missing ) )
503 ->parseAsBlock();
504 $out->addHTML( $msg );
505 }
506
512 public function hasDeletedRevision() {
513 $this->loadRevisionData();
514 return (
515 $this->mNewRevisionRecord &&
516 $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
517 ) ||
518 (
519 $this->mOldRevisionRecord &&
520 $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
521 );
522 }
523
530 public function getPermissionErrors( Authority $performer ) {
531 $this->loadRevisionData();
532 $permStatus = PermissionStatus::newEmpty();
533 if ( $this->mNewPage ) {
534 $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
535 }
536 if ( $this->mOldPage ) {
537 $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
538 }
539 return $permStatus->toLegacyErrorArray();
540 }
541
547 public function hasSuppressedRevision() {
548 return $this->hasDeletedRevision() && (
549 ( $this->mOldRevisionRecord &&
550 $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
551 ( $this->mNewRevisionRecord &&
552 $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
553 );
554 }
555
567 public function isUserAllowedToSeeRevisions( Authority $performer ) {
568 $this->loadRevisionData();
569
570 if ( $this->mOldRevisionRecord && !$this->mOldRevisionRecord->userCan(
571 RevisionRecord::DELETED_TEXT,
572 $performer
573 ) ) {
574 return false;
575 }
576
577 // $this->mNewRev will only be falsy if a loading error occurred
578 // (in which case the user is allowed to see).
579 return !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
580 RevisionRecord::DELETED_TEXT,
581 $performer
582 );
583 }
584
592 public function shouldBeHiddenFromUser( Authority $performer ) {
593 return $this->hasDeletedRevision() && ( !$this->unhide ||
594 !$this->isUserAllowedToSeeRevisions( $performer ) );
595 }
596
600 public function showDiffPage( $diffOnly = false ) {
601 # Allow frames except in certain special cases
602 $out = $this->getOutput();
603 $out->setPreventClickjacking( false );
604 $out->setRobotPolicy( 'noindex,nofollow' );
605
606 // Allow extensions to add any extra output here
607 $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
608
609 if ( !$this->loadRevisionData() ) {
610 if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
611 $this->showMissingRevision();
612 }
613 return;
614 }
615
616 $user = $this->getUser();
617 $permErrors = $this->getPermissionErrors( $this->getAuthority() );
618 if ( $permErrors ) {
619 throw new PermissionsError( 'read', $permErrors );
620 }
621
622 $rollback = '';
623
624 $query = $this->slotDiffOptions;
625 # Carry over 'diffonly' param via navigation links
626 if ( $diffOnly != MediaWikiServices::getInstance()
627 ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
628 ) {
629 $query['diffonly'] = $diffOnly;
630 }
631 # Cascade unhide param in links for easy deletion browsing
632 if ( $this->unhide ) {
633 $query['unhide'] = 1;
634 }
635
636 # Check if one of the revisions is deleted/suppressed
637 $deleted = $this->hasDeletedRevision();
638 $suppressed = $this->hasSuppressedRevision();
639 $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
640
641 $revisionTools = [];
642
643 # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
644 # a diff between a version V and its previous version V' AND the version V
645 # is the first version of that article. In that case, V' does not exist.
646 if ( $this->mOldRevisionRecord === false ) {
647 if ( $this->mNewPage ) {
648 $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
649 }
650 $samePage = true;
651 $oldHeader = '';
652 // Allow extensions to change the $oldHeader variable
653 $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
654 } else {
655 $this->hookRunner->onDifferenceEngineViewHeader( $this );
656
657 if ( !$this->mOldPage || !$this->mNewPage ) {
658 // XXX say something to the user?
659 $samePage = false;
660 } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
661 $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
662 $samePage = true;
663 } else {
664 $out->setPageTitle( $this->msg( 'difference-title-multipage',
665 $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
666 $out->addSubtitle( $this->msg( 'difference-multipage' ) );
667 $samePage = false;
668 }
669
670 if ( $samePage && $this->mNewPage &&
671 $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
672 ) {
673 if ( $this->mNewRevisionRecord->isCurrent() &&
674 $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
675 ) {
676 $rollbackLink = Linker::generateRollback(
677 $this->mNewRevisionRecord,
678 $this->getContext(),
679 [ 'noBrackets' ]
680 );
681 if ( $rollbackLink ) {
682 $out->setPreventClickjacking( true );
683 $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
684 }
685 }
686
687 if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
688 $this->userCanEdit( $this->mNewRevisionRecord )
689 ) {
690 $undoLink = $this->linkRenderer->makeKnownLink(
691 $this->mNewPage,
692 $this->msg( 'editundo' )->text(),
693 [ 'title' => Linker::titleAttrib( 'undo' ) ],
694 [
695 'action' => 'edit',
696 'undoafter' => $this->mOldid,
697 'undo' => $this->mNewid
698 ]
699 );
700 $revisionTools['mw-diff-undo'] = $undoLink;
701 }
702 }
703 # Make "previous revision link"
704 $hasPrevious = $samePage && $this->mOldPage &&
705 $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
706 if ( $hasPrevious ) {
707 $prevlink = $this->linkRenderer->makeKnownLink(
708 $this->mOldPage,
709 $this->msg( 'previousdiff' )->text(),
710 [ 'id' => 'differences-prevlink' ],
711 [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query
712 );
713 } else {
714 $prevlink = "\u{00A0}";
715 }
716
717 if ( $this->mOldRevisionRecord->isMinor() ) {
718 $oldminor = ChangesList::flag( 'minor' );
719 } else {
720 $oldminor = '';
721 }
722
723 $oldRevRecord = $this->mOldRevisionRecord;
724
725 $ldel = $this->revisionDeleteLink( $oldRevRecord );
726 $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
727 $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
728 $oldRevComment = Linker::revComment( $oldRevRecord, !$diffOnly, !$this->unhide );
729
730 if ( $oldRevComment === '' ) {
731 $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
732 $oldRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
733 }
734
735 $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
736 '<div id="mw-diff-otitle2">' .
737 Linker::revUserTools( $oldRevRecord, !$this->unhide ) . '</div>' .
738 '<div id="mw-diff-otitle3">' . $oldminor . $oldRevComment . $ldel . '</div>' .
739 '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
740 '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
741
742 // Allow extensions to change the $oldHeader variable
743 $this->hookRunner->onDifferenceEngineOldHeader(
744 $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
745 }
746
747 $out->addJsConfigVars( [
748 'wgDiffOldId' => $this->mOldid,
749 'wgDiffNewId' => $this->mNewid,
750 ] );
751
752 # Make "next revision link"
753 # Skip next link on the top revision
754 if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
755 $nextlink = $this->linkRenderer->makeKnownLink(
756 $this->mNewPage,
757 $this->msg( 'nextdiff' )->text(),
758 [ 'id' => 'differences-nextlink' ],
759 [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query
760 );
761 } else {
762 $nextlink = "\u{00A0}";
763 }
764
765 if ( $this->mNewRevisionRecord->isMinor() ) {
766 $newminor = ChangesList::flag( 'minor' );
767 } else {
768 $newminor = '';
769 }
770
771 # Handle RevisionDelete links...
772 $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
773
774 # Allow extensions to define their own revision tools
775 $this->hookRunner->onDiffTools(
776 $this->mNewRevisionRecord,
777 $revisionTools,
778 $this->mOldRevisionRecord ?: null,
779 $user
780 );
781
782 $formattedRevisionTools = [];
783 // Put each one in parentheses (poor man's button)
784 foreach ( $revisionTools as $key => $tool ) {
785 $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
786 $element = Html::rawElement(
787 'span',
788 [ 'class' => $toolClass ],
789 $this->msg( 'parentheses' )->rawParams( $tool )->escaped()
790 );
791 $formattedRevisionTools[] = $element;
792 }
793
794 $newRevRecord = $this->mNewRevisionRecord;
795
796 $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
797 ' ' . implode( ' ', $formattedRevisionTools );
798 $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
799 $newRevComment = Linker::revComment( $newRevRecord, !$diffOnly, !$this->unhide );
800
801 if ( $newRevComment === '' ) {
802 $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
803 $newRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
804 }
805
806 $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
807 '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
808 " $rollback</div>" .
809 '<div id="mw-diff-ntitle3">' . $newminor . $newRevComment . $rdel . '</div>' .
810 '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
811 '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
812
813 // Allow extensions to change the $newHeader variable
814 $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
815 $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
816 $rdel, $this->unhide );
817
818 # If the diff cannot be shown due to a deleted revision, then output
819 # the diff header and links to unhide (if available)...
820 if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
821 $this->showDiffStyle();
822 $multi = $this->getMultiNotice();
823 $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
824 if ( !$allowed ) {
825 # Give explanation for why revision is not visible
826 $msg = [ $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff' ];
827 } else {
828 # Give explanation and add a link to view the diff...
829 $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
830 $msg = [
831 $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff',
832 $this->getTitle()->getFullURL( $query )
833 ];
834 }
835 $out->addHtml( Html::warningBox( $this->msg( ...$msg )->parse(), 'plainlinks' ) );
836 # Otherwise, output a regular diff...
837 } else {
838 # Add deletion notice if the user is viewing deleted content
839 $notice = '';
840 if ( $deleted ) {
841 $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
842 $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' );
843 }
844 $this->showDiff( $oldHeader, $newHeader, $notice );
845 if ( !$diffOnly ) {
846 $this->renderNewRevision();
847 }
848 }
849 }
850
861 public function markPatrolledLink() {
862 if ( $this->mMarkPatrolledLink === null ) {
863 $linkInfo = $this->getMarkPatrolledLinkInfo();
864 // If false, there is no patrol link needed/allowed
865 if ( !$linkInfo || !$this->mNewPage ) {
866 $this->mMarkPatrolledLink = '';
867 } else {
868 $this->mMarkPatrolledLink = ' <span class="patrollink" data-mw="interface">[' .
869 $this->linkRenderer->makeKnownLink(
870 $this->mNewPage,
871 $this->msg( 'markaspatrolleddiff' )->text(),
872 [],
873 [
874 'action' => 'markpatrolled',
875 'rcid' => $linkInfo['rcid'],
876 ]
877 ) . ']</span>';
878 // Allow extensions to change the markpatrolled link
879 $this->hookRunner->onDifferenceEngineMarkPatrolledLink( $this,
880 $this->mMarkPatrolledLink, $linkInfo['rcid'] );
881 }
882 }
883 return $this->mMarkPatrolledLink;
884 }
885
893 protected function getMarkPatrolledLinkInfo() {
894 $user = $this->getUser();
895 $config = $this->getConfig();
896
897 // Prepare a change patrol link, if applicable
898 if (
899 // Is patrolling enabled and the user allowed to?
900 $config->get( MainConfigNames::UseRCPatrol ) &&
901 $this->mNewPage &&
902 $this->getAuthority()->probablyCan( 'patrol', $this->mNewPage ) &&
903 // Only do this if the revision isn't more than 6 hours older
904 // than the Max RC age (6h because the RC might not be cleaned out regularly)
905 RecentChange::isInRCLifespan( $this->mNewRevisionRecord->getTimestamp(), 21600 )
906 ) {
907 // Look for an unpatrolled change corresponding to this diff
908 $change = RecentChange::newFromConds(
909 [
910 'rc_this_oldid' => $this->mNewid,
911 'rc_patrolled' => RecentChange::PRC_UNPATROLLED
912 ],
913 __METHOD__
914 );
915
916 if ( $change && !$change->getPerformerIdentity()->equals( $user ) ) {
917 $rcid = $change->getAttribute( 'rc_id' );
918 } else {
919 // None found or the page has been created by the current user.
920 // If the user could patrol this it already would be patrolled
921 $rcid = 0;
922 }
923
924 // Allow extensions to possibly change the rcid here
925 // For example the rcid might be set to zero due to the user
926 // being the same as the performer of the change but an extension
927 // might still want to show it under certain conditions
928 $this->hookRunner->onDifferenceEngineMarkPatrolledRCID( $rcid, $this, $change, $user );
929
930 // Build the link
931 if ( $rcid ) {
932 $this->getOutput()->setPreventClickjacking( true );
933 if ( $this->getAuthority()->isAllowed( 'writeapi' ) ) {
934 $this->getOutput()->addModules( 'mediawiki.misc-authed-curate' );
935 }
936
937 return [ 'rcid' => $rcid ];
938 }
939 }
940
941 // No mark as patrolled link applicable
942 return false;
943 }
944
950 private function revisionDeleteLink( RevisionRecord $revRecord ) {
952 $this->getAuthority(),
953 $revRecord,
954 $revRecord->getPageAsLinkTarget()
955 );
956 if ( $link !== '' ) {
957 $link = "\u{00A0}\u{00A0}\u{00A0}" . $link . ' ';
958 }
959
960 return $link;
961 }
962
968 public function renderNewRevision() {
969 if ( $this->isContentOverridden ) {
970 // The code below only works with a RevisionRecord object. We could construct a
971 // fake RevisionRecord (here or in setContent), but since this does not seem
972 // needed at the moment, we'll just fail for now.
973 throw new LogicException(
974 __METHOD__
975 . ' is not supported after calling setContent(). Use setRevisions() instead.'
976 );
977 }
978
979 $out = $this->getOutput();
980 $revHeader = $this->getRevisionHeader( $this->mNewRevisionRecord );
981 # Add "current version as of X" title
982 $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
983 <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
984 # Page content may be handled by a hooked call instead...
985 if ( $this->hookRunner->onArticleContentOnDiff( $this, $out ) ) {
986 $this->loadNewText();
987 if ( !$this->mNewPage ) {
988 // New revision is unsaved; bail out.
989 // TODO in theory rendering the new revision is a meaningful thing to do
990 // even if it's unsaved, but a lot of untangling is required to do it safely.
991 return;
992 }
993
994 $out->setRevisionId( $this->mNewid );
995 $out->setRevisionIsCurrent( $this->mNewRevisionRecord->isCurrent() );
996 $out->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() );
997 $out->setArticleFlag( true );
998
999 if ( !$this->hookRunner->onArticleRevisionViewCustom(
1000 $this->mNewRevisionRecord, $this->mNewPage, $this->mOldid, $out )
1001 ) {
1002 // Handled by extension
1003 // NOTE: sync with hooks called in Article::view()
1004 } else {
1005 // Normal page
1006 if ( $this->getTitle()->equals( $this->mNewPage ) ) {
1007 // If the Title stored in the context is the same as the one
1008 // of the new revision, we can use its associated WikiPage
1009 // object.
1010 $wikiPage = $this->getWikiPage();
1011 } else {
1012 // Otherwise we need to create our own WikiPage object
1013 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->mNewPage );
1014 }
1015
1016 $parserOutput = $this->getParserOutput( $wikiPage, $this->mNewRevisionRecord );
1017
1018 # WikiPage::getParserOutput() should not return false, but just in case
1019 if ( $parserOutput ) {
1020 // Allow extensions to change parser output here
1021 if ( $this->hookRunner->onDifferenceEngineRenderRevisionAddParserOutput(
1022 $this, $out, $parserOutput, $wikiPage )
1023 ) {
1024 $skinOptions = $this->getSkin()->getOptions();
1025 $out->setSections( $parserOutput->getSections() );
1026 $out->addParserOutput( $parserOutput, [
1027 // phab:T311529 - diffs should respect skin
1028 'injectTOC' => $skinOptions['toc'],
1029 'enableSectionEditLinks' => $this->mNewRevisionRecord->isCurrent()
1030 && $this->getAuthority()->probablyCan(
1031 'edit',
1032 $this->mNewRevisionRecord->getPage()
1033 ),
1034 'absoluteURLs' => $this->slotDiffOptions['expand-url'] ?? false
1035 ] );
1036 }
1037 }
1038 }
1039 }
1040
1041 // Allow extensions to optionally not show the final patrolled link
1042 if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1043 # Add redundant patrol link on bottom...
1044 $out->addHTML( $this->markPatrolledLink() );
1045 }
1046 }
1047
1054 protected function getParserOutput( WikiPage $page, RevisionRecord $revRecord ) {
1055 if ( !$revRecord->getId() ) {
1056 // WikiPage::getParserOutput wants a revision ID. Passing 0 will incorrectly show
1057 // the current revision, so fail instead. If need be, WikiPage::getParserOutput
1058 // could be made to accept a RevisionRecord instead of the id.
1059 return false;
1060 }
1061
1062 $parserOptions = $page->makeParserOptions( $this->getContext() );
1063 return $page->getParserOutput( $parserOptions, $revRecord->getId() );
1064 }
1065
1076 public function showDiff( $otitle, $ntitle, $notice = '' ) {
1077 // Allow extensions to affect the output here
1078 $this->hookRunner->onDifferenceEngineShowDiff( $this );
1079
1080 $diff = $this->getDiff( $otitle, $ntitle, $notice );
1081 if ( $diff === false ) {
1082 $this->showMissingRevision();
1083 return false;
1084 }
1085
1086 $this->showDiffStyle();
1087 if ( $this->slotDiffOptions['expand-url'] ?? false ) {
1088 $diff = Linker::expandLocalLinks( $diff );
1089 }
1090 $this->getOutput()->addHTML( $diff );
1091 return true;
1092 }
1093
1097 public function showDiffStyle() {
1098 if ( !$this->isSlotDiffRenderer ) {
1099 $this->getOutput()->addModules( 'mediawiki.diff' );
1100 $this->getOutput()->addModuleStyles( [
1101 'mediawiki.interface.helpers.styles',
1102 'mediawiki.diff.styles'
1103 ] );
1104 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1105 $slotDiffRenderer->addModules( $this->getOutput() );
1106 }
1107 }
1108 }
1109
1119 public function getDiff( $otitle, $ntitle, $notice = '' ) {
1120 $body = $this->getDiffBody();
1121 if ( $body === false ) {
1122 return false;
1123 }
1124
1125 $multi = $this->getMultiNotice();
1126 // Display a message when the diff is empty
1127 if ( $body === '' ) {
1128 $notice .= '<div class="mw-diff-empty">' .
1129 $this->msg( 'diff-empty' )->parse() .
1130 "</div>\n";
1131 }
1132
1133 return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
1134 }
1135
1141 public function getDiffBody() {
1142 $this->mCacheHit = true;
1143 // Check if the diff should be hidden from this user
1144 if ( !$this->isContentOverridden ) {
1145 if ( !$this->loadRevisionData() ) {
1146 return false;
1147 } elseif ( $this->mOldRevisionRecord &&
1148 !$this->mOldRevisionRecord->userCan(
1149 RevisionRecord::DELETED_TEXT,
1150 $this->getAuthority()
1151 )
1152 ) {
1153 return false;
1154 } elseif ( $this->mNewRevisionRecord &&
1155 !$this->mNewRevisionRecord->userCan(
1156 RevisionRecord::DELETED_TEXT,
1157 $this->getAuthority()
1158 ) ) {
1159 return false;
1160 }
1161 // Short-circuit
1162 if ( $this->mOldRevisionRecord === false || (
1163 $this->mOldRevisionRecord &&
1164 $this->mNewRevisionRecord &&
1165 $this->mOldRevisionRecord->getId() &&
1166 $this->mOldRevisionRecord->getId() == $this->mNewRevisionRecord->getId()
1167 ) ) {
1168 if ( $this->hookRunner->onDifferenceEngineShowEmptyOldContent( $this ) ) {
1169 return '';
1170 }
1171 }
1172 }
1173
1174 // Cacheable?
1175 $key = false;
1176 $services = MediaWikiServices::getInstance();
1177 $cache = $services->getMainWANObjectCache();
1178 $stats = $services->getStatsdDataFactory();
1179 if ( $this->mOldid && $this->mNewid ) {
1180 // Check if subclass is still using the old way
1181 // for backwards-compatibility
1182 $detected = MWDebug::detectDeprecatedOverride(
1183 $this,
1184 __CLASS__,
1185 'getDiffBodyCacheKey',
1186 '1.31'
1187 );
1188 $key = null;
1189 if ( $detected ) {
1190 $key = $this->getDiffBodyCacheKey();
1191 }
1192 if ( $key === null ) {
1193 $key = $cache->makeKey( ...$this->getDiffBodyCacheKeyParams() );
1194 }
1195
1196 // Try cache
1197 if ( !$this->mRefreshCache ) {
1198 $difftext = $cache->get( $key );
1199 if ( is_string( $difftext ) ) {
1200 $stats->updateCount( 'diff_cache.hit', 1 );
1201 $difftext = $this->localiseDiff( $difftext );
1202 $difftext .= "\n<!-- diff cache key $key -->\n";
1203
1204 return $difftext;
1205 }
1206 } // don't try to load but save the result
1207 }
1208 $this->mCacheHit = false;
1209
1210 // Loadtext is permission safe, this just clears out the diff
1211 if ( !$this->loadText() ) {
1212 return false;
1213 }
1214
1215 $difftext = '';
1216 // We've checked for revdelete at the beginning of this method; it's OK to ignore
1217 // read permissions here.
1218 $slotContents = $this->getSlotContents();
1219 foreach ( $this->getSlotDiffRenderers() as $role => $slotDiffRenderer ) {
1220 $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'],
1221 $slotContents[$role]['new'] );
1222 if ( $slotDiff && $role !== SlotRecord::MAIN ) {
1223 // FIXME: ask SlotRoleHandler::getSlotNameMessage
1224 $slotTitle = $role;
1225 $difftext .= $this->getSlotHeader( $slotTitle );
1226 }
1227 $difftext .= $slotDiff;
1228 }
1229
1230 // Save to cache for 7 days
1231 if ( !$this->hookRunner->onAbortDiffCache( $this ) ) {
1232 $stats->updateCount( 'diff_cache.uncacheable', 1 );
1233 } elseif ( $key !== false ) {
1234 $stats->updateCount( 'diff_cache.miss', 1 );
1235 $cache->set( $key, $difftext, 7 * 86400 );
1236 } else {
1237 $stats->updateCount( 'diff_cache.uncacheable', 1 );
1238 }
1239 // localise line numbers and title attribute text
1240 $difftext = $this->localiseDiff( $difftext );
1241
1242 return $difftext;
1243 }
1244
1251 public function getDiffBodyForRole( $role ) {
1252 $diffRenderers = $this->getSlotDiffRenderers();
1253 if ( !isset( $diffRenderers[$role] ) ) {
1254 return false;
1255 }
1256
1257 $slotContents = $this->getSlotContents();
1258 $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'],
1259 $slotContents[$role]['new'] );
1260 if ( !$slotDiff ) {
1261 return false;
1262 }
1263
1264 if ( $role !== SlotRecord::MAIN ) {
1265 // TODO use human-readable role name at least
1266 $slotTitle = $role;
1267 $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff;
1268 }
1269
1270 return $this->localiseDiff( $slotDiff );
1271 }
1272
1280 protected function getSlotHeader( $headerText ) {
1281 // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1282 $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1283 $userLang = $this->getLanguage()->getHtmlCode();
1284 return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-header', 'lang' => $userLang ],
1285 Html::element( 'th', [ 'colspan' => $columnCount ], $headerText ) );
1286 }
1287
1298 protected function getDiffBodyCacheKey() {
1299 wfDeprecated( __METHOD__, '1.31' );
1300 return null;
1301 }
1302
1317 protected function getDiffBodyCacheKeyParams() {
1318 if ( !$this->mOldid || !$this->mNewid ) {
1319 throw new MWException( 'mOldid and mNewid must be set to get diff cache key.' );
1320 }
1321
1322 $engine = $this->getEngine();
1323 $params = [
1324 'diff',
1325 $engine === 'php' ? false : $engine, // Back compat
1326 self::DIFF_VERSION,
1327 "old-{$this->mOldid}",
1328 "rev-{$this->mNewid}"
1329 ];
1330
1331 if ( $engine === 'wikidiff2' ) {
1332 $params[] = phpversion( 'wikidiff2' );
1333 }
1334
1335 if ( !$this->isSlotDiffRenderer ) {
1336 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1337 $params = array_merge( $params, $slotDiffRenderer->getExtraCacheKeys() );
1338 }
1339 }
1340
1341 return $params;
1342 }
1343
1351 public function getExtraCacheKeys() {
1352 // This method is called when the DifferenceEngine is used for a slot diff. We only care
1353 // about special things, not the revision IDs, which are added to the cache key by the
1354 // page-level DifferenceEngine, and which might not have a valid value for this object.
1355 $this->mOldid = 123456789;
1356 $this->mNewid = 987654321;
1357
1358 // This will repeat a bunch of unnecessary key fields for each slot. Not nice but harmless.
1359 $detected = MWDebug::detectDeprecatedOverride(
1360 $this,
1361 __CLASS__,
1362 'getDiffBodyCacheKey',
1363 '1.31'
1364 );
1365 if ( $detected ) {
1366 $cacheString = $this->getDiffBodyCacheKey();
1367 if ( $cacheString ) {
1368 return [ $cacheString ];
1369 }
1370 }
1371
1372 $params = $this->getDiffBodyCacheKeyParams();
1373
1374 // Try to get rid of the standard keys to keep the cache key human-readable:
1375 // call the getDiffBodyCacheKeyParams implementation of the base class, and if
1376 // the child class includes the same keys, drop them.
1377 // Uses an obscure PHP feature where static calls to non-static methods are allowed
1378 // as long as we are already in a non-static method of the same class, and the call context
1379 // ($this) will be inherited.
1380 // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
1382 if ( array_slice( $params, 0, count( $standardParams ) ) === $standardParams ) {
1383 $params = array_slice( $params, count( $standardParams ) );
1384 }
1385
1386 return $params;
1387 }
1388
1393 public function setSlotDiffOptions( $options ) {
1394 $this->slotDiffOptions = $options;
1395 }
1396
1410 public function generateContentDiffBody( Content $old, Content $new ) {
1411 $slotDiffRenderer = $new->getContentHandler()->getSlotDiffRenderer( $this->getContext() );
1412 if (
1413 $slotDiffRenderer instanceof DifferenceEngineSlotDiffRenderer
1414 && $this->isSlotDiffRenderer
1415 ) {
1416 // Oops, we are just about to enter an infinite loop (the slot-level DifferenceEngine
1417 // called a DifferenceEngineSlotDiffRenderer that wraps the same DifferenceEngine class).
1418 // This will happen when a content model has no custom slot diff renderer, it does have
1419 // a custom difference engine, but that does not override this method.
1420 throw new Exception( get_class( $this ) . ': could not maintain backwards compatibility. '
1421 . 'Please use a SlotDiffRenderer.' );
1422 }
1423 return $slotDiffRenderer->getDiff( $old, $new ) . $this->getDebugString();
1424 }
1425
1438 public function generateTextDiffBody( $otext, $ntext ) {
1439 $slotDiffRenderer = $this->contentHandlerFactory
1440 ->getContentHandler( CONTENT_MODEL_TEXT )
1441 ->getSlotDiffRenderer( $this->getContext() );
1442 if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1443 // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1444 // This is too unlikely to happen to bother handling properly.
1445 throw new Exception( 'The slot diff renderer for text content should be a '
1446 . 'TextSlotDiffRenderer subclass' );
1447 }
1448 return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1449 }
1450
1457 public static function getEngine() {
1458 $diffEngine = MediaWikiServices::getInstance()->getMainConfig()
1459 ->get( MainConfigNames::DiffEngine );
1460 $externalDiffEngine = MediaWikiServices::getInstance()->getMainConfig()
1461 ->get( MainConfigNames::ExternalDiffEngine );
1462
1463 if ( $diffEngine === null ) {
1464 $engines = [ 'external', 'wikidiff2', 'php' ];
1465 } else {
1466 $engines = [ $diffEngine ];
1467 }
1468
1469 $failureReason = null;
1470 foreach ( $engines as $engine ) {
1471 switch ( $engine ) {
1472 case 'external':
1473 if ( is_string( $externalDiffEngine ) ) {
1474 if ( is_executable( $externalDiffEngine ) ) {
1475 return $externalDiffEngine;
1476 }
1477 $failureReason = 'ExternalDiffEngine config points to a non-executable';
1478 if ( $diffEngine === null ) {
1479 wfDebug( "$failureReason, ignoring" );
1480 }
1481 } else {
1482 $failureReason = 'ExternalDiffEngine config is set to a non-string value';
1483 if ( $diffEngine === null && $externalDiffEngine ) {
1484 wfWarn( "$failureReason, ignoring" );
1485 }
1486 }
1487 break;
1488
1489 case 'wikidiff2':
1490 if ( function_exists( 'wikidiff2_do_diff' ) ) {
1491 return 'wikidiff2';
1492 }
1493 $failureReason = 'wikidiff2 is not available';
1494 break;
1495
1496 case 'php':
1497 // Always available.
1498 return 'php';
1499
1500 default:
1501 throw new DomainException( 'Invalid value for $wgDiffEngine: ' . $engine );
1502 }
1503 }
1504 throw new UnexpectedValueException( "Cannot use diff engine '$engine': $failureReason" );
1505 }
1506
1520 protected function textDiff( $otext, $ntext ) {
1521 wfDeprecated( __METHOD__, '1.32' );
1522 $slotDiffRenderer = $this->contentHandlerFactory
1523 ->getContentHandler( CONTENT_MODEL_TEXT )
1524 ->getSlotDiffRenderer( $this->getContext() );
1525 if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1526 // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1527 // This is too unlikely to happen to bother handling properly.
1528 throw new Exception( 'The slot diff renderer for text content should be a '
1529 . 'TextSlotDiffRenderer subclass' );
1530 }
1531 return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1532 }
1533
1542 protected function debug( $generator = "internal" ) {
1543 if ( !$this->enableDebugComment ) {
1544 return '';
1545 }
1546 $data = [ $generator ];
1547 if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) ) {
1548 $data[] = wfHostname();
1549 }
1550 $data[] = wfTimestamp( TS_DB );
1551
1552 return "<!-- diff generator: " .
1553 implode( " ", array_map( "htmlspecialchars", $data ) ) .
1554 " -->\n";
1555 }
1556
1560 private function getDebugString() {
1561 $engine = self::getEngine();
1562 if ( $engine === 'wikidiff2' ) {
1563 return $this->debug( 'wikidiff2' );
1564 } elseif ( $engine === 'php' ) {
1565 return $this->debug( 'native PHP' );
1566 } else {
1567 return $this->debug( "external $engine" );
1568 }
1569 }
1570
1577 private function localiseDiff( $text ) {
1578 $text = $this->localiseLineNumbers( $text );
1579 if ( $this->getEngine() === 'wikidiff2' &&
1580 version_compare( phpversion( 'wikidiff2' ), '1.5.1', '>=' )
1581 ) {
1582 $text = $this->addLocalisedTitleTooltips( $text );
1583 }
1584 return $text;
1585 }
1586
1594 public function localiseLineNumbers( $text ) {
1595 return preg_replace_callback(
1596 '/<!--LINE (\d+)-->/',
1597 function ( array $matches ) {
1598 if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
1599 return '';
1600 }
1601 return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
1602 },
1603 $text
1604 );
1605 }
1606
1613 private function addLocalisedTitleTooltips( $text ) {
1614 return preg_replace_callback(
1615 '/class="mw-diff-movedpara-(left|right)"/',
1616 function ( array $matches ) {
1617 $key = $matches[1] === 'right' ?
1618 'diff-paragraph-moved-toold' :
1619 'diff-paragraph-moved-tonew';
1620 return $matches[0] . ' title="' . $this->msg( $key )->escaped() . '"';
1621 },
1622 $text
1623 );
1624 }
1625
1631 public function getMultiNotice() {
1632 // The notice only make sense if we are diffing two saved revisions of the same page.
1633 if (
1634 !$this->mOldRevisionRecord || !$this->mNewRevisionRecord
1635 || !$this->mOldPage || !$this->mNewPage
1636 || !$this->mOldPage->equals( $this->mNewPage )
1637 || $this->mOldRevisionRecord->getId() === null
1638 || $this->mNewRevisionRecord->getId() === null
1639 // (T237709) Deleted revs might have different page IDs
1640 || $this->mNewPage->getArticleID() !== $this->mOldRevisionRecord->getPageId()
1641 || $this->mNewPage->getArticleID() !== $this->mNewRevisionRecord->getPageId()
1642 ) {
1643 return '';
1644 }
1645
1646 if ( $this->mOldRevisionRecord->getTimestamp() > $this->mNewRevisionRecord->getTimestamp() ) {
1647 $oldRevRecord = $this->mNewRevisionRecord; // flip
1648 $newRevRecord = $this->mOldRevisionRecord; // flip
1649 } else { // normal case
1650 $oldRevRecord = $this->mOldRevisionRecord;
1651 $newRevRecord = $this->mNewRevisionRecord;
1652 }
1653
1654 // Don't show the notice if too many rows must be scanned
1655 // @todo show some special message for that case
1656 $nEdits = 0;
1657 $revisionIdList = $this->revisionStore->getRevisionIdsBetween(
1658 $this->mNewPage->getArticleID(),
1659 $oldRevRecord,
1660 $newRevRecord,
1661 1000
1662 );
1663 // only count revisions that are visible
1664 if ( count( $revisionIdList ) > 0 ) {
1665 foreach ( $revisionIdList as $revisionId ) {
1666 $revision = $this->revisionStore->getRevisionById( $revisionId );
1667 if ( $revision->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ) ) {
1668 $nEdits++;
1669 }
1670 }
1671 }
1672 if ( $nEdits > 0 && $nEdits <= 1000 ) {
1673 $limit = 100; // use diff-multi-manyusers if too many users
1674 try {
1675 $users = $this->revisionStore->getAuthorsBetween(
1676 $this->mNewPage->getArticleID(),
1677 $oldRevRecord,
1678 $newRevRecord,
1679 null,
1680 $limit
1681 );
1682 $numUsers = count( $users );
1683
1684 $newRevUser = $newRevRecord->getUser( RevisionRecord::RAW );
1685 $newRevUserText = $newRevUser ? $newRevUser->getName() : '';
1686 if ( $numUsers == 1 && $users[0]->getName() == $newRevUserText ) {
1687 $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1688 }
1689 } catch ( InvalidArgumentException $e ) {
1690 $numUsers = 0;
1691 }
1692
1693 return self::intermediateEditsMsg( $nEdits, $numUsers, $limit );
1694 }
1695
1696 return '';
1697 }
1698
1708 public static function intermediateEditsMsg( $numEdits, $numUsers, $limit ) {
1709 if ( $numUsers === 0 ) {
1710 $msg = 'diff-multi-sameuser';
1711 } elseif ( $numUsers > $limit ) {
1712 $msg = 'diff-multi-manyusers';
1713 $numUsers = $limit;
1714 } else {
1715 $msg = 'diff-multi-otherusers';
1716 }
1717
1718 return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1719 }
1720
1725 private function userCanEdit( RevisionRecord $revRecord ) {
1726 if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1727 return false;
1728 }
1729
1730 return true;
1731 }
1732
1742 public function getRevisionHeader( RevisionRecord $rev, $complete = '' ) {
1743 $lang = $this->getLanguage();
1744 $user = $this->getUser();
1745 $revtimestamp = $rev->getTimestamp();
1746 $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1747 $dateofrev = $lang->userDate( $revtimestamp, $user );
1748 $timeofrev = $lang->userTime( $revtimestamp, $user );
1749
1750 $header = $this->msg(
1751 $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1752 $timestamp,
1753 $dateofrev,
1754 $timeofrev
1755 );
1756
1757 if ( $complete !== 'complete' ) {
1758 return $header->escaped();
1759 }
1760
1761 $title = $rev->getPageAsLinkTarget();
1762
1763 $header = $this->linkRenderer->makeKnownLink( $title, $header->text(), [],
1764 [ 'oldid' => $rev->getId() ] );
1765
1766 if ( $this->userCanEdit( $rev ) ) {
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 if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1782 $header = Html::rawElement(
1783 'span',
1784 [ 'class' => Linker::getRevisionDeletedClass( $rev ) ],
1785 $header
1786 );
1787 }
1788 } else {
1789 $header = Html::rawElement( 'span', [ 'class' => 'history-deleted' ], $header );
1790 }
1791
1792 return $header;
1793 }
1794
1807 public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
1808 // shared.css sets diff in interface language/dir, but the actual content
1809 // is often in a different language, mostly the page content language/dir
1810 $header = Html::openElement( 'table', [
1811 'class' => [
1812 'diff',
1813 'diff-contentalign-' . $this->getDiffLang()->alignStart(),
1814 'diff-editfont-' . $this->userOptionsLookup->getOption(
1815 $this->getUser(),
1816 'editfont'
1817 )
1818 ],
1819 'data-mw' => 'interface',
1820 ] );
1821 $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
1822
1823 if ( !$diff && !$otitle ) {
1824 $header .= "
1825 <tr class=\"diff-title\" lang=\"{$userLang}\">
1826 <td class=\"diff-ntitle\">{$ntitle}</td>
1827 </tr>";
1828 $multiColspan = 1;
1829 } else {
1830 if ( $diff ) { // Safari/Chrome show broken output if cols not used
1831 $header .= "
1832 <col class=\"diff-marker\" />
1833 <col class=\"diff-content\" />
1834 <col class=\"diff-marker\" />
1835 <col class=\"diff-content\" />";
1836 $colspan = 2;
1837 $multiColspan = 4;
1838 } else {
1839 $colspan = 1;
1840 $multiColspan = 2;
1841 }
1842 if ( $otitle || $ntitle ) {
1843 // FIXME Hardcoding values from TableDiffFormatter.
1844 $deletedClass = 'diff-side-deleted';
1845 $addedClass = 'diff-side-added';
1846 $header .= "
1847 <tr class=\"diff-title\" lang=\"{$userLang}\">
1848 <td colspan=\"$colspan\" class=\"diff-otitle {$deletedClass}\">{$otitle}</td>
1849 <td colspan=\"$colspan\" class=\"diff-ntitle {$addedClass}\">{$ntitle}</td>
1850 </tr>";
1851 }
1852 }
1853
1854 if ( $multi != '' ) {
1855 $header .= "<tr><td colspan=\"{$multiColspan}\" " .
1856 "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
1857 }
1858 if ( $notice != '' ) {
1859 $header .= "<tr><td colspan=\"{$multiColspan}\" " .
1860 "class=\"diff-notice\" lang=\"{$userLang}\">{$notice}</td></tr>";
1861 }
1862
1863 return $header . $diff . "</table>";
1864 }
1865
1873 public function setContent( Content $oldContent, Content $newContent ) {
1874 $this->mOldContent = $oldContent;
1875 $this->mNewContent = $newContent;
1876
1877 $this->mTextLoaded = 2;
1878 $this->mRevisionsLoaded = true;
1879 $this->isContentOverridden = true;
1880 $this->slotDiffRenderers = null;
1881 }
1882
1888 public function setRevisions(
1889 ?RevisionRecord $oldRevision, RevisionRecord $newRevision
1890 ) {
1891 if ( $oldRevision ) {
1892 $this->mOldRevisionRecord = $oldRevision;
1893 $this->mOldid = $oldRevision->getId();
1894 $this->mOldPage = Title::newFromLinkTarget( $oldRevision->getPageAsLinkTarget() );
1895 // This method is meant for edit diffs and such so there is no reason to provide a
1896 // revision that's not readable to the user, but check it just in case.
1897 $this->mOldContent = $oldRevision->getContent( SlotRecord::MAIN,
1898 RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
1899 } else {
1900 $this->mOldPage = null;
1901 $this->mOldRevisionRecord = $this->mOldid = false;
1902 }
1903 $this->mNewRevisionRecord = $newRevision;
1904 $this->mNewid = $newRevision->getId();
1905 $this->mNewPage = Title::newFromLinkTarget( $newRevision->getPageAsLinkTarget() );
1906 $this->mNewContent = $newRevision->getContent( SlotRecord::MAIN,
1907 RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
1908
1909 $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true;
1910 $this->mTextLoaded = $oldRevision ? 2 : 1;
1911 $this->isContentOverridden = false;
1912 $this->slotDiffRenderers = null;
1913 }
1914
1921 public function setTextLanguage( Language $lang ) {
1922 $this->mDiffLang = $lang;
1923 }
1924
1937 public function mapDiffPrevNext( $old, $new ) {
1938 if ( $new === 'prev' ) {
1939 // Show diff between revision $old and the previous one. Get previous one from DB.
1940 $newid = intval( $old );
1941 $oldid = false;
1942 $newRev = $this->revisionStore->getRevisionById( $newid );
1943 if ( $newRev ) {
1944 $oldRev = $this->revisionStore->getPreviousRevision( $newRev );
1945 if ( $oldRev ) {
1946 $oldid = $oldRev->getId();
1947 }
1948 }
1949 } elseif ( $new === 'next' ) {
1950 // Show diff between revision $old and the next one. Get next one from DB.
1951 $oldid = intval( $old );
1952 $newid = false;
1953 $oldRev = $this->revisionStore->getRevisionById( $oldid );
1954 if ( $oldRev ) {
1955 $newRev = $this->revisionStore->getNextRevision( $oldRev );
1956 if ( $newRev ) {
1957 $newid = $newRev->getId();
1958 }
1959 }
1960 } else {
1961 $oldid = intval( $old );
1962 $newid = intval( $new );
1963 }
1964
1965 // @phan-suppress-next-line PhanTypeMismatchReturn getId does not return null here
1966 return [ $oldid, $newid ];
1967 }
1968
1969 private function loadRevisionIds() {
1970 if ( $this->mRevisionsIdsLoaded ) {
1971 return;
1972 }
1973
1974 $this->mRevisionsIdsLoaded = true;
1975
1976 $old = $this->mOldid;
1977 $new = $this->mNewid;
1978
1979 list( $this->mOldid, $this->mNewid ) = self::mapDiffPrevNext( $old, $new );
1980 if ( $new === 'next' && $this->mNewid === false ) {
1981 # if no result, NewId points to the newest old revision. The only newer
1982 # revision is cur, which is "0".
1983 $this->mNewid = 0;
1984 }
1985
1986 $this->hookRunner->onNewDifferenceEngine(
1987 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
1988 $this->getTitle(), $this->mOldid, $this->mNewid, $old, $new );
1989 }
1990
2004 public function loadRevisionData() {
2005 if ( $this->mRevisionsLoaded ) {
2006 return $this->isContentOverridden ||
2007 ( $this->mOldRevisionRecord !== null && $this->mNewRevisionRecord !== null );
2008 }
2009
2010 // Whether it succeeds or fails, we don't want to try again
2011 $this->mRevisionsLoaded = true;
2012
2013 $this->loadRevisionIds();
2014
2015 // Load the new RevisionRecord object
2016 if ( $this->mNewid ) {
2017 $this->mNewRevisionRecord = $this->revisionStore->getRevisionById( $this->mNewid );
2018 } else {
2019 $this->mNewRevisionRecord = $this->revisionStore->getRevisionByTitle( $this->getTitle() );
2020 }
2021
2022 if ( !$this->mNewRevisionRecord instanceof RevisionRecord ) {
2023 return false;
2024 }
2025
2026 // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
2027 $this->mNewid = $this->mNewRevisionRecord->getId();
2028 $this->mNewPage = $this->mNewid ?
2029 Title::newFromLinkTarget( $this->mNewRevisionRecord->getPageAsLinkTarget() ) :
2030 null;
2031
2032 // Load the old RevisionRecord object
2033 $this->mOldRevisionRecord = false;
2034 if ( $this->mOldid ) {
2035 $this->mOldRevisionRecord = $this->revisionStore->getRevisionById( $this->mOldid );
2036 } elseif ( $this->mOldid === 0 ) {
2037 $revRecord = $this->revisionStore->getPreviousRevision( $this->mNewRevisionRecord );
2038 // No previous revision; mark to show as first-version only.
2039 $this->mOldid = $revRecord ? $revRecord->getId() : false;
2040 $this->mOldRevisionRecord = $revRecord ?? false;
2041 } /* elseif ( $this->mOldid === false ) leave mOldRevisionRecord false; */
2042
2043 if ( $this->mOldRevisionRecord === null ) {
2044 return false;
2045 }
2046
2047 if ( $this->mOldRevisionRecord && $this->mOldRevisionRecord->getId() ) {
2048 $this->mOldPage = Title::newFromLinkTarget(
2049 $this->mOldRevisionRecord->getPageAsLinkTarget()
2050 );
2051 } else {
2052 $this->mOldPage = null;
2053 }
2054
2055 // Load tags information for both revisions
2056 $dbr = wfGetDB( DB_REPLICA );
2057 $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
2058 if ( $this->mOldid !== false ) {
2059 $tagIds = $dbr->selectFieldValues(
2060 'change_tag',
2061 'ct_tag_id',
2062 [ 'ct_rev_id' => $this->mOldid ],
2063 __METHOD__
2064 );
2065 $tags = [];
2066 foreach ( $tagIds as $tagId ) {
2067 try {
2068 $tags[] = $changeTagDefStore->getName( (int)$tagId );
2069 } catch ( NameTableAccessException $exception ) {
2070 continue;
2071 }
2072 }
2073 $this->mOldTags = implode( ',', $tags );
2074 } else {
2075 $this->mOldTags = false;
2076 }
2077
2078 $tagIds = $dbr->selectFieldValues(
2079 'change_tag',
2080 'ct_tag_id',
2081 [ 'ct_rev_id' => $this->mNewid ],
2082 __METHOD__
2083 );
2084 $tags = [];
2085 foreach ( $tagIds as $tagId ) {
2086 try {
2087 $tags[] = $changeTagDefStore->getName( (int)$tagId );
2088 } catch ( NameTableAccessException $exception ) {
2089 continue;
2090 }
2091 }
2092 $this->mNewTags = implode( ',', $tags );
2093
2094 return true;
2095 }
2096
2105 public function loadText() {
2106 if ( $this->mTextLoaded == 2 ) {
2107 return $this->loadRevisionData() &&
2108 ( $this->mOldRevisionRecord === false || $this->mOldContent )
2109 && $this->mNewContent;
2110 }
2111
2112 // Whether it succeeds or fails, we don't want to try again
2113 $this->mTextLoaded = 2;
2114
2115 if ( !$this->loadRevisionData() ) {
2116 return false;
2117 }
2118
2119 if ( $this->mOldRevisionRecord ) {
2120 $this->mOldContent = $this->mOldRevisionRecord->getContent(
2121 SlotRecord::MAIN,
2122 RevisionRecord::FOR_THIS_USER,
2123 $this->getAuthority()
2124 );
2125 if ( $this->mOldContent === null ) {
2126 return false;
2127 }
2128 }
2129
2130 $this->mNewContent = $this->mNewRevisionRecord->getContent(
2131 SlotRecord::MAIN,
2132 RevisionRecord::FOR_THIS_USER,
2133 $this->getAuthority()
2134 );
2135 $this->hookRunner->onDifferenceEngineLoadTextAfterNewContentIsLoaded( $this );
2136 if ( $this->mNewContent === null ) {
2137 return false;
2138 }
2139
2140 return true;
2141 }
2142
2148 public function loadNewText() {
2149 if ( $this->mTextLoaded >= 1 ) {
2150 return $this->loadRevisionData();
2151 }
2152
2153 $this->mTextLoaded = 1;
2154
2155 if ( !$this->loadRevisionData() ) {
2156 return false;
2157 }
2158
2159 $this->mNewContent = $this->mNewRevisionRecord->getContent(
2160 SlotRecord::MAIN,
2161 RevisionRecord::FOR_THIS_USER,
2162 $this->getAuthority()
2163 );
2164
2165 $this->hookRunner->onDifferenceEngineAfterLoadNewText( $this );
2166
2167 return true;
2168 }
2169
2170}
getUser()
getAuthority()
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.
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.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
getContext()
static formatSummaryRow( $tags, $page, MessageLocalizer $localizer=null)
Creates HTML for the given tags.
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.
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.
getDiffBodyCacheKey()
Returns the cache key for diff body text or content.
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 to 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.
textDiff( $otext, $ntext)
Generates diff, to be wrapped internally in a logging/instrumentation.
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.
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:53
static expandLocalLinks(string $html)
Helper function to expand local links.
Definition Linker.php:1414
static getRevisionDeletedClass(RevisionRecord $revisionRecord)
Returns css class of a deleted revision.
Definition Linker.php:1351
static revComment(RevisionRecord $revRecord, $local=false, $isPublic=false, $useParentheses=true)
Wrap and format the given revision's comment block, if the current user is allowed to view it.
Definition Linker.php:1607
static getRevDeleteLink(Authority $performer, RevisionRecord $revRecord, LinkTarget $title)
Get a revision-deletion link, or disabled link, or nothing, depending on user permissions & the setti...
Definition Linker.php:2153
static generateRollback(RevisionRecord $revRecord, IContextSource $context=null, $options=[ 'verify'])
Generate a rollback link for a given revision.
Definition Linker.php:1834
static titleAttrib( $name, $options=null, array $msgParams=[], $localizer=null)
Given the id of an interface element, constructs the appropriate title attribute from the system mess...
Definition Linker.php:2080
static revUserTools(RevisionRecord $revRecord, $isPublic=false, $useParentheses=true)
Generate a user tool link cluster if the current user is allowed to view it.
Definition Linker.php:1371
MediaWiki exception.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Class that generates HTML anchor link elements for pages.
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.
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.
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.
Renders a diff for a single slot (that is, a diff between two content objects).
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.
Represents a title within MediaWiki.
Definition Title.php:49
Base representation for an editable wiki page.
Definition WikiPage.php:62
makeParserOptions( $context)
Get parser options suitable for rendering the primary article wikitext.
getParserOutput(?ParserOptions $parserOptions=null, $oldid=null, $noCache=false)
Get a ParserOutput for the given ParserOptions and revision ID.
Base interface for content objects.
Definition Content.php:35
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.
$cache
Definition mcc.php:33
const DB_REPLICA
Definition defines.php:26
$content
Definition router.php:76
if(!isset( $args[0])) $lang
$header