MediaWiki master
DifferenceEngine.php
Go to the documentation of this file.
1<?php
12
13use BadMethodCallException;
14use Exception;
15use InvalidArgumentException;
16use LogicException;
23use MediaWiki\Debug\DeprecationHelper;
56use Wikimedia\Timestamp\ConvertibleTimestamp;
57use Wikimedia\Timestamp\TimestampFormat as TS;
58
82
83 use DeprecationHelper;
84
91 private const DIFF_VERSION = '1.41';
92
99 protected $mOldid;
100
107 protected $mNewid;
108
119 private $mOldRevisionRecord;
120
129 private $mNewRevisionRecord;
130
135 protected $mOldPage;
136
141 protected $mNewPage;
142
147 private $mOldTags;
148
153 private $mNewTags;
154
160 private $mOldContent;
161
167 private $mNewContent;
168
170 protected $mDiffLang;
171
173 private $mRevisionsIdsLoaded = false;
174
176 protected $mRevisionsLoaded = false;
177
179 protected $mTextLoaded = 0;
180
189 protected $isContentOverridden = false;
190
192 protected $mCacheHit = false;
193
195 private $cacheHitKey = null;
196
203 public $enableDebugComment = false;
204
208 protected $mReducedLineNumbers = false;
209
211 protected $mMarkPatrolledLink = null;
212
214 protected $unhide = false;
215
217 protected $mRefreshCache = false;
218
220 protected $slotDiffRenderers = null;
221
228 protected $isSlotDiffRenderer = false;
229
234 private $slotDiffOptions = [];
235
240 private $extraQueryParams = [];
241
243 private $textDiffer;
244
246 private IContentHandlerFactory $contentHandlerFactory;
247 private RevisionStore $revisionStore;
248 private ArchivedRevisionLookup $archivedRevisionLookup;
249 private HookRunner $hookRunner;
250 private WikiPageFactory $wikiPageFactory;
251 private UserOptionsLookup $userOptionsLookup;
252 private CommentFormatter $commentFormatter;
253 private IConnectionProvider $dbProvider;
254 private UserGroupManager $userGroupManager;
255 private UserEditTracker $userEditTracker;
256 private UserIdentityUtils $userIdentityUtils;
257 private RecentChangeLookup $recentChangeLookup;
258
260 private $revisionLoadErrors = [];
261
270 public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
271 $refreshCache = false, $unhide = false
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->archivedRevisionLookup = $services->getArchivedRevisionLookup();
289 $this->hookRunner = new HookRunner( $services->getHookContainer() );
290 $this->wikiPageFactory = $services->getWikiPageFactory();
291 $this->userOptionsLookup = $services->getUserOptionsLookup();
292 $this->commentFormatter = $services->getCommentFormatter();
293 $this->dbProvider = $services->getConnectionProvider();
294 $this->userGroupManager = $services->getUserGroupManager();
295 $this->userEditTracker = $services->getUserEditTracker();
296 $this->userIdentityUtils = $services->getUserIdentityUtils();
297 $this->recentChangeLookup = $services->getRecentChangeLookup();
298 }
299
305 protected function getSlotDiffRenderers() {
306 if ( $this->isSlotDiffRenderer ) {
307 throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
308 }
309
310 if ( $this->slotDiffRenderers === null ) {
311 if ( !$this->loadRevisionData() ) {
312 return [];
313 }
314
315 $slotContents = $this->getSlotContents();
316 $this->slotDiffRenderers = [];
317 foreach ( $slotContents as $role => $contents ) {
318 if ( $contents['new'] && $contents['old']
319 && $contents['new']->equals( $contents['old'] )
320 ) {
321 // Do not produce a diff of identical content
322 continue;
323 }
324 if ( !$contents['new'] && !$contents['old'] ) {
325 // Nothing to diff (i.e both revisions are corrupted), just ignore
326 continue;
327 }
328 $handler = ( $contents['new'] ?: $contents['old'] )->getContentHandler();
329 $this->slotDiffRenderers[$role] = $handler->getSlotDiffRenderer(
330 $this->getContext(),
331 $this->slotDiffOptions + [
332 'contentLanguage' => $this->getDiffLang()->getCode(),
333 'textDiffer' => $this->getTextDiffer()
334 ]
335 );
336 }
337 }
338
340 }
341
348 public function markAsSlotDiffRenderer() {
349 $this->isSlotDiffRenderer = true;
350 }
351
357 protected function getSlotContents() {
358 if ( $this->isContentOverridden ) {
359 return [
360 SlotRecord::MAIN => [ 'old' => $this->mOldContent, 'new' => $this->mNewContent ]
361 ];
362 } elseif ( !$this->loadRevisionData() ) {
363 return [];
364 }
365
366 $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
367 $oldSlots = $this->mOldRevisionRecord ?
368 $this->mOldRevisionRecord->getPrimarySlots()->getSlots() :
369 [];
370 // The order here will determine the visual order of the diff. The current logic is
371 // slots of the new revision first in natural order, then deleted ones. This is ad hoc
372 // and should not be relied on - in the future we may want the ordering to depend
373 // on the page type.
374 $roles = array_keys( array_merge( $newSlots, $oldSlots ) );
375
376 $slots = [];
377 foreach ( $roles as $role ) {
378 $slots[$role] = [
379 'old' => $this->loadSingleSlot(
380 $oldSlots[$role] ?? null,
381 'old'
382 ),
383 'new' => $this->loadSingleSlot(
384 $newSlots[$role] ?? null,
385 'new'
386 )
387 ];
388 }
389 // move main slot to front
390 if ( isset( $slots[SlotRecord::MAIN] ) ) {
391 $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
392 }
393 return $slots;
394 }
395
403 private function loadSingleSlot( ?SlotRecord $slot, string $which ) {
404 if ( !$slot ) {
405 return null;
406 }
407 try {
408 return $slot->getContent();
409 } catch ( BadRevisionException ) {
410 $this->addRevisionLoadError( $which );
411 return null;
412 }
413 }
414
420 private function addRevisionLoadError( $which ) {
421 $this->revisionLoadErrors[] = $this->msg( $which === 'new'
422 ? 'difference-bad-new-revision' : 'difference-bad-old-revision'
423 );
424 }
425
432 public function getRevisionLoadErrors() {
433 return $this->revisionLoadErrors;
434 }
435
440 private function hasNewRevisionLoadError() {
441 foreach ( $this->revisionLoadErrors as $error ) {
442 if ( $error->getKey() === 'difference-bad-new-revision' ) {
443 return true;
444 }
445 }
446 return false;
447 }
448
450 public function getTitle() {
451 // T202454 avoid errors when there is no title
452 return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
453 }
454
461 public function setReducedLineNumbers( $value = true ) {
462 $this->mReducedLineNumbers = $value;
463 }
464
470 public function getDiffLang() {
471 # Default language in which the diff text is written.
472 $this->mDiffLang ??= $this->getDefaultLanguage();
473 return $this->mDiffLang;
474 }
475
482 protected function getDefaultLanguage() {
483 return $this->getTitle()->getPageLanguage();
484 }
485
489 public function wasCacheHit() {
490 return $this->mCacheHit;
491 }
492
500 public function getOldid() {
501 $this->loadRevisionIds();
502
503 return $this->mOldid;
504 }
505
512 public function getNewid() {
513 $this->loadRevisionIds();
514
515 return $this->mNewid;
516 }
517
524 public function getOldRevision() {
525 return $this->mOldRevisionRecord ?: null;
526 }
527
533 public function getNewRevision() {
534 return $this->mNewRevisionRecord;
535 }
536
545 public function deletedLink( $id ) {
546 if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
547 $revRecord = $this->archivedRevisionLookup->getArchivedRevisionRecord( null, $id );
548 if ( $revRecord ) {
549 $title = Title::newFromPageIdentity( $revRecord->getPage() );
550
551 return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
552 'target' => $title->getPrefixedText(),
553 'timestamp' => $revRecord->getTimestamp()
554 ] );
555 }
556 }
557
558 return false;
559 }
560
568 public function deletedIdMarker( $id ) {
569 $link = $this->deletedLink( $id );
570 if ( $link ) {
571 return "[$link $id]";
572 } else {
573 return (string)$id;
574 }
575 }
576
577 private function showMissingRevision() {
578 $out = $this->getOutput();
579
580 $missing = [];
581 if ( $this->mOldid && !$this->mOldRevisionRecord ) {
582 $missing[] = $this->deletedIdMarker( $this->mOldid );
583 }
584 if ( !$this->mNewRevisionRecord ) {
585 $missing[] = $this->deletedIdMarker( $this->mNewid );
586 }
587
588 $out->setPageTitleMsg( $this->msg( 'errorpagetitle' ) );
589
590 // Don't display the deletion log for the main page, it's probably not useful
591 $key = $this->getTitle()->equals( Title::newMainPage() ) ?
592 'difference-missing-revision-nolog' :
593 'difference-missing-revision';
594
595 $msg = $this->msg( $key )
596 ->params( $this->getLanguage()->listToText( $missing ) )
597 ->numParams( count( $missing ) )
598 ->parseAsBlock();
599 $out->addHTML( $msg );
600 }
601
607 public function hasDeletedRevision() {
608 $this->loadRevisionData();
609 return (
610 $this->mNewRevisionRecord &&
611 $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
612 ) ||
613 (
614 $this->mOldRevisionRecord &&
615 $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
616 );
617 }
618
626 public function authorizeView( Authority $performer ): PermissionStatus {
627 $this->loadRevisionData();
628 $permStatus = PermissionStatus::newEmpty();
629 if ( $this->mNewPage ) {
630 $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
631 }
632 if ( $this->mOldPage ) {
633 $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
634 }
635 return $permStatus;
636 }
637
643 public function hasSuppressedRevision() {
644 return $this->hasDeletedRevision() && (
645 ( $this->mOldRevisionRecord &&
646 $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
647 ( $this->mNewRevisionRecord &&
648 $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
649 );
650 }
651
658 private function getUserEditCount( $user ): string {
659 $editCount = $this->userEditTracker->getUserEditCount( $user );
660 if ( $editCount === null ) {
661 return '';
662 }
663
664 return Html::rawElement( 'div', [
665 'class' => 'mw-diff-usereditcount',
666 ],
667 $this->msg(
668 'diff-user-edits',
669 $this->getLanguage()->formatNum( $editCount )
670 )->parse()
671 );
672 }
673
680 private function getUserRoles( UserIdentity $user ) {
681 if ( !$this->userIdentityUtils->isNamed( $user ) ) {
682 return '';
683 }
684 $userGroups = $this->userGroupManager->getUserGroups( $user );
685 $userGroupLinks = [];
686 foreach ( $userGroups as $group ) {
687 $userGroupLinks[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
688 }
689 return Html::rawElement( 'div', [
690 'class' => 'mw-diff-userroles',
691 ], $this->getLanguage()->commaList( $userGroupLinks ) );
692 }
693
700 private function getUserMetaData( ?UserIdentity $user ) {
701 if ( !$user ) {
702 return '';
703 }
704 return Html::rawElement( 'div', [
705 'class' => 'mw-diff-usermetadata',
706 ], $this->getUserRoles( $user ) . $this->getUserEditCount( $user ) );
707 }
708
720 public function isUserAllowedToSeeRevisions( Authority $performer ) {
721 $this->loadRevisionData();
722
723 if ( $this->mOldRevisionRecord && !$this->mOldRevisionRecord->userCan(
724 RevisionRecord::DELETED_TEXT,
725 $performer
726 ) ) {
727 return false;
728 }
729
730 // $this->mNewRev will only be falsy if a loading error occurred
731 // (in which case the user is allowed to see).
732 return !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
733 RevisionRecord::DELETED_TEXT,
734 $performer
735 );
736 }
737
745 public function shouldBeHiddenFromUser( Authority $performer ) {
746 return $this->hasDeletedRevision() && ( !$this->unhide ||
747 !$this->isUserAllowedToSeeRevisions( $performer ) );
748 }
749
753 public function showDiffPage( $diffOnly = false ) {
754 # Allow frames except in certain special cases
755 $out = $this->getOutput();
756 $out->getMetadata()->setPreventClickjacking( false );
757 $out->setRobotPolicy( 'noindex,nofollow' );
758
759 // Allow extensions to add any extra output here
760 $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
761
762 if ( !$this->loadRevisionData() ) {
763 if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
764 $this->showMissingRevision();
765 }
766 return;
767 }
768
769 $user = $this->getUser();
770 $permStatus = $this->authorizeView( $this->getAuthority() );
771 if ( !$permStatus->isGood() ) {
772 throw new PermissionsError( 'read', $permStatus );
773 }
774
775 $rollback = '';
776
777 $query = $this->extraQueryParams;
778 # Carry over 'diffonly' param via navigation links
779 if ( $diffOnly != MediaWikiServices::getInstance()
780 ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
781 ) {
782 $query['diffonly'] = $diffOnly;
783 }
784 # Cascade unhide param in links for easy deletion browsing
785 if ( $this->unhide ) {
786 $query['unhide'] = 1;
787 }
788
789 # Check if one of the revisions is deleted/suppressed
790 $deleted = $this->hasDeletedRevision();
791 $suppressed = $this->hasSuppressedRevision();
792 $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
793
794 $revisionTools = [];
795 $breadCrumbs = '';
796
797 # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
798 # a diff between a version V and its previous version V' AND the version V
799 # is the first version of that article. In that case, V' does not exist.
800 if ( $this->mOldRevisionRecord === false ) {
801 if ( $this->mNewPage ) {
802 $out->setPageTitleMsg(
803 $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
804 );
805 }
806 $samePage = true;
807 $oldHeader = '';
808 // Allow extensions to change the $oldHeader variable
809 $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
810 } else {
811 $this->hookRunner->onDifferenceEngineViewHeader( $this );
812
813 if ( !$this->mOldPage || !$this->mNewPage ) {
814 // XXX say something to the user?
815 $samePage = false;
816 } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
817 $out->setPageTitleMsg(
818 $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
819 );
820 $samePage = true;
821 } else {
822 $out->setPageTitleMsg( $this->msg( 'difference-title-multipage' )->plaintextParams(
823 $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
824 $out->addSubtitle( $this->msg( 'difference-multipage' ) );
825 $samePage = false;
826 }
827
828 if ( $samePage && $this->mNewPage &&
829 $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
830 ) {
831 if ( $this->mNewRevisionRecord->isCurrent() &&
832 $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
833 ) {
834 $rollbackLink = Linker::generateRollback(
835 $this->mNewRevisionRecord,
836 $this->getContext(),
837 [ 'noBrackets' ]
838 );
839 if ( $rollbackLink ) {
840 $out->getMetadata()->setPreventClickjacking( true );
841 $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
842 }
843 }
844
845 if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
846 $this->userCanEdit( $this->mNewRevisionRecord )
847 ) {
848 $undoLink = $this->linkRenderer->makeKnownLink(
849 $this->mNewPage,
850 $this->msg( 'editundo' )->text(),
851 [ 'title' => Linker::titleAttrib( 'undo' ) ],
852 [
853 'action' => 'edit',
854 'undoafter' => $this->mOldid,
855 'undo' => $this->mNewid
856 ]
857 );
858 $revisionTools['mw-diff-undo'] = $undoLink;
859 }
860 }
861 # Make "previous revision link"
862 $hasPrevious = $samePage && $this->mOldPage &&
863 $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
864 if ( $hasPrevious ) {
865 $prevlinkQuery = [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query;
866 $prevlink = $this->linkRenderer->makeKnownLink(
867 $this->mOldPage,
868 $this->msg( 'previousdiff' )->text(),
869 [ 'id' => 'differences-prevlink' ],
870 $prevlinkQuery
871 );
872 $breadCrumbs .= $this->linkRenderer->makeKnownLink(
873 $this->mOldPage,
874 $this->msg( 'previousdiff' )->text(),
875 [
876 'class' => 'mw-diff-revision-history-link-previous'
877 ],
878 $prevlinkQuery
879 );
880 } else {
881 $prevlink = "\u{00A0}";
882 }
883
884 if ( $this->mOldRevisionRecord->isMinor() ) {
885 $oldminor = ChangesList::flag( 'minor', $this->getContext() );
886 } else {
887 $oldminor = '';
888 }
889
890 $oldRevRecord = $this->mOldRevisionRecord;
891
892 $ldel = $this->revisionDeleteLink( $oldRevRecord );
893 $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
894 $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
895 $oldRevComment = $this->commentFormatter
896 ->formatRevision(
897 $oldRevRecord, $user, !$diffOnly, !$this->unhide, false
898 );
899
900 if ( $oldRevComment === '' ) {
901 $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
902 $oldRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
903 }
904
905 $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
906 '<div id="mw-diff-otitle2">' .
907 Linker::revUserTools( $oldRevRecord, !$this->unhide ) .
908 $this->getUserMetaData( $oldRevRecord->getUser() ) .
909 '</div>' .
910 '<div id="mw-diff-otitle3">' . $oldminor . $oldRevComment . $ldel . '</div>' .
911 '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
912 '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
913
914 // Allow extensions to change the $oldHeader variable
915 $this->hookRunner->onDifferenceEngineOldHeader(
916 $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
917 }
918
919 $out->addJsConfigVars( [
920 'wgDiffOldId' => $this->mOldid,
921 'wgDiffNewId' => $this->mNewid,
922 ] );
923
924 # Make "next revision link"
925 # Skip next link on the top revision
926 if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
927 $nextlinkQuery = [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query;
928 $nextlink = $this->linkRenderer->makeKnownLink(
929 $this->mNewPage,
930 $this->msg( 'nextdiff' )->text(),
931 [ 'id' => 'differences-nextlink' ],
932 $nextlinkQuery
933 );
934 $breadCrumbs .= $this->linkRenderer->makeKnownLink(
935 $this->mNewPage,
936 $this->msg( 'nextdiff' )->text(),
937 [
938 'class' => 'mw-diff-revision-history-link-next'
939 ],
940 $nextlinkQuery
941 );
942 } else {
943 $nextlink = "\u{00A0}";
944 }
945
946 if ( $this->mNewRevisionRecord->isMinor() ) {
947 $newminor = ChangesList::flag( 'minor', $this->getContext() );
948 } else {
949 $newminor = '';
950 }
951
952 # Handle RevisionDelete links...
953 $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
954
955 # Allow extensions to define their own revision tools
956 $this->hookRunner->onDiffTools(
957 $this->mNewRevisionRecord,
958 $revisionTools,
959 $this->mOldRevisionRecord ?: null,
960 $user
961 );
962
963 $formattedRevisionTools = [];
964 // Put each one in parentheses (poor man's button)
965 foreach ( $revisionTools as $key => $tool ) {
966 $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
967 $element = Html::rawElement(
968 'span',
969 [ 'class' => $toolClass ],
970 $tool
971 );
972 $formattedRevisionTools[] = $element;
973 }
974
975 $newRevRecord = $this->mNewRevisionRecord;
976
977 $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
978 ' ' . implode( ' ', $formattedRevisionTools );
979 $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
980 $newRevComment = $this->commentFormatter->formatRevision(
981 $newRevRecord, $user, !$diffOnly, !$this->unhide, false
982 );
983
984 if ( $newRevComment === '' ) {
985 $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
986 $newRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
987 }
988
989 $newMobileFooter = $this->getMobileFooter( $newRevRecord, $formattedRevisionTools );
990
991 $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
992 '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
993 $rollback .
994 $this->getUserMetaData( $newRevRecord->getUser() ) .
995 '</div>' .
996 '<div id="mw-diff-ntitle3">' . $newminor . $newRevComment . $rdel . '</div>' .
997 '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
998 '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
999
1000 // Allow extensions to change the $newHeader variable
1001 $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
1002 $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
1003 $rdel, $this->unhide );
1004
1005 $out->addHTML(
1006 Html::rawElement( 'div', [
1007 'class' => 'mw-diff-revision-history-links'
1008 ], $breadCrumbs )
1009 );
1010
1011 $out->addHTML(
1012 Html::rawElement( 'div', [
1013 'class' => 'mw-diff-mobile-footer'
1014 ], $newMobileFooter )
1015 );
1016 $addMessageBoxStyles = false;
1017 # If the diff cannot be shown due to a deleted revision, then output
1018 # the diff header and links to unhide (if available)...
1019 if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
1020 $this->showDiffStyle();
1021 $multi = $this->getMultiNotice();
1022 $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
1023 if ( !$allowed ) {
1024 # Give explanation for why revision is not visible
1025 $msg = [ $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff' ];
1026 } else {
1027 # Give explanation and add a link to view the diff...
1028 $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
1029 $msg = [
1030 $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff',
1031 $this->getTitle()->getFullURL( $query )
1032 ];
1033 }
1034 $out->addHTML( Html::warningBox( $this->msg( ...$msg )->parse(), 'plainlinks' ) );
1035 $addMessageBoxStyles = true;
1036 # Otherwise, output a regular diff...
1037 } else {
1038 # Add deletion notice if the user is viewing deleted content
1039 $notice = '';
1040 if ( $deleted ) {
1041 $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
1042 $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' );
1043 $addMessageBoxStyles = true;
1044 }
1045
1046 # Add an error if the content can't be loaded
1047 $this->getSlotContents();
1048 foreach ( $this->getRevisionLoadErrors() as $msg ) {
1049 $notice .= Html::warningBox( $msg->parse() );
1050 $addMessageBoxStyles = true;
1051 }
1052
1053 // Check if inline switcher will be needed
1054 if ( $this->getTextDiffer()->hasFormat( 'inline' ) ) {
1055 $out->enableOOUI();
1056 }
1057
1058 $this->showTablePrefixes();
1059 $this->showDiff( $oldHeader, $newHeader, $notice );
1060 if ( !$diffOnly ) {
1061 $this->renderNewRevision();
1062 }
1063
1064 // Allow extensions to optionally not show the final patrolled link
1065 if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1066 # Add redundant patrol link on bottom...
1067 $out->addHTML( $this->markPatrolledLink() );
1068 }
1069 }
1070 if ( $addMessageBoxStyles ) {
1071 $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1072 }
1073 }
1074
1078 private function showTablePrefixes() {
1079 $parts = [];
1080 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1081 $parts += $slotDiffRenderer->getTablePrefix( $this->getContext(), $this->mNewPage );
1082 }
1083 ksort( $parts );
1084 $nonEmptyParts = array_values( array_filter( $parts ) );
1085 if ( $nonEmptyParts ) {
1086 $language = $this->getLanguage();
1087 $attrs = [
1088 'class' => 'mw-diff-table-prefix',
1089 'dir' => $language->getDir(),
1090 'lang' => $language->getCode(),
1091 ];
1092 $this->getOutput()->addHTML(
1093 Html::rawElement( 'div', $attrs, implode( '', $nonEmptyParts ) ) );
1094 }
1095 }
1096
1108 public function markPatrolledLink() {
1109 if ( $this->mMarkPatrolledLink === null ) {
1110 $linkInfo = $this->getMarkPatrolledLinkInfo();
1111 // If false, there is no patrol link needed/allowed
1112 if ( !$linkInfo || !$this->mNewPage ) {
1113 $this->mMarkPatrolledLink = '';
1114 } else {
1115 $patrolLinkClass = 'patrollink';
1116 $this->mMarkPatrolledLink = ' <span class="' . $patrolLinkClass . '"' .
1117 ' data-mw-interface>[' .
1118 $this->linkRenderer->makeKnownLink(
1119 $this->mNewPage,
1120 $this->msg( 'markaspatrolleddiff' )->text(),
1121 [],
1122 [
1123 'action' => 'markpatrolled',
1124 'rcid' => $linkInfo['rcid'],
1125 ]
1126 ) . ']</span>';
1127 // Allow extensions to change the markpatrolled link
1128 $this->hookRunner->onDifferenceEngineMarkPatrolledLink( $this,
1129 $this->mMarkPatrolledLink, $linkInfo['rcid'] );
1130 }
1131 }
1132 return $this->mMarkPatrolledLink;
1133 }
1134
1142 protected function getMarkPatrolledLinkInfo() {
1143 $user = $this->getUser();
1144 $config = $this->getConfig();
1145
1146 // Prepare a change patrol link, if applicable
1147 if (
1148 // Is patrolling enabled and the user allowed to?
1149 $config->get( MainConfigNames::UseRCPatrol ) &&
1150 $this->mNewPage &&
1151 $this->getAuthority()->probablyCan( 'patrol', $this->mNewPage ) &&
1152 // Only do this if the revision isn't more than 6 hours older
1153 // than the Max RC age (6h because the RC might not be cleaned out regularly)
1154 RecentChange::isInRCLifespan( $this->mNewRevisionRecord->getTimestamp(), 21600 )
1155 ) {
1156 // Look for an unpatrolled change corresponding to this diff
1157 $change = $this->recentChangeLookup->getRecentChangeByConds(
1158 [
1159 'rc_this_oldid' => $this->mNewid,
1160 'rc_patrolled' => RecentChange::PRC_UNPATROLLED
1161 ],
1162 __METHOD__
1163 );
1164
1165 if ( $change && !$change->getPerformerIdentity()->equals( $user ) ) {
1166 $rcid = $change->getAttribute( 'rc_id' );
1167 } else {
1168 // None found or the page has been created by the current user.
1169 // If the user could patrol this it already would be patrolled
1170 $rcid = 0;
1171 }
1172
1173 // Allow extensions to possibly change the rcid here
1174 // For example the rcid might be set to zero due to the user
1175 // being the same as the performer of the change but an extension
1176 // might still want to show it under certain conditions
1177 $this->hookRunner->onDifferenceEngineMarkPatrolledRCID( $rcid, $this, $change, $user );
1178
1179 // Build the link
1180 if ( $rcid ) {
1181 $this->getOutput()->getMetadata()->setPreventClickjacking( true );
1182 $this->getOutput()->addModules( 'mediawiki.misc-authed-curate' );
1183
1184 return [ 'rcid' => $rcid ];
1185 }
1186 }
1187
1188 // No mark as patrolled link applicable
1189 return false;
1190 }
1191
1197 private function revisionDeleteLink( RevisionRecord $revRecord ) {
1198 $link = Linker::getRevDeleteLink(
1199 $this->getAuthority(),
1200 $revRecord,
1201 $revRecord->getPageAsLinkTarget()
1202 );
1203 if ( $link !== '' ) {
1204 $link = "\u{00A0}\u{00A0}\u{00A0}" . $link . ' ';
1205 }
1206
1207 return $link;
1208 }
1209
1215 public function renderNewRevision() {
1216 if ( $this->isContentOverridden ) {
1217 // The code below only works with a RevisionRecord object. We could construct a
1218 // fake RevisionRecord (here or in setContent), but since this does not seem
1219 // needed at the moment, we'll just fail for now.
1220 throw new LogicException(
1221 __METHOD__
1222 . ' is not supported after calling setContent(). Use setRevisions() instead.'
1223 );
1224 }
1225
1226 $out = $this->getOutput();
1227 $revHeader = $this->getRevisionHeader( $this->mNewRevisionRecord );
1228 # Add "current version as of X" title
1229 $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
1230 <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
1231 # Page content may be handled by a hooked call instead...
1232 if ( $this->hookRunner->onArticleContentOnDiff( $this, $out ) ) {
1233 $this->loadNewText();
1234 if ( !$this->mNewPage ) {
1235 // New revision is unsaved; bail out.
1236 // TODO in theory rendering the new revision is a meaningful thing to do
1237 // even if it's unsaved, but a lot of untangling is required to do it safely.
1238 return;
1239 }
1240 if ( $this->hasNewRevisionLoadError() ) {
1241 // There was an error loading the new revision
1242 return;
1243 }
1244
1245 $out->setRevisionId( $this->mNewid );
1246 $out->setRevisionIsCurrent( $this->mNewRevisionRecord->isCurrent() );
1247 $out->getMetadata()->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() );
1248 $out->setArticleFlag( true );
1249
1250 if ( !$this->hookRunner->onArticleRevisionViewCustom(
1251 $this->mNewRevisionRecord, $this->mNewPage, $this->mOldid, $out )
1252 ) {
1253 // Handled by extension
1254 // NOTE: sync with hooks called in Article::view()
1255 } else {
1256 // Normal page
1257 if ( $this->getTitle()->equals( $this->mNewPage ) ) {
1258 // If the Title stored in the context is the same as the one
1259 // of the new revision, we can use its associated WikiPage
1260 // object.
1261 $wikiPage = $this->getWikiPage();
1262 } else {
1263 // Otherwise we need to create our own WikiPage object
1264 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->mNewPage );
1265 }
1266
1267 $parserOptions = $wikiPage->makeParserOptions( $this->getContext() );
1268 $parserOptions->setRenderReason( 'diff-page' );
1269
1270 $parserOutputAccess = MediaWikiServices::getInstance()->getParserOutputAccess();
1271 $status = $parserOutputAccess->getParserOutput(
1272 $wikiPage,
1273 $parserOptions,
1274 $this->mNewRevisionRecord,
1275 [
1276 // we already checked
1277 ParserOutputAccess::OPT_NO_AUDIENCE_CHECK => true,
1278 // Update cascading protection
1279 ParserOutputAccess::OPT_LINKS_UPDATE => true,
1280 ],
1281 );
1282 if ( $status->isOK() ) {
1283 $parserOutput = $status->getValue();
1284 // Allow extensions to change parser output here
1285 if ( $this->hookRunner->onDifferenceEngineRenderRevisionAddParserOutput(
1286 $this, $out, $parserOutput, $wikiPage )
1287 ) {
1288 $editLinks = $this->mNewRevisionRecord->isCurrent()
1289 && $this->getAuthority()->probablyCan(
1290 'edit',
1291 $this->mNewRevisionRecord->getPage() );
1292 if ( !$editLinks ) {
1293 $parserOptions->setSuppressSectionEditLinks();
1294 }
1295 $out->addParserOutput( $parserOutput, $parserOptions, [
1296 'absoluteURLs' => $this->slotDiffOptions['expand-url'] ?? false
1297 ] );
1298 }
1299 } else {
1300 $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1301 foreach ( $status->getMessages() as $msg ) {
1302 $out->addHTML( Html::errorBox(
1303 $this->msg( $msg )->parse()
1304 ) );
1305 }
1306 }
1307 }
1308 }
1309 }
1310
1321 public function showDiff( $otitle, $ntitle, $notice = '' ) {
1322 // Allow extensions to affect the output here
1323 $this->hookRunner->onDifferenceEngineShowDiff( $this );
1324
1325 $diff = $this->getDiff( $otitle, $ntitle, $notice );
1326 if ( $diff === false ) {
1327 $this->showMissingRevision();
1328 return false;
1329 }
1330
1331 $this->showDiffStyle();
1332 if ( $this->slotDiffOptions['expand-url'] ?? false ) {
1333 $diff = Linker::expandLocalLinks( $diff );
1334 }
1335 $this->getOutput()->addHTML( $diff );
1336 return true;
1337 }
1338
1342 public function showDiffStyle() {
1343 if ( !$this->isSlotDiffRenderer ) {
1344 $this->getOutput()->addModules( 'mediawiki.diff' );
1345 $this->getOutput()->addModuleStyles( [
1346 'mediawiki.interface.helpers.styles',
1347 'mediawiki.diff.styles'
1348 ] );
1349 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1350 $slotDiffRenderer->addModules( $this->getOutput() );
1351 }
1352 }
1353 }
1354
1364 public function getDiff( $otitle, $ntitle, $notice = '' ) {
1365 $body = $this->getDiffBody();
1366 if ( $body === false ) {
1367 return false;
1368 }
1369
1370 $multi = $this->getMultiNotice();
1371 // Display a message when the diff is empty
1372 if ( $body === '' ) {
1373 $notice .= '<div class="mw-diff-empty">' .
1374 $this->msg( 'diff-empty' )->parse() .
1375 "</div>\n";
1376 }
1377
1378 if ( $this->cacheHitKey !== null ) {
1379 $body .= "\n<!-- diff cache key " . htmlspecialchars( $this->cacheHitKey ) . " -->\n";
1380 }
1381
1382 return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
1383 }
1384
1385 private function incrementStats( string $cacheStatus ): void {
1386 $stats = MediaWikiServices::getInstance()->getStatsFactory();
1387 $stats->getCounter( 'diff_cache_total' )
1388 ->setLabel( 'status', $cacheStatus )
1389 ->increment();
1390 }
1391
1397 public function getDiffBody() {
1398 $this->mCacheHit = true;
1399 // Check if the diff should be hidden from this user
1400 if ( !$this->isContentOverridden ) {
1401 if ( !$this->loadRevisionData() ) {
1402 return false;
1403 } elseif ( $this->mOldRevisionRecord &&
1404 !$this->mOldRevisionRecord->userCan(
1405 RevisionRecord::DELETED_TEXT,
1406 $this->getAuthority()
1407 )
1408 ) {
1409 return false;
1410 } elseif ( $this->mNewRevisionRecord &&
1411 !$this->mNewRevisionRecord->userCan(
1412 RevisionRecord::DELETED_TEXT,
1413 $this->getAuthority()
1414 ) ) {
1415 return false;
1416 }
1417 // Short-circuit
1418 if ( $this->mOldRevisionRecord === false || (
1419 $this->mOldRevisionRecord &&
1420 $this->mNewRevisionRecord &&
1421 $this->mOldRevisionRecord->getId() &&
1422 $this->mOldRevisionRecord->getId() == $this->mNewRevisionRecord->getId()
1423 ) ) {
1424 if ( $this->hookRunner->onDifferenceEngineShowEmptyOldContent( $this ) ) {
1425 return '';
1426 }
1427 }
1428 }
1429
1430 // Cacheable?
1431 $key = false;
1432 $services = MediaWikiServices::getInstance();
1433 $cache = $services->getMainWANObjectCache();
1434 $stats = $services->getStatsdDataFactory();
1435 if ( $this->mOldid && $this->mNewid ) {
1436 $key = $cache->makeKey( ...$this->getDiffBodyCacheKeyParams() );
1437
1438 // Try cache
1439 if ( !$this->mRefreshCache ) {
1440 $difftext = $cache->get( $key );
1441 if ( is_string( $difftext ) ) {
1442 $this->incrementStats( 'hit' );
1443 $difftext = $this->localiseDiff( $difftext );
1444 $this->cacheHitKey = $key;
1445 return $difftext;
1446 }
1447 } // don't try to load but save the result
1448 }
1449 $this->mCacheHit = false;
1450 $this->cacheHitKey = null;
1451
1452 // Loadtext is permission safe, this just clears out the diff
1453 if ( !$this->loadText() ) {
1454 return false;
1455 }
1456
1457 $difftext = '';
1458 // We've checked for revdelete at the beginning of this method; it's OK to ignore
1459 // read permissions here.
1460 $slotContents = $this->getSlotContents();
1461 foreach ( $this->getSlotDiffRenderers() as $role => $slotDiffRenderer ) {
1462 try {
1463 $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'],
1464 $slotContents[$role]['new'] );
1465 } catch ( IncompatibleDiffTypesException $e ) {
1466 $slotDiff = $this->getSlotError( $e->getMessageObject()->parse() );
1467 }
1468 if ( $slotDiff && $role !== SlotRecord::MAIN ) {
1469 // FIXME: ask SlotRoleHandler::getSlotNameMessage
1470 $slotTitle = $role;
1471 $difftext .= $this->getSlotHeader( $slotTitle );
1472 }
1473 $difftext .= $slotDiff;
1474 }
1475
1476 // Save to cache for 7 days
1477 if ( !$this->hookRunner->onAbortDiffCache( $this ) ) {
1478 $this->incrementStats( 'uncacheable' );
1479 } elseif ( $key !== false ) {
1480 $this->incrementStats( 'miss' );
1481 $cache->set( $key, $difftext, 7 * 86400 );
1482 } else {
1483 $this->incrementStats( 'uncacheable' );
1484 }
1485 // localise line numbers and title attribute text
1486 $difftext = $this->localiseDiff( $difftext );
1487
1488 return $difftext;
1489 }
1490
1497 public function getDiffBodyForRole( $role ) {
1498 $diffRenderers = $this->getSlotDiffRenderers();
1499 if ( !isset( $diffRenderers[$role] ) ) {
1500 return false;
1501 }
1502
1503 $slotContents = $this->getSlotContents();
1504 try {
1505 $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'],
1506 $slotContents[$role]['new'] );
1507 } catch ( IncompatibleDiffTypesException $e ) {
1508 $slotDiff = $this->getSlotError( $e->getMessageObject()->parse() );
1509 }
1510 if ( $slotDiff === '' ) {
1511 return false;
1512 }
1513
1514 if ( $role !== SlotRecord::MAIN ) {
1515 // TODO use human-readable role name at least
1516 $slotTitle = $role;
1517 $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff;
1518 }
1519
1520 return $this->localiseDiff( $slotDiff );
1521 }
1522
1529 protected function getSlotHeader( $headerText ) {
1530 // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1531 $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1532 $userLang = $this->getLanguage()->getHtmlCode();
1533 return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-header', 'lang' => $userLang ],
1534 Html::element( 'th', [ 'colspan' => $columnCount ], $headerText ) );
1535 }
1536
1543 protected function getSlotError( $errorText ) {
1544 // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1545 $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1546 $userLang = $this->getLanguage()->getHtmlCode();
1547 return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-error', 'lang' => $userLang ],
1548 Html::rawElement( 'td', [ 'colspan' => $columnCount ], $errorText ) );
1549 }
1550
1564 protected function getDiffBodyCacheKeyParams() {
1565 if ( !$this->mOldid || !$this->mNewid ) {
1566 throw new BadMethodCallException( 'mOldid and mNewid must be set to get diff cache key.' );
1567 }
1568
1569 $params = [
1570 'diff',
1571 self::DIFF_VERSION,
1572 "old-{$this->mOldid}",
1573 "rev-{$this->mNewid}"
1574 ];
1575
1576 $extraKeys = [];
1577 if ( !$this->isSlotDiffRenderer ) {
1578 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1579 $extraKeys = array_merge( $extraKeys, $slotDiffRenderer->getExtraCacheKeys() );
1580 }
1581 }
1582 ksort( $extraKeys );
1583 return array_merge( $params, array_values( $extraKeys ) );
1584 }
1585
1593 public function getExtraCacheKeys() {
1594 // This method is called when the DifferenceEngine is used for a slot diff. We only care
1595 // about special things, not the revision IDs, which are added to the cache key by the
1596 // page-level DifferenceEngine, and which might not have a valid value for this object.
1597 $this->mOldid = 123456789;
1598 $this->mNewid = 987654321;
1599
1600 // This will repeat a bunch of unnecessary key fields for each slot. Not nice but harmless.
1601 $params = $this->getDiffBodyCacheKeyParams();
1602
1603 // Try to get rid of the standard keys to keep the cache key human-readable:
1604 // call the getDiffBodyCacheKeyParams implementation of the base class, and if
1605 // the child class includes the same keys, drop them.
1606 // Uses an obscure PHP feature where static calls to non-static methods are allowed
1607 // as long as we are already in a non-static method of the same class, and the call context
1608 // ($this) will be inherited.
1609 // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
1610 $standardParams = DifferenceEngine::getDiffBodyCacheKeyParams();
1611 if ( array_slice( $params, 0, count( $standardParams ) ) === $standardParams ) {
1612 $params = array_slice( $params, count( $standardParams ) );
1613 }
1614
1615 return $params;
1616 }
1617
1628 public function setSlotDiffOptions( $options ) {
1629 $validatedOptions = [];
1630 if ( isset( $options['diff-type'] )
1631 && $this->getTextDiffer()->hasFormat( $options['diff-type'] )
1632 ) {
1633 $validatedOptions['diff-type'] = $options['diff-type'];
1634 }
1635 if ( !empty( $options['expand-url'] ) ) {
1636 $validatedOptions['expand-url'] = true;
1637 }
1638 if ( !empty( $options['inline-toggle'] ) ) {
1639 $validatedOptions['inline-toggle'] = true;
1640 }
1641 $this->slotDiffOptions = $validatedOptions;
1642 }
1643
1651 public function setExtraQueryParams( $params ) {
1652 $this->extraQueryParams = $params;
1653 }
1654
1668 public function generateContentDiffBody( Content $old, Content $new ) {
1669 $slotDiffRenderer = $new->getContentHandler()->getSlotDiffRenderer( $this->getContext() );
1670 if (
1671 $slotDiffRenderer instanceof DifferenceEngineSlotDiffRenderer
1672 && $this->isSlotDiffRenderer
1673 ) {
1674 // Oops, we are just about to enter an infinite loop (the slot-level DifferenceEngine
1675 // called a DifferenceEngineSlotDiffRenderer that wraps the same DifferenceEngine class).
1676 // This will happen when a content model has no custom slot diff renderer, it does have
1677 // a custom difference engine, but that does not override this method.
1678 throw new LogicException( get_class( $this ) . ': could not maintain backwards compatibility. '
1679 . 'Please use a SlotDiffRenderer.' );
1680 }
1681 return $slotDiffRenderer->getDiff( $old, $new ) . $this->getDebugString();
1682 }
1683
1696 public function generateTextDiffBody( $otext, $ntext ) {
1697 $slotDiffRenderer = $this->contentHandlerFactory
1698 ->getContentHandler( CONTENT_MODEL_TEXT )
1699 ->getSlotDiffRenderer( $this->getContext() );
1700 if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1701 // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1702 // This is too unlikely to happen to bother handling properly.
1703 throw new LogicException( 'The slot diff renderer for text content should be a '
1704 . 'TextSlotDiffRenderer subclass' );
1705 }
1706 return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1707 }
1708
1715 public static function getEngine() {
1716 $differenceEngine = new self;
1717 $engine = $differenceEngine->getTextDiffer()->getEngineForFormat( 'table' );
1718 if ( $engine === 'external' ) {
1719 return MediaWikiServices::getInstance()->getMainConfig()
1720 ->get( MainConfigNames::ExternalDiffEngine );
1721 } else {
1722 return $engine;
1723 }
1724 }
1725
1734 protected function debug( $generator = "internal" ) {
1735 if ( !$this->enableDebugComment ) {
1736 return '';
1737 }
1738 $data = [ $generator ];
1739 if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) ) {
1740 $data[] = wfHostname();
1741 }
1742 $data[] = ConvertibleTimestamp::now( TS::DB );
1743
1744 return "<!-- diff generator: " .
1745 implode( " ", array_map( "htmlspecialchars", $data ) ) .
1746 " -->\n";
1747 }
1748
1752 private function getDebugString() {
1753 $engine = self::getEngine();
1754 if ( $engine === 'wikidiff2' ) {
1755 return $this->debug( 'wikidiff2' );
1756 } elseif ( $engine === 'php' ) {
1757 return $this->debug( 'native PHP' );
1758 } else {
1759 return $this->debug( "external $engine" );
1760 }
1761 }
1762
1769 private function localiseDiff( $text ) {
1770 return $this->getTextDiffer()->localize( $this->getTextDiffFormat(), $text );
1771 }
1772
1781 public function localiseLineNumbers( $text ) {
1782 return preg_replace_callback( '/<!--LINE (\d+)-->/',
1783 function ( array $matches ) {
1784 if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
1785 return '';
1786 }
1787 return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
1788 }, $text );
1789 }
1790
1796 public function getMultiNotice() {
1797 // The notice only make sense if we are diffing two saved revisions of the same page.
1798 if (
1799 !$this->mOldRevisionRecord || !$this->mNewRevisionRecord
1800 || !$this->mOldPage || !$this->mNewPage
1801 || !$this->mOldPage->equals( $this->mNewPage )
1802 || $this->mOldRevisionRecord->getId() === null
1803 || $this->mNewRevisionRecord->getId() === null
1804 // (T237709) Deleted revs might have different page IDs
1805 || $this->mNewPage->getArticleID() !== $this->mOldRevisionRecord->getPageId()
1806 || $this->mNewPage->getArticleID() !== $this->mNewRevisionRecord->getPageId()
1807 ) {
1808 return '';
1809 }
1810
1811 if ( $this->mOldRevisionRecord->getTimestamp() > $this->mNewRevisionRecord->getTimestamp() ) {
1812 $oldRevRecord = $this->mNewRevisionRecord; // flip
1813 $newRevRecord = $this->mOldRevisionRecord; // flip
1814 } else { // normal case
1815 $oldRevRecord = $this->mOldRevisionRecord;
1816 $newRevRecord = $this->mNewRevisionRecord;
1817 }
1818
1819 // Don't show the notice if too many rows must be scanned
1820 // @todo show some special message for that case
1821 $nEdits = 0;
1822 $revisionIdList = $this->revisionStore->getRevisionIdsBetween(
1823 $this->mNewPage->getArticleID(),
1824 $oldRevRecord,
1825 $newRevRecord,
1826 1000
1827 );
1828 // only count revisions that are visible
1829 if ( count( $revisionIdList ) > 0 ) {
1830 foreach ( $revisionIdList as $revisionId ) {
1831 $revision = $this->revisionStore->getRevisionById( $revisionId );
1832 if ( $revision->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ) ) {
1833 $nEdits++;
1834 }
1835 }
1836 }
1837 if ( $nEdits > 0 && $nEdits <= 1000 ) {
1838 // Use an invalid username to get the wiki's default gender (as fallback)
1839 $newRevUserForGender = '[HIDDEN]';
1840 $limit = 100; // use diff-multi-manyusers if too many users
1841 try {
1842 $users = $this->revisionStore->getAuthorsBetween(
1843 $this->mNewPage->getArticleID(),
1844 $oldRevRecord,
1845 $newRevRecord,
1846 null,
1847 $limit
1848 );
1849 $numUsers = count( $users );
1850
1851 $newRevUser = $newRevRecord->getUser( RevisionRecord::RAW );
1852 $newRevUserText = $newRevUser ? $newRevUser->getName() : '';
1853 $newRevUserSafe = $newRevRecord->getUser(
1854 RevisionRecord::FOR_THIS_USER,
1855 $this->getAuthority()
1856 );
1857 $newRevUserForGender = $newRevUserSafe ? $newRevUserSafe->getName() : '[HIDDEN]';
1858 if ( $numUsers == 1 && $users[0]->getName() == $newRevUserText ) {
1859 $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1860 }
1861 } catch ( InvalidArgumentException ) {
1862 $numUsers = 0;
1863 }
1864
1865 return self::intermediateEditsMsg( $nEdits, $numUsers, $limit, $newRevUserForGender );
1866 }
1867
1868 return '';
1869 }
1870
1881 public static function intermediateEditsMsg( $numEdits, $numUsers, $limit, $lastUser = '[HIDDEN]' ) {
1882 if ( $numUsers === 0 ) {
1883 $msg = 'diff-multi-sameuser';
1884 return wfMessage( $msg )
1885 ->numParams( $numEdits, $numUsers )
1886 ->params( $lastUser )
1887 ->parse();
1888 } elseif ( $numUsers > $limit ) {
1889 $msg = 'diff-multi-manyusers';
1890 $numUsers = $limit;
1891 } else {
1892 $msg = 'diff-multi-otherusers';
1893 }
1894
1895 return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1896 }
1897
1902 private function userCanEdit( RevisionRecord $revRecord ) {
1903 if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1904 return false;
1905 }
1906
1907 return true;
1908 }
1909
1919 public function getRevisionHeader( RevisionRecord $rev, $complete = '' ) {
1920 $lang = $this->getLanguage();
1921 $user = $this->getUser();
1922 $revtimestamp = $rev->getTimestamp();
1923 $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1924 $dateofrev = $lang->userDate( $revtimestamp, $user );
1925 $timeofrev = $lang->userTime( $revtimestamp, $user );
1926
1927 $header = $this->msg(
1928 $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1929 $timestamp,
1930 $dateofrev,
1931 $timeofrev
1932 );
1933
1934 if ( $complete !== 'complete' ) {
1935 return $header->escaped();
1936 }
1937
1938 $title = $rev->getPageAsLinkTarget();
1939
1940 if ( $this->userCanEdit( $rev ) ) {
1941 $header = $this->linkRenderer->makeKnownLink(
1942 $title,
1943 $header->text(),
1944 [],
1945 [ 'oldid' => $rev->getId() ]
1946 );
1947 $editQuery = [ 'action' => 'edit' ];
1948 if ( !$rev->isCurrent() ) {
1949 $editQuery['oldid'] = $rev->getId();
1950 }
1951
1952 $key = $this->getAuthority()->probablyCan( 'edit', $rev->getPage() ) ? 'editold' : 'viewsourceold';
1953 $msg = $this->msg( $key )->text();
1954 $editLink = $this->linkRenderer->makeKnownLink( $title, $msg, [], $editQuery );
1955 $header .= ' ' . Html::rawElement(
1956 'span',
1957 [ 'class' => 'mw-diff-edit' ],
1958 $editLink
1959 );
1960 } else {
1961 $header = $header->escaped();
1962 }
1963
1964 // Machine readable information
1965 $header .= Html::element( 'span',
1966 [
1967 'class' => 'mw-diff-timestamp',
1968 'data-timestamp' => wfTimestamp( TS::ISO_8601, $revtimestamp ),
1969 ], ''
1970 );
1971
1972 if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1973 return Html::rawElement(
1974 'span',
1975 [ 'class' => Linker::getRevisionDeletedClass( $rev ) ],
1976 $header
1977 );
1978 }
1979
1980 return $header;
1981 }
1982
1995 public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
1996 // shared.css sets diff in interface language/dir, but the actual content
1997 // is often in a different language, mostly the page content language/dir
1998 $header = Html::openElement( 'table', [
1999 'class' => [
2000 'diff',
2001 // The following classes are used here:
2002 // * diff-type-table
2003 // * diff-type-inline
2004 'diff-type-' . $this->getTextDiffFormat(),
2005 // The following classes are used here:
2006 // * diff-contentalign-left
2007 // * diff-contentalign-right
2008 'diff-contentalign-' . $this->getDiffLang()->alignStart(),
2009 // The following classes are used here:
2010 // * diff-editfont-monospace
2011 // * diff-editfont-sans-serif
2012 // * diff-editfont-serif
2013 'diff-editfont-' . $this->userOptionsLookup->getOption(
2014 $this->getUser(),
2015 'editfont'
2016 )
2017 ],
2018 'data-mw-interface' => '',
2019 ] );
2020 $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
2021
2022 if ( !$diff && !$otitle ) {
2023 $header .= "
2024 <tr class=\"diff-title\" lang=\"{$userLang}\">
2025 <td class=\"diff-ntitle\">{$ntitle}</td>
2026 </tr>";
2027 $multiColspan = 1;
2028 } else {
2029 if ( $diff ) { // Safari/Chrome show broken output if cols not used
2030 $header .= "
2031 <col class=\"diff-marker\" />
2032 <col class=\"diff-content\" />
2033 <col class=\"diff-marker\" />
2034 <col class=\"diff-content\" />";
2035 $colspan = 2;
2036 $multiColspan = 4;
2037 } else {
2038 $colspan = 1;
2039 $multiColspan = 2;
2040 }
2041 if ( $otitle || $ntitle ) {
2042 // FIXME Hardcoding values from TableDiffFormatter.
2043 $deletedClass = 'diff-side-deleted';
2044 $addedClass = 'diff-side-added';
2045 $header .= "
2046 <tr class=\"diff-title\" lang=\"{$userLang}\">
2047 <td colspan=\"$colspan\" class=\"diff-otitle {$deletedClass}\">{$otitle}</td>
2048 <td colspan=\"$colspan\" class=\"diff-ntitle {$addedClass}\">{$ntitle}</td>
2049 </tr>";
2050 }
2051 }
2052
2053 if ( $multi != '' ) {
2054 $header .= "<tr><td colspan=\"{$multiColspan}\" " .
2055 "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
2056 }
2057 if ( $notice != '' ) {
2058 $header .= "<tr><td colspan=\"{$multiColspan}\" " .
2059 "class=\"diff-notice\" lang=\"{$userLang}\">{$notice}</td></tr>";
2060 }
2061
2062 return $header . $diff . "</table>";
2063 }
2064
2072 public function setContent( Content $oldContent, Content $newContent ) {
2073 $this->mOldContent = $oldContent;
2074 $this->mNewContent = $newContent;
2075
2076 $this->mTextLoaded = 2;
2077 $this->mRevisionsLoaded = true;
2078 $this->isContentOverridden = true;
2079 $this->slotDiffRenderers = null;
2080 }
2081
2087 public function setRevisions(
2088 ?RevisionRecord $oldRevision, RevisionRecord $newRevision
2089 ) {
2090 if ( $oldRevision ) {
2091 $this->mOldRevisionRecord = $oldRevision;
2092 $this->mOldid = $oldRevision->getId();
2093 $this->mOldPage = Title::newFromPageIdentity( $oldRevision->getPage() );
2094 // This method is meant for edit diffs and such so there is no reason to provide a
2095 // revision that's not readable to the user, but check it just in case.
2096 $this->mOldContent = $oldRevision->getContent( SlotRecord::MAIN,
2097 RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
2098 if ( !$this->mOldContent ) {
2099 $this->addRevisionLoadError( 'old' );
2100 }
2101 } else {
2102 $this->mOldPage = null;
2103 $this->mOldRevisionRecord = $this->mOldid = false;
2104 }
2105 $this->mNewRevisionRecord = $newRevision;
2106 $this->mNewid = $newRevision->getId();
2107 $this->mNewPage = Title::newFromPageIdentity( $newRevision->getPage() );
2108 $this->mNewContent = $newRevision->getContent( SlotRecord::MAIN,
2109 RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
2110 if ( !$this->mNewContent ) {
2111 $this->addRevisionLoadError( 'new' );
2112 }
2113
2114 $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true;
2115 $this->mTextLoaded = $oldRevision ? 2 : 1;
2116 $this->isContentOverridden = false;
2117 $this->slotDiffRenderers = null;
2118 }
2119
2126 public function setTextLanguage( Language $lang ) {
2127 $this->mDiffLang = $lang;
2128 }
2129
2142 public function mapDiffPrevNext( $old, $new ) {
2143 if ( $new === 'prev' ) {
2144 // Show diff between revision $old and the previous one. Get previous one from DB.
2145 $newid = intval( $old );
2146 $oldid = false;
2147 $newRev = $this->revisionStore->getRevisionById( $newid );
2148 if ( $newRev ) {
2149 $oldRev = $this->revisionStore->getPreviousRevision( $newRev );
2150 if ( $oldRev ) {
2151 $oldid = $oldRev->getId();
2152 }
2153 }
2154 } elseif ( $new === 'next' ) {
2155 // Show diff between revision $old and the next one. Get next one from DB.
2156 $oldid = intval( $old );
2157 $newid = false;
2158 $oldRev = $this->revisionStore->getRevisionById( $oldid );
2159 if ( $oldRev ) {
2160 $newRev = $this->revisionStore->getNextRevision( $oldRev );
2161 if ( $newRev ) {
2162 $newid = $newRev->getId();
2163 }
2164 }
2165 } else {
2166 $oldid = intval( $old );
2167 $newid = intval( $new );
2168 }
2169
2170 // @phan-suppress-next-line PhanTypeMismatchReturn getId does not return null here
2171 return [ $oldid, $newid ];
2172 }
2173
2174 private function loadRevisionIds() {
2175 if ( $this->mRevisionsIdsLoaded ) {
2176 return;
2177 }
2178
2179 $this->mRevisionsIdsLoaded = true;
2180
2181 $old = $this->mOldid;
2182 $new = $this->mNewid;
2183
2184 [ $this->mOldid, $this->mNewid ] = self::mapDiffPrevNext( $old, $new );
2185 if ( $new === 'next' && $this->mNewid === false ) {
2186 # if no result, NewId points to the newest old revision. The only newer
2187 # revision is cur, which is "0".
2188 $this->mNewid = 0;
2189 }
2190
2191 $this->hookRunner->onNewDifferenceEngine(
2192 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
2193 $this->getTitle(), $this->mOldid, $this->mNewid, $old, $new );
2194 }
2195
2209 public function loadRevisionData() {
2210 if ( $this->mRevisionsLoaded ) {
2211 return $this->isContentOverridden ||
2212 ( $this->mOldRevisionRecord !== null && $this->mNewRevisionRecord !== null );
2213 }
2214
2215 // Whether it succeeds or fails, we don't want to try again
2216 $this->mRevisionsLoaded = true;
2217
2218 $this->loadRevisionIds();
2219
2220 // Load the new RevisionRecord object
2221 if ( $this->mNewid ) {
2222 $this->mNewRevisionRecord = $this->revisionStore->getRevisionById( $this->mNewid );
2223 } else {
2224 $this->mNewRevisionRecord = $this->revisionStore->getRevisionByTitle( $this->getTitle() );
2225 }
2226
2227 // Load the old RevisionRecord object
2228 $this->mOldRevisionRecord = false;
2229 if ( $this->mOldid ) {
2230 $this->mOldRevisionRecord = $this->revisionStore->getRevisionById( $this->mOldid );
2231 } elseif ( $this->mOldid === 0 && $this->mNewRevisionRecord instanceof RevisionRecord ) {
2232 $revRecord = $this->revisionStore->getPreviousRevision( $this->mNewRevisionRecord );
2233 // No previous revision; mark to show as first-version only.
2234 $this->mOldid = $revRecord ? $revRecord->getId() : false;
2235 $this->mOldRevisionRecord = $revRecord ?? false;
2236 } /* elseif ( $this->mOldid === false ) leave mOldRevisionRecord false; */
2237
2238 if ( $this->mOldRevisionRecord === null || $this->mNewRevisionRecord === null ) {
2239 return false;
2240 }
2241
2242 // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
2243 $this->mNewid = $this->mNewRevisionRecord->getId();
2244 $this->mNewPage = $this->mNewid ?
2245 Title::newFromPageIdentity( $this->mNewRevisionRecord->getPage() ) :
2246 null;
2247
2248 if ( $this->mOldRevisionRecord && $this->mOldRevisionRecord->getId() ) {
2249 $this->mOldPage = Title::newFromPageIdentity( $this->mOldRevisionRecord->getPage() );
2250 } else {
2251 $this->mOldPage = null;
2252 }
2253
2254 // Load tags information for both revisions
2255 $dbr = $this->dbProvider->getReplicaDatabase();
2256 $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
2257 if ( $this->mOldid !== false ) {
2258 $tagIds = $dbr->newSelectQueryBuilder()
2259 ->select( 'ct_tag_id' )
2260 ->from( 'change_tag' )
2261 ->where( [ 'ct_rev_id' => $this->mOldid ] )
2262 ->caller( __METHOD__ )->fetchFieldValues();
2263 $tags = [];
2264 foreach ( $tagIds as $tagId ) {
2265 try {
2266 $tags[] = $changeTagDefStore->getName( (int)$tagId );
2267 } catch ( NameTableAccessException ) {
2268 continue;
2269 }
2270 }
2271 $this->mOldTags = implode( ',', $tags );
2272 } else {
2273 $this->mOldTags = false;
2274 }
2275
2276 $tagIds = $dbr->newSelectQueryBuilder()
2277 ->select( 'ct_tag_id' )
2278 ->from( 'change_tag' )
2279 ->where( [ 'ct_rev_id' => $this->mNewid ] )
2280 ->caller( __METHOD__ )->fetchFieldValues();
2281 $tags = [];
2282 foreach ( $tagIds as $tagId ) {
2283 try {
2284 $tags[] = $changeTagDefStore->getName( (int)$tagId );
2285 } catch ( NameTableAccessException ) {
2286 continue;
2287 }
2288 }
2289 $this->mNewTags = implode( ',', $tags );
2290
2291 return true;
2292 }
2293
2301 public function loadText() {
2302 if ( $this->mTextLoaded == 2 ) {
2303 return $this->loadRevisionData() &&
2304 ( $this->mOldRevisionRecord === false || $this->mOldContent )
2305 && $this->mNewContent;
2306 }
2307
2308 // Whether it succeeds or fails, we don't want to try again
2309 $this->mTextLoaded = 2;
2310
2311 if ( !$this->loadRevisionData() ) {
2312 return false;
2313 }
2314
2315 if ( $this->mOldRevisionRecord ) {
2316 $this->mOldContent = $this->mOldRevisionRecord->getContent(
2317 SlotRecord::MAIN,
2318 RevisionRecord::FOR_THIS_USER,
2319 $this->getAuthority()
2320 );
2321 if ( $this->mOldContent === null ) {
2322 return false;
2323 }
2324 }
2325
2326 $this->mNewContent = $this->mNewRevisionRecord->getContent(
2327 SlotRecord::MAIN,
2328 RevisionRecord::FOR_THIS_USER,
2329 $this->getAuthority()
2330 );
2331 $this->hookRunner->onDifferenceEngineLoadTextAfterNewContentIsLoaded( $this );
2332 if ( $this->mNewContent === null ) {
2333 return false;
2334 }
2335
2336 return true;
2337 }
2338
2344 public function loadNewText() {
2345 if ( $this->mTextLoaded >= 1 ) {
2346 return $this->loadRevisionData();
2347 }
2348
2349 $this->mTextLoaded = 1;
2350
2351 if ( !$this->loadRevisionData() ) {
2352 return false;
2353 }
2354
2355 $this->mNewContent = $this->mNewRevisionRecord->getContent(
2356 SlotRecord::MAIN,
2357 RevisionRecord::FOR_THIS_USER,
2358 $this->getAuthority()
2359 );
2360
2361 $this->hookRunner->onDifferenceEngineAfterLoadNewText( $this );
2362
2363 return true;
2364 }
2365
2371 protected function getTextDiffer() {
2372 if ( $this->textDiffer === null ) {
2373 $this->textDiffer = new ManifoldTextDiffer(
2374 $this->getContext(),
2375 $this->getDiffLang(),
2376 $this->getConfig()->get( MainConfigNames::DiffEngine ),
2377 $this->getConfig()->get( MainConfigNames::ExternalDiffEngine ),
2378 $this->getConfig()->get( MainConfigNames::Wikidiff2Options )
2379 );
2380 }
2381 return $this->textDiffer;
2382 }
2383
2390 public function getSupportedFormats() {
2391 return $this->getTextDiffer()->getFormats();
2392 }
2393
2400 public function getTextDiffFormat() {
2401 return $this->slotDiffOptions['diff-type'] ?? 'table';
2402 }
2403
2409 private function getMobileFooter( ?RevisionRecord $newRevRecord, array $formattedRevisionTools ): string {
2410 $this->getOutput()->addModuleStyles( [ 'codex-styles' ] );
2411 $summary = Html::rawElement(
2412 'summary',
2413 [ "class" => "cdx-accordion--has-icon" ],
2414 Html::rawElement(
2415 'h3',
2416 [ "class" => "cdx-accordion__header" ],
2417 Html::rawElement(
2418 'span',
2419 [ 'class' => 'cdx-accordion__header__title' ],
2420 Linker::revUserTools( $newRevRecord, !$this->unhide )
2421 )
2422 )
2423 );
2424 $rollbackLink = '';
2425 if ( $this->mNewRevisionRecord->isCurrent() &&
2426 $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
2427 ) {
2428 $rollbackLink = Linker::generateRollback(
2429 $this->mNewRevisionRecord,
2430 $this->getContext(),
2431 [ 'noBrackets' ]
2432 );
2433 }
2434 $user = $newRevRecord->getUser();
2435 $userGroups = [];
2436 if ( $user !== null ) {
2437 $userGroups = $this->userGroupManager->getUserGroups( $user );
2438 }
2439 $userGroupCount = count( $userGroups );
2440 $userEditCount = $user === null ? '' : $this->getUserEditCount( $user );
2441 $userGroupList = [];
2442 foreach ( $userGroups as $userGroup ) {
2443 $userGroupList[] = $this->msg( "group-$userGroup" )->escaped();
2444 }
2445 if ( $userGroupCount == 0 ) {
2446 $userGroupsPopover = '';
2447 } else {
2448 $popover = Html::rawElement(
2449 'div',
2450 [ 'class' => 'cdx-popover mw-diff-usergroups-popover', 'role' => 'tooltip' ],
2451 Html::rawElement(
2452 'div',
2453 [ 'class' => 'cdx-popover__body' ],
2454 $this->msg( 'diff-usergroups-list', $this->getLanguage()->commaList( $userGroupList ) )->escaped()
2455 ) . Html::rawElement( 'div', [ 'class' => 'cdx-popover__arrow' ] )
2456 );
2457 $popoverTrigger = Html::element(
2458 'span',
2459 [ 'class' => 'cdx-popover-trigger cdx-button__icon cdx-icon cdx-icon--info ', 'tabindex' => '0' ],
2460 );
2461 $userGroupsPopover = Html::rawElement(
2462 'div',
2463 [ 'class' => 'mw-diff-usermetadata' ],
2464
2465 Html::element( 'span',
2466 [ 'class' => 'mw-diff-usergroups-popover-text' ],
2467 $this->msg( 'diff-usergroups', $userGroupCount )->text()
2468 ) .
2469 Html::rawElement(
2470 'div',
2471 [ 'class' => 'mw-diff-usergroups-popover-wrapper' ],
2472 $popoverTrigger . $popover
2473 )
2474 );
2475 }
2476
2477 $content = Html::rawElement(
2478 'div',
2479 [ "class" => "cdx-accordion__content" ],
2480 $userGroupsPopover
2481 . $userEditCount
2482 . $rollbackLink
2483 . implode( '', $formattedRevisionTools )
2484 );
2485 return Html::rawElement(
2486 'details',
2487 [ "class" => "mw-diff-new-mobile-footer-accordion cdx-accordion cdx-accordion--separation-minimal" ],
2488 $summary . $content
2489 );
2490 }
2491
2492}
2493
2495class_alias( DifferenceEngine::class, 'DifferenceEngine' );
const NS_SPECIAL
Definition Defines.php:40
const CONTENT_MODEL_TEXT
Definition Defines.php:238
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
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.
Recent changes tagging.
This is the main service interface for converting single-line comments from various DB comment fields...
The simplest way of implementing IContextSource is to hold a RequestContext as a member variable and ...
setContext(IContextSource $context)
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
getContext()
Get the base IContextSource object.
makeTitle( $linkId)
Convert a link ID to a Title.to override Title
B/C adapter for turning a DifferenceEngine into a SlotDiffRenderer.
DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
bool $enableDebugComment
Set this to true to add debug info to the HTML output.
Title null $mNewPage
Title of new revision or null if the new revision does not exist or does not belong to a page.
bool $isContentOverridden
Was the content overridden via setContent()? If the content was overridden, most internal state (e....
loadText()
Load the text of the revisions, as well as revision data.
getRevisionHeader(RevisionRecord $rev, $complete='')
Get a header for a specified revision.
authorizeView(Authority $performer)
Check whether the user can read both of the pages for the current diff.
getSlotError( $errorText)
Get an error message for inclusion in a diff body (as a table row).
loadRevisionData()
Load revision metadata for the specified revisions.
isUserAllowedToSeeRevisions(Authority $performer)
Checks whether the current user has permission for accessing the revisions of the diff.
getMultiNotice()
If there are revisions between the ones being compared, return a note saying so.
getDefaultLanguage()
Get the language to use if none has been set by setTextLanguage().
getSlotContents()
Get the old and new content objects for all slots.
getDiffBodyForRole( $role)
Get the diff table body for one slot, without header.
generateTextDiffBody( $otext, $ntext)
Generate a diff, no caching.
markAsSlotDiffRenderer()
Mark this DifferenceEngine as a slot renderer (as opposed to a page renderer).
getDiffLang()
Get the language in which the diff text is written.
bool $mRefreshCache
Refresh the diff cache.
int false null $mOldid
Revision ID for the old revision.
getExtraCacheKeys()
Implements DifferenceEngineSlotDiffRenderer::getExtraCacheKeys().
setReducedLineNumbers( $value=true)
Set reduced line numbers mode.
showDiff( $otitle, $ntitle, $notice='')
Get the diff text, send it to the OutputPage object Returns false if the diff could not be generated,...
static getEngine()
Process DiffEngine config and get a sensible, usable engine.
hasDeletedRevision()
Checks whether one of the given Revisions was deleted.
debug( $generator="internal")
Generate a debug comment indicating diff generating time, server node, and generator backend.
int string false null $mNewid
Revision ID for the new revision.
getNewid()
Get the ID of new revision (right pane) of the diff.
setRevisions(?RevisionRecord $oldRevision, RevisionRecord $newRevision)
Use specified text instead of loading from the database.
localiseLineNumbers( $text)
Replace a common convention for language-independent line numbers with the text in the user's languag...
bool $mRevisionsLoaded
Have the revisions been loaded.
getDiffBodyCacheKeyParams()
Get the cache key parameters.
loadNewText()
Load the text of the new revision, not the old one.
renderNewRevision()
Show the new revision of the page.
setExtraQueryParams( $params)
Set query parameters to append to diff page links.
__construct( $context=null, $old=0, $new=0, $rcid=0, $refreshCache=false, $unhide=false)
Title null $mOldPage
Title of old revision or null if the old revision does not exist or does not belong to a page.
getNewRevision()
Get the right side of the diff.
bool $mCacheHit
Was the diff fetched from cache?
bool $unhide
Show rev_deleted content if allowed.
getOldid()
Get the ID of old revision (left pane) of the diff.
getOldRevision()
Get the left side of the diff.
mapDiffPrevNext( $old, $new)
Maps a revision pair definition as accepted by DifferenceEngine constructor to a pair of actual integ...
getTextDiffer()
Get the TextDiffer which will be used for rendering text.
getMarkPatrolledLinkInfo()
Returns an array of meta data needed to build a "mark as patrolled" link and adds a JS module to the ...
bool $isSlotDiffRenderer
Temporary hack for B/C while slot diff related methods of DifferenceEngine are being deprecated.
string $mMarkPatrolledLink
Link to action=markpatrolled.
SlotDiffRenderer[] null $slotDiffRenderers
DifferenceEngine classes for the slots, keyed by role name.
deletedIdMarker( $id)
Build a wikitext link toward a deleted revision, if viewable.
addHeader( $diff, $otitle, $ntitle, $multi='', $notice='')
Add the header to a diff body.
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.
setTextLanguage(Language $lang)
Set the language in which the diff text is written.
int $mTextLoaded
How many text blobs have been loaded, 0, 1 or 2?
setContent(Content $oldContent, Content $newContent)
Use specified text instead of loading from the database.
getDiff( $otitle, $ntitle, $notice='')
Get complete diff table, including header.
bool $mReducedLineNumbers
If true, line X is not displayed when X is 1, for example to increase readability and conserve space ...
getSupportedFormats()
Get the list of supported text diff formats.
getTitle()
1.18 to override Title|null
shouldBeHiddenFromUser(Authority $performer)
Checks whether the diff should be hidden from the current user This is based on whether the user is a...
static intermediateEditsMsg( $numEdits, $numUsers, $limit, $lastUser='[HIDDEN]')
Get a notice about how many intermediate edits and users there are.
getTextDiffFormat()
Get the selected text diff format.
generateContentDiffBody(Content $old, Content $new)
Generate a diff, no caching.
getSlotHeader( $headerText)
Get a slot header for inclusion in a diff body (as a table row).
getDiffBody()
Get the diff table body, without header.
deletedLink( $id)
Look up a special:Undelete link to the given deleted revision id, as a workaround for being unable to...
showDiffStyle()
Add style sheets for diff display.
markPatrolledLink()
Build a link to mark a change as patrolled.
Exception thrown when trying to render a diff between two content types which cannot be compared (thi...
Renders a diff for a single slot (that is, a diff between two content objects).
A TextDiffer which acts as a container for other TextDiffers, and dispatches requests to them.
Renders a slot diff by doing a text diff on the native representation.
getMessageObject()
Return a Message object for this exception.Message
Show an error when a user tries to do something they do not have the necessary permissions for.
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:44
Base class for language-specific code.
Definition Language.php:65
Class that generates HTML for internal links.
Some internal bits split of from Skin.php.
Definition Linker.php:47
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:144
Service for getting rendered output of a given page.
Service for creating WikiPage objects.
A StatusValue for permission errors.
Base class for lists of recent changes shown on special pages.
Utility class for creating and reading rows in the recentchanges table.
Exception raised when the text of a revision is permanently missing or corrupt.
Page revision base class.
getContent( $role, $audience=self::FOR_PUBLIC, ?Authority $performer=null)
Returns the Content of the given slot of this revision.
getUser( $audience=self::FOR_PUBLIC, ?Authority $performer=null)
Fetch revision's author's user identity, if it's available to the specified audience.
getPage()
Returns the page this revision belongs to.
isCurrent()
Checks whether the revision record is a stored latest revision.
getTimestamp()
MCR migration note: this replaced Revision::getTimestamp.
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.
Parent class for all special pages.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
Exception representing a failure to look up a row from a name table.
Represents a title within MediaWiki.
Definition Title.php:69
Provides access to user options.
Track info about user edit counts and timings.
Manage user group memberships.
Represents the membership of one user in one user group.
Convenience functions for interpreting UserIdentity objects using additional services or config.
Content objects represent page content, e.g.
Definition Content.php:28
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 with the current execution context,...
Definition Authority.php:23
authorizeRead(string $action, PageIdentity $target, ?PermissionStatus $status=null)
Authorize read access.
Interface for objects representing user identity.
Provide primary and replica IDatabase connections.
msg( $key,... $params)