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