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;
57use Wikimedia\Timestamp\ConvertibleTimestamp;
58use Wikimedia\Timestamp\TimestampFormat as TS;
59
83
84 use DeprecationHelper;
85
92 private const DIFF_VERSION = '1.41';
93
100 protected $mOldid;
101
108 protected $mNewid;
109
120 private $mOldRevisionRecord;
121
130 private $mNewRevisionRecord;
131
136 protected $mOldPage;
137
142 protected $mNewPage;
143
148 private $mOldTags;
149
154 private $mNewTags;
155
161 private $mOldContent;
162
168 private $mNewContent;
169
171 protected $mDiffLang;
172
174 private $mRevisionsIdsLoaded = false;
175
177 protected $mRevisionsLoaded = false;
178
180 protected $mTextLoaded = 0;
181
190 protected $isContentOverridden = false;
191
193 protected $mCacheHit = false;
194
196 private $cacheHitKey = null;
197
204 public $enableDebugComment = false;
205
209 protected $mReducedLineNumbers = false;
210
212 protected $mMarkPatrolledLink = null;
213
215 protected $unhide = false;
216
218 protected $mRefreshCache = false;
219
221 protected $slotDiffRenderers = null;
222
229 protected $isSlotDiffRenderer = false;
230
235 private $slotDiffOptions = [];
236
241 private $extraQueryParams = [];
242
244 private $textDiffer;
245
247 private IContentHandlerFactory $contentHandlerFactory;
248 private RevisionStore $revisionStore;
249 private ArchivedRevisionLookup $archivedRevisionLookup;
250 private HookRunner $hookRunner;
251 private WikiPageFactory $wikiPageFactory;
252 private UserOptionsLookup $userOptionsLookup;
253 private CommentFormatter $commentFormatter;
254 private IConnectionProvider $dbProvider;
255 private UserGroupManager $userGroupManager;
256 private UserEditTracker $userEditTracker;
257 private UserIdentityUtils $userIdentityUtils;
258 private RecentChangeLookup $recentChangeLookup;
259 private ChangeTagsFormatter $changeTagsFormatter;
260
262 private $revisionLoadErrors = [];
263
272 public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
273 $refreshCache = false, $unhide = false
274 ) {
275 if ( $context instanceof IContextSource ) {
276 $this->setContext( $context );
277 }
278
279 wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'" );
280
281 $this->mOldid = $old;
282 $this->mNewid = $new;
283 $this->mRefreshCache = $refreshCache;
284 $this->unhide = $unhide;
285
286 $services = MediaWikiServices::getInstance();
287 $this->linkRenderer = $services->getLinkRenderer();
288 $this->contentHandlerFactory = $services->getContentHandlerFactory();
289 $this->revisionStore = $services->getRevisionStore();
290 $this->archivedRevisionLookup = $services->getArchivedRevisionLookup();
291 $this->hookRunner = new HookRunner( $services->getHookContainer() );
292 $this->wikiPageFactory = $services->getWikiPageFactory();
293 $this->userOptionsLookup = $services->getUserOptionsLookup();
294 $this->commentFormatter = $services->getCommentFormatter();
295 $this->dbProvider = $services->getConnectionProvider();
296 $this->userGroupManager = $services->getUserGroupManager();
297 $this->userEditTracker = $services->getUserEditTracker();
298 $this->userIdentityUtils = $services->getUserIdentityUtils();
299 $this->recentChangeLookup = $services->getRecentChangeLookup();
300 $this->changeTagsFormatter = $services->getChangeTagsFormatter();
301 }
302
308 protected function getSlotDiffRenderers() {
309 if ( $this->isSlotDiffRenderer ) {
310 throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
311 }
312
313 if ( $this->slotDiffRenderers === null ) {
314 if ( !$this->loadRevisionData() ) {
315 return [];
316 }
317
318 $slotContents = $this->getSlotContents();
319 $this->slotDiffRenderers = [];
320 foreach ( $slotContents as $role => $contents ) {
321 if ( $contents['new'] && $contents['old']
322 && $contents['new']->equals( $contents['old'] )
323 ) {
324 // Do not produce a diff of identical content
325 continue;
326 }
327 if ( !$contents['new'] && !$contents['old'] ) {
328 // Nothing to diff (i.e both revisions are corrupted), just ignore
329 continue;
330 }
331 $handler = ( $contents['new'] ?: $contents['old'] )->getContentHandler();
332 $this->slotDiffRenderers[$role] = $handler->getSlotDiffRenderer(
333 $this->getContext(),
334 $this->slotDiffOptions + [
335 'contentLanguage' => $this->getDiffLang()->getCode(),
336 'textDiffer' => $this->getTextDiffer()
337 ]
338 );
339 }
340 }
341
343 }
344
351 public function markAsSlotDiffRenderer() {
352 $this->isSlotDiffRenderer = true;
353 }
354
360 protected function getSlotContents() {
361 if ( $this->isContentOverridden ) {
362 return [
363 SlotRecord::MAIN => [ 'old' => $this->mOldContent, 'new' => $this->mNewContent ]
364 ];
365 } elseif ( !$this->loadRevisionData() ) {
366 return [];
367 }
368
369 $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
370 $oldSlots = $this->mOldRevisionRecord ?
371 $this->mOldRevisionRecord->getPrimarySlots()->getSlots() :
372 [];
373 // The order here will determine the visual order of the diff. The current logic is
374 // slots of the new revision first in natural order, then deleted ones. This is ad hoc
375 // and should not be relied on - in the future we may want the ordering to depend
376 // on the page type.
377 $roles = array_keys( array_merge( $newSlots, $oldSlots ) );
378
379 $slots = [];
380 foreach ( $roles as $role ) {
381 $slots[$role] = [
382 'old' => $this->loadSingleSlot(
383 $oldSlots[$role] ?? null,
384 'old'
385 ),
386 'new' => $this->loadSingleSlot(
387 $newSlots[$role] ?? null,
388 'new'
389 )
390 ];
391 }
392 // move main slot to front
393 if ( isset( $slots[SlotRecord::MAIN] ) ) {
394 $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
395 }
396 return $slots;
397 }
398
406 private function loadSingleSlot( ?SlotRecord $slot, string $which ) {
407 if ( !$slot ) {
408 return null;
409 }
410 try {
411 return $slot->getContent();
412 } catch ( BadRevisionException ) {
413 $this->addRevisionLoadError( $which );
414 return null;
415 }
416 }
417
423 private function addRevisionLoadError( $which ) {
424 $this->revisionLoadErrors[] = $this->msg( $which === 'new'
425 ? 'difference-bad-new-revision' : 'difference-bad-old-revision'
426 );
427 }
428
435 public function getRevisionLoadErrors() {
436 return $this->revisionLoadErrors;
437 }
438
443 private function hasNewRevisionLoadError() {
444 foreach ( $this->revisionLoadErrors as $error ) {
445 if ( $error->getKey() === 'difference-bad-new-revision' ) {
446 return true;
447 }
448 }
449 return false;
450 }
451
453 public function getTitle() {
454 // T202454 avoid errors when there is no title
455 return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
456 }
457
464 public function setReducedLineNumbers( $value = true ) {
465 $this->mReducedLineNumbers = $value;
466 }
467
473 public function getDiffLang() {
474 # Default language in which the diff text is written.
475 $this->mDiffLang ??= $this->getDefaultLanguage();
476 return $this->mDiffLang;
477 }
478
485 protected function getDefaultLanguage() {
486 return $this->getTitle()->getPageLanguage();
487 }
488
492 public function wasCacheHit() {
493 return $this->mCacheHit;
494 }
495
503 public function getOldid() {
504 $this->loadRevisionIds();
505
506 return $this->mOldid;
507 }
508
515 public function getNewid() {
516 $this->loadRevisionIds();
517
518 return $this->mNewid;
519 }
520
527 public function getOldRevision() {
528 return $this->mOldRevisionRecord ?: null;
529 }
530
536 public function getNewRevision() {
537 return $this->mNewRevisionRecord;
538 }
539
548 public function deletedLink( $id ) {
549 if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
550 $revRecord = $this->archivedRevisionLookup->getArchivedRevisionRecord( null, $id );
551 if ( $revRecord ) {
552 $title = Title::newFromPageIdentity( $revRecord->getPage() );
553
554 return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
555 'target' => $title->getPrefixedText(),
556 'timestamp' => $revRecord->getTimestamp()
557 ] );
558 }
559 }
560
561 return false;
562 }
563
571 public function deletedIdMarker( $id ) {
572 $link = $this->deletedLink( $id );
573 if ( $link ) {
574 return "[$link $id]";
575 } else {
576 return (string)$id;
577 }
578 }
579
580 private function showMissingRevision() {
581 $out = $this->getOutput();
582
583 $missing = [];
584 if ( $this->mOldid && !$this->mOldRevisionRecord ) {
585 $missing[] = $this->deletedIdMarker( $this->mOldid );
586 }
587 if ( $this->mNewid && !$this->mNewRevisionRecord ) {
588 $missing[] = $this->deletedIdMarker( $this->mNewid );
589 }
590
591 $out->setPageTitleMsg( $this->msg( 'errorpagetitle' ) );
592
593 // Don't display the deletion log for the main page, it's probably not useful
594 $key = $this->getTitle()->equals( Title::newMainPage() ) ?
595 'difference-missing-revision-nolog' :
596 'difference-missing-revision';
597
598 $msg = $this->msg( $key )
599 ->params( Message::listParam( $missing ) )
600 ->numParams( count( $missing ) )
601 ->parseAsBlock();
602 $out->addHTML( $msg );
603 }
604
610 public function hasDeletedRevision() {
611 $this->loadRevisionData();
612 return (
613 $this->mNewRevisionRecord &&
614 $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
615 ) ||
616 (
617 $this->mOldRevisionRecord &&
618 $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
619 );
620 }
621
629 public function authorizeView( Authority $performer ): PermissionStatus {
630 $this->loadRevisionData();
631 $permStatus = PermissionStatus::newEmpty();
632 if ( $this->mNewPage ) {
633 $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
634 }
635 if ( $this->mOldPage ) {
636 $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
637 }
638 return $permStatus;
639 }
640
646 public function hasSuppressedRevision() {
647 return $this->hasDeletedRevision() && (
648 ( $this->mOldRevisionRecord &&
649 $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
650 ( $this->mNewRevisionRecord &&
651 $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
652 );
653 }
654
661 private function getUserEditCount( $user ): string {
662 $editCount = $this->userEditTracker->getUserEditCount( $user );
663 if ( $editCount === null ) {
664 return '';
665 }
666
667 return Html::rawElement( 'div', [
668 'class' => 'mw-diff-usereditcount',
669 ],
670 $this->msg(
671 'diff-user-edits',
672 $this->getLanguage()->formatNum( $editCount )
673 )->parse()
674 );
675 }
676
683 private function getUserRoles( UserIdentity $user ) {
684 if ( !$this->userIdentityUtils->isNamed( $user ) ) {
685 return '';
686 }
687 $userGroups = $this->userGroupManager->getUserGroups( $user );
688 $userGroupLinks = [];
689 foreach ( $userGroups as $group ) {
690 $userGroupLinks[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
691 }
692 return Html::rawElement( 'div', [
693 'class' => 'mw-diff-userroles',
694 ], $this->getLanguage()->commaList( $userGroupLinks ) );
695 }
696
703 private function getUserMetaData( ?UserIdentity $user ) {
704 if ( !$user ) {
705 return '';
706 }
707 return Html::rawElement( 'div', [
708 'class' => 'mw-diff-usermetadata',
709 ], $this->getUserRoles( $user ) . $this->getUserEditCount( $user ) );
710 }
711
723 public function isUserAllowedToSeeRevisions( Authority $performer ) {
724 $this->loadRevisionData();
725
726 if ( $this->mOldRevisionRecord && !$this->mOldRevisionRecord->userCan(
727 RevisionRecord::DELETED_TEXT,
728 $performer
729 ) ) {
730 return false;
731 }
732
733 // $this->mNewRev will only be falsy if a loading error occurred
734 // (in which case the user is allowed to see).
735 return !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
736 RevisionRecord::DELETED_TEXT,
737 $performer
738 );
739 }
740
748 public function shouldBeHiddenFromUser( Authority $performer ) {
749 return $this->hasDeletedRevision() && ( !$this->unhide ||
750 !$this->isUserAllowedToSeeRevisions( $performer ) );
751 }
752
756 public function showDiffPage( $diffOnly = false ) {
757 # Allow frames except in certain special cases
758 $out = $this->getOutput();
759 $out->getMetadata()->setPreventClickjacking( false );
760 $out->setRobotPolicy( 'noindex,nofollow' );
761
762 // Allow extensions to add any extra output here
763 $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
764
765 if ( !$this->loadRevisionData() ) {
766 if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
767 $this->showMissingRevision();
768 }
769 return;
770 }
771
772 $user = $this->getUser();
773 $permStatus = $this->authorizeView( $this->getAuthority() );
774 if ( !$permStatus->isGood() ) {
775 throw new PermissionsError( 'read', $permStatus );
776 }
777
778 $rollback = '';
779
780 $query = $this->extraQueryParams;
781 # Carry over 'diffonly' param via navigation links
782 if ( $diffOnly != MediaWikiServices::getInstance()
783 ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
784 ) {
785 $query['diffonly'] = $diffOnly;
786 }
787 # Cascade unhide param in links for easy deletion browsing
788 if ( $this->unhide ) {
789 $query['unhide'] = 1;
790 }
791
792 # Check if one of the revisions is deleted/suppressed
793 $deleted = $this->hasDeletedRevision();
794 $suppressed = $this->hasSuppressedRevision();
795 $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
796
797 $revisionTools = [];
798 $breadCrumbs = '';
799
800 # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
801 # a diff between a version V and its previous version V' AND the version V
802 # is the first version of that article. In that case, V' does not exist.
803 if ( $this->mOldRevisionRecord === false ) {
804 if ( $this->mNewPage ) {
805 $out->setPageTitleMsg(
806 $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
807 );
808 }
809 $samePage = true;
810 $oldHeader = '';
811 // Allow extensions to change the $oldHeader variable
812 $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
813 } else {
814 $this->hookRunner->onDifferenceEngineViewHeader( $this );
815
816 if ( !$this->mOldPage || !$this->mNewPage ) {
817 // XXX say something to the user?
818 $samePage = false;
819 } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
820 $out->setPageTitleMsg(
821 $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
822 );
823 $samePage = true;
824 } else {
825 $out->setPageTitleMsg( $this->msg( 'difference-title-multipage' )->plaintextParams(
826 $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
827 $out->addSubtitle( $this->msg( 'difference-multipage' ) );
828 $samePage = false;
829 }
830
831 if ( $samePage && $this->mNewPage &&
832 $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
833 ) {
834 if ( $this->mNewRevisionRecord->isCurrent() &&
835 $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
836 ) {
837 $rollbackLink = Linker::generateRollback(
838 $this->mNewRevisionRecord,
839 $this->getContext(),
840 [ 'noBrackets' ]
841 );
842 if ( $rollbackLink ) {
843 $out->getMetadata()->setPreventClickjacking( true );
844 $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
845 }
846 }
847
848 if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
849 $this->userCanEdit( $this->mNewRevisionRecord )
850 ) {
851 $undoLink = $this->linkRenderer->makeKnownLink(
852 $this->mNewPage,
853 $this->msg( 'editundo' )->text(),
854 [ 'title' => Linker::titleAttrib( 'undo' ) ],
855 [
856 'action' => 'edit',
857 'undoafter' => $this->mOldid,
858 'undo' => $this->mNewid
859 ]
860 );
861 $revisionTools['mw-diff-undo'] = $undoLink;
862 }
863 }
864 # Make "previous revision link"
865 $hasPrevious = $samePage && $this->mOldPage &&
866 $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
867 if ( $hasPrevious ) {
868 $prevlinkQuery = [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query;
869 $prevlink = $this->linkRenderer->makeKnownLink(
870 $this->mOldPage,
871 $this->msg( 'previousdiff' )->text(),
872 [ 'id' => 'differences-prevlink' ],
873 $prevlinkQuery
874 );
875 $breadCrumbs .= $this->linkRenderer->makeKnownLink(
876 $this->mOldPage,
877 $this->msg( 'previousdiff' )->text(),
878 [
879 'class' => 'mw-diff-revision-history-link-previous'
880 ],
881 $prevlinkQuery
882 );
883 } else {
884 $prevlink = "\u{00A0}";
885 }
886
887 if ( $this->mOldRevisionRecord->isMinor() ) {
888 $oldminor = ChangesList::flag( 'minor', $this->getContext() );
889 } else {
890 $oldminor = '';
891 }
892
893 $oldRevRecord = $this->mOldRevisionRecord;
894
895 $ldel = $this->revisionDeleteLink( $oldRevRecord );
896 $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
897 $oldChangeTags = $this->changeTagsFormatter->formatTagsAsSummaryList(
898 $this->mOldTags,
899 $this->getContext(),
900 $this->getAuthority()
901 );
902 $oldRevComment = $this->commentFormatter
903 ->formatRevision(
904 $oldRevRecord, $user, !$diffOnly, !$this->unhide, false
905 );
906
907 if ( $oldRevComment === '' ) {
908 $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
909 $oldRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
910 }
911
912 $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
913 '<div id="mw-diff-otitle2">' .
914 Linker::revUserTools( $oldRevRecord, !$this->unhide ) .
915 $this->getUserMetaData( $oldRevRecord->getUser() ) .
916 '</div>' .
917 '<div id="mw-diff-otitle3">' . $oldminor . $oldRevComment . $ldel . '</div>' .
918 '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
919 '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
920
921 // Allow extensions to change the $oldHeader variable
922 $this->hookRunner->onDifferenceEngineOldHeader(
923 $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
924 }
925
926 $out->addJsConfigVars( [
927 'wgDiffOldId' => $this->mOldid,
928 'wgDiffNewId' => $this->mNewid,
929 ] );
930
931 # Make "next revision link"
932 # Skip next link on the top revision
933 if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
934 $nextlinkQuery = [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query;
935 $nextlink = $this->linkRenderer->makeKnownLink(
936 $this->mNewPage,
937 $this->msg( 'nextdiff' )->text(),
938 [ 'id' => 'differences-nextlink' ],
939 $nextlinkQuery
940 );
941 $breadCrumbs .= $this->linkRenderer->makeKnownLink(
942 $this->mNewPage,
943 $this->msg( 'nextdiff' )->text(),
944 [
945 'class' => 'mw-diff-revision-history-link-next'
946 ],
947 $nextlinkQuery
948 );
949 } else {
950 $nextlink = "\u{00A0}";
951 }
952
953 if ( $this->mNewRevisionRecord->isMinor() ) {
954 $newminor = ChangesList::flag( 'minor', $this->getContext() );
955 } else {
956 $newminor = '';
957 }
958
959 # Handle RevisionDelete links...
960 $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
961
962 # Allow extensions to define their own revision tools
963 $this->hookRunner->onDiffTools(
964 $this->mNewRevisionRecord,
965 $revisionTools,
966 $this->mOldRevisionRecord ?: null,
967 $user
968 );
969
970 $formattedRevisionTools = [];
971 // Put each one in parentheses (poor man's button)
972 foreach ( $revisionTools as $key => $tool ) {
973 $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
974 $element = Html::rawElement(
975 'span',
976 [ 'class' => $toolClass ],
977 $tool
978 );
979 $formattedRevisionTools[] = $element;
980 }
981
982 $newRevRecord = $this->mNewRevisionRecord;
983
984 $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
985 ' ' . implode( ' ', $formattedRevisionTools );
986 $newChangeTags = $this->changeTagsFormatter->formatTagsAsSummaryList(
987 $this->mNewTags,
988 $this->getContext(),
989 $this->getAuthority()
990 );
991 $newRevComment = $this->commentFormatter->formatRevision(
992 $newRevRecord, $user, !$diffOnly, !$this->unhide, false
993 );
994
995 if ( $newRevComment === '' ) {
996 $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
997 $newRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
998 }
999
1000 $newMobileFooter = $this->getMobileFooter( $newRevRecord, $formattedRevisionTools );
1001
1002 $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
1003 '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
1004 $rollback .
1005 $this->getUserMetaData( $newRevRecord->getUser() ) .
1006 '</div>' .
1007 '<div id="mw-diff-ntitle3">' . $newminor . $newRevComment . $rdel . '</div>' .
1008 '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
1009 '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
1010
1011 // Allow extensions to change the $newHeader variable
1012 $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
1013 $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
1014 $rdel, $this->unhide );
1015
1016 $out->addHTML(
1017 Html::rawElement( 'div', [
1018 'class' => 'mw-diff-revision-history-links'
1019 ], $breadCrumbs )
1020 );
1021
1022 $out->addHTML(
1023 Html::rawElement( 'div', [
1024 'class' => 'mw-diff-mobile-footer'
1025 ], $newMobileFooter )
1026 );
1027 $addMessageBoxStyles = false;
1028 # If the diff cannot be shown due to a deleted revision, then output
1029 # the diff header and links to unhide (if available)...
1030 if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
1031 $this->showDiffStyle();
1032 $multi = $this->getMultiNotice();
1033 $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
1034 if ( !$allowed ) {
1035 # Give explanation for why revision is not visible
1036 $msg = [ $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff' ];
1037 } else {
1038 # Give explanation and add a link to view the diff...
1039 $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
1040 $msg = [
1041 $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff',
1042 $this->getTitle()->getFullURL( $query )
1043 ];
1044 }
1045 $out->addHTML( Html::warningBox( $this->msg( ...$msg )->parse(), 'plainlinks' ) );
1046 $addMessageBoxStyles = true;
1047 # Otherwise, output a regular diff...
1048 } else {
1049 # Add deletion notice if the user is viewing deleted content
1050 $notice = '';
1051 if ( $deleted ) {
1052 $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
1053 $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' );
1054 $addMessageBoxStyles = true;
1055 }
1056
1057 # Add an error if the content can't be loaded
1058 $this->getSlotContents();
1059 foreach ( $this->getRevisionLoadErrors() as $msg ) {
1060 $notice .= Html::warningBox( $msg->parse() );
1061 $addMessageBoxStyles = true;
1062 }
1063
1064 // Check if inline switcher will be needed
1065 if ( $this->getTextDiffer()->hasFormat( 'inline' ) ) {
1066 $out->enableOOUI();
1067 }
1068
1069 $this->showTablePrefixes();
1070 $this->showDiff( $oldHeader, $newHeader, $notice );
1071 if ( !$diffOnly ) {
1072 $this->renderNewRevision();
1073 }
1074
1075 // Allow extensions to optionally not show the final patrolled link
1076 if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1077 # Add redundant patrol link on bottom...
1078 $out->addHTML( $this->markPatrolledLink() );
1079 }
1080 }
1081 if ( $addMessageBoxStyles ) {
1082 $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1083 }
1084 }
1085
1089 private function showTablePrefixes() {
1090 $parts = [];
1091 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1092 $parts += $slotDiffRenderer->getTablePrefix( $this->getContext(), $this->mNewPage );
1093 }
1094 ksort( $parts );
1095 $nonEmptyParts = array_values( array_filter( $parts ) );
1096 if ( $nonEmptyParts ) {
1097 $language = $this->getLanguage();
1098 $attrs = [
1099 'class' => 'mw-diff-table-prefix',
1100 'dir' => $language->getDir(),
1101 'lang' => $language->getCode(),
1102 ];
1103 $this->getOutput()->addHTML(
1104 Html::rawElement( 'div', $attrs, implode( '', $nonEmptyParts ) ) );
1105 }
1106 }
1107
1119 public function markPatrolledLink() {
1120 if ( $this->mMarkPatrolledLink === null ) {
1121 $linkInfo = $this->getMarkPatrolledLinkInfo();
1122 // If false, there is no patrol link needed/allowed
1123 if ( !$linkInfo || !$this->mNewPage ) {
1124 $this->mMarkPatrolledLink = '';
1125 } else {
1126 $patrolLinkClass = 'patrollink';
1127 $this->mMarkPatrolledLink = ' <span class="' . $patrolLinkClass . '"' .
1128 ' data-mw-interface>[' .
1129 $this->linkRenderer->makeKnownLink(
1130 $this->mNewPage,
1131 $this->msg( 'markaspatrolleddiff' )->text(),
1132 [],
1133 [
1134 'action' => 'markpatrolled',
1135 'rcid' => $linkInfo['rcid'],
1136 ]
1137 ) . ']</span>';
1138 // Allow extensions to change the markpatrolled link
1139 $this->hookRunner->onDifferenceEngineMarkPatrolledLink( $this,
1140 $this->mMarkPatrolledLink, $linkInfo['rcid'] );
1141 }
1142 }
1143 return $this->mMarkPatrolledLink;
1144 }
1145
1153 protected function getMarkPatrolledLinkInfo() {
1154 $user = $this->getUser();
1155 $config = $this->getConfig();
1156
1157 // Prepare a change patrol link, if applicable
1158 if (
1159 // Is patrolling enabled and the user allowed to?
1160 $config->get( MainConfigNames::UseRCPatrol ) &&
1161 $this->mNewPage &&
1162 $this->getAuthority()->probablyCan( 'patrol', $this->mNewPage ) &&
1163 // Only do this if the revision isn't more than 6 hours older
1164 // than the Max RC age (6h because the RC might not be cleaned out regularly)
1165 RecentChange::isInRCLifespan( $this->mNewRevisionRecord->getTimestamp(), 21600 )
1166 ) {
1167 // Look for an unpatrolled change corresponding to this diff
1168 $change = $this->recentChangeLookup->getRecentChangeByConds(
1169 [
1170 'rc_this_oldid' => $this->mNewid,
1171 'rc_patrolled' => RecentChange::PRC_UNPATROLLED
1172 ],
1173 __METHOD__
1174 );
1175
1176 if ( $change && !$change->getPerformerIdentity()->equals( $user ) ) {
1177 $rcid = $change->getAttribute( 'rc_id' );
1178 } else {
1179 // None found or the page has been created by the current user.
1180 // If the user could patrol this it already would be patrolled
1181 $rcid = 0;
1182 }
1183
1184 // Allow extensions to possibly change the rcid here
1185 // For example the rcid might be set to zero due to the user
1186 // being the same as the performer of the change but an extension
1187 // might still want to show it under certain conditions
1188 $this->hookRunner->onDifferenceEngineMarkPatrolledRCID( $rcid, $this, $change, $user );
1189
1190 // Build the link
1191 if ( $rcid ) {
1192 $this->getOutput()->getMetadata()->setPreventClickjacking( true );
1193 $this->getOutput()->addModules( 'mediawiki.misc-authed-curate' );
1194
1195 return [ 'rcid' => $rcid ];
1196 }
1197 }
1198
1199 // No mark as patrolled link applicable
1200 return false;
1201 }
1202
1208 private function revisionDeleteLink( RevisionRecord $revRecord ) {
1209 $link = Linker::getRevDeleteLink(
1210 $this->getAuthority(),
1211 $revRecord,
1212 $revRecord->getPageAsLinkTarget()
1213 );
1214 if ( $link !== '' ) {
1215 $link = "\u{00A0}\u{00A0}\u{00A0}" . $link . ' ';
1216 }
1217
1218 return $link;
1219 }
1220
1226 public function renderNewRevision() {
1227 if ( $this->isContentOverridden ) {
1228 // The code below only works with a RevisionRecord object. We could construct a
1229 // fake RevisionRecord (here or in setContent), but since this does not seem
1230 // needed at the moment, we'll just fail for now.
1231 throw new LogicException(
1232 __METHOD__
1233 . ' is not supported after calling setContent(). Use setRevisions() instead.'
1234 );
1235 }
1236
1237 $out = $this->getOutput();
1238 $revHeader = $this->getRevisionHeader( $this->mNewRevisionRecord );
1239 # Add "current version as of X" title
1240 $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
1241 <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
1242 # Page content may be handled by a hooked call instead...
1243 if ( $this->hookRunner->onArticleContentOnDiff( $this, $out ) ) {
1244 $this->loadNewText();
1245 if ( !$this->mNewPage ) {
1246 // New revision is unsaved; bail out.
1247 // TODO in theory rendering the new revision is a meaningful thing to do
1248 // even if it's unsaved, but a lot of untangling is required to do it safely.
1249 return;
1250 }
1251 if ( $this->hasNewRevisionLoadError() ) {
1252 // There was an error loading the new revision
1253 return;
1254 }
1255
1256 $out->setRevisionId( $this->mNewid );
1257 $out->setRevisionIsCurrent( $this->mNewRevisionRecord->isCurrent() );
1258 $out->getMetadata()->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() );
1259 $out->setArticleFlag( true );
1260
1261 if ( !$this->hookRunner->onArticleRevisionViewCustom(
1262 $this->mNewRevisionRecord, $this->mNewPage, $this->mOldid, $out )
1263 ) {
1264 // Handled by extension
1265 // NOTE: sync with hooks called in Article::view()
1266 } else {
1267 // Normal page
1268 if ( $this->getTitle()->equals( $this->mNewPage ) ) {
1269 // If the Title stored in the context is the same as the one
1270 // of the new revision, we can use its associated WikiPage
1271 // object.
1272 $wikiPage = $this->getWikiPage();
1273 } else {
1274 // Otherwise we need to create our own WikiPage object
1275 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->mNewPage );
1276 }
1277
1278 # Use appropriate parser options for the Article.
1279 $article = Article::newFromWikiPage( $wikiPage, $this->getContext() );
1280 $parserOptions = $article->getParserOptions();
1281 // Override the render reason.
1282 $parserOptions->setRenderReason( 'diff-page' );
1283
1284 $errors = [];
1285 $parserOutput = $article->getPage()->getParserOutput(
1286 $parserOptions, $this->mNewRevisionRecord,
1287 options: [
1288 // we already checked
1289 ParserOutputAccess::OPT_NO_AUDIENCE_CHECK => true,
1290 // Update cascading protection
1291 ParserOutputAccess::OPT_LINKS_UPDATE => true,
1292 ],
1293 errors: $errors
1294 );
1295
1296 if ( $parserOutput !== false ) {
1297 // Allow extensions to change parser output here
1298 if ( $this->hookRunner->onDifferenceEngineRenderRevisionAddParserOutput(
1299 $this, $out, $parserOutput, $wikiPage )
1300 ) {
1301 $editLinks = $this->mNewRevisionRecord->isCurrent()
1302 && $this->getAuthority()->probablyCan(
1303 'edit',
1304 $this->mNewRevisionRecord->getPage() );
1305 if ( !$editLinks ) {
1306 $parserOptions->setSuppressSectionEditLinks();
1307 }
1308 $out->addParserOutput( $parserOutput, $parserOptions, [
1309 'absoluteURLs' => $this->slotDiffOptions['expand-url'] ?? false
1310 ] );
1311 }
1312 } else {
1313 $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1314 // @phan-suppress-next-line PhanEmptyForeach $errors written by-reference
1315 foreach ( $errors as $msg ) {
1316 $out->addHTML( Html::errorBox(
1317 $this->msg( $msg )->parse()
1318 ) );
1319 }
1320 }
1321 }
1322 }
1323 }
1324
1335 public function showDiff( $otitle, $ntitle, $notice = '' ) {
1336 // Allow extensions to affect the output here
1337 $this->hookRunner->onDifferenceEngineShowDiff( $this );
1338
1339 $diff = $this->getDiff( $otitle, $ntitle, $notice );
1340 if ( $diff === false ) {
1341 $this->showMissingRevision();
1342 return false;
1343 }
1344
1345 $this->showDiffStyle();
1346 if ( $this->slotDiffOptions['expand-url'] ?? false ) {
1347 $diff = Linker::expandLocalLinks( $diff );
1348 }
1349 $this->getOutput()->addHTML( $diff );
1350 return true;
1351 }
1352
1356 public function showDiffStyle() {
1357 if ( !$this->isSlotDiffRenderer ) {
1358 $this->getOutput()->addModules( 'mediawiki.diff' );
1359 $this->getOutput()->addModuleStyles( [
1360 'mediawiki.interface.helpers.styles',
1361 'mediawiki.diff.styles'
1362 ] );
1363 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1364 $slotDiffRenderer->addModules( $this->getOutput() );
1365 }
1366 }
1367 }
1368
1378 public function getDiff( $otitle, $ntitle, $notice = '' ) {
1379 $body = $this->getDiffBody();
1380 if ( $body === false ) {
1381 return false;
1382 }
1383
1384 $multi = $this->getMultiNotice();
1385 // Display a message when the diff is empty
1386 if ( $body === '' ) {
1387 $notice .= '<div class="mw-diff-empty">' .
1388 $this->msg( 'diff-empty' )->parse() .
1389 "</div>\n";
1390 }
1391
1392 if ( $this->cacheHitKey !== null ) {
1393 $body .= "\n<!-- diff cache key " . htmlspecialchars( $this->cacheHitKey ) . " -->\n";
1394 }
1395
1396 return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
1397 }
1398
1399 private function incrementStats( string $cacheStatus ): void {
1400 $stats = MediaWikiServices::getInstance()->getStatsFactory();
1401 $stats->getCounter( 'diff_cache_total' )
1402 ->setLabel( 'status', $cacheStatus )
1403 ->increment();
1404 }
1405
1411 public function getDiffBody() {
1412 $this->mCacheHit = true;
1413 // Check if the diff should be hidden from this user
1414 if ( !$this->isContentOverridden ) {
1415 if ( !$this->loadRevisionData() ) {
1416 return false;
1417 } elseif ( $this->mOldRevisionRecord &&
1418 !$this->mOldRevisionRecord->userCan(
1419 RevisionRecord::DELETED_TEXT,
1420 $this->getAuthority()
1421 )
1422 ) {
1423 return false;
1424 } elseif ( $this->mNewRevisionRecord &&
1425 !$this->mNewRevisionRecord->userCan(
1426 RevisionRecord::DELETED_TEXT,
1427 $this->getAuthority()
1428 ) ) {
1429 return false;
1430 }
1431 // Short-circuit
1432 if ( $this->mOldRevisionRecord === false || (
1433 $this->mOldRevisionRecord &&
1434 $this->mNewRevisionRecord &&
1435 $this->mOldRevisionRecord->getId() &&
1436 $this->mOldRevisionRecord->getId() == $this->mNewRevisionRecord->getId()
1437 ) ) {
1438 if ( $this->hookRunner->onDifferenceEngineShowEmptyOldContent( $this ) ) {
1439 return '';
1440 }
1441 }
1442 }
1443
1444 // Cacheable?
1445 $key = false;
1446 $services = MediaWikiServices::getInstance();
1447 $cache = $services->getMainWANObjectCache();
1448 $stats = $services->getStatsdDataFactory();
1449 if ( $this->mOldid && $this->mNewid ) {
1450 $key = $cache->makeKey( ...$this->getDiffBodyCacheKeyParams() );
1451
1452 // Try cache
1453 if ( !$this->mRefreshCache ) {
1454 $difftext = $cache->get( $key );
1455 if ( is_string( $difftext ) ) {
1456 $this->incrementStats( 'hit' );
1457 $difftext = $this->localiseDiff( $difftext );
1458 $this->cacheHitKey = $key;
1459 return $difftext;
1460 }
1461 } // don't try to load but save the result
1462 }
1463 $this->mCacheHit = false;
1464 $this->cacheHitKey = null;
1465
1466 // Loadtext is permission safe, this just clears out the diff
1467 if ( !$this->loadText() ) {
1468 return false;
1469 }
1470
1471 $difftext = '';
1472 // We've checked for revdelete at the beginning of this method; it's OK to ignore
1473 // read permissions here.
1474 $slotContents = $this->getSlotContents();
1475 foreach ( $this->getSlotDiffRenderers() as $role => $slotDiffRenderer ) {
1476 try {
1477 $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'],
1478 $slotContents[$role]['new'] );
1479 } catch ( IncompatibleDiffTypesException $e ) {
1480 $slotDiff = $this->getSlotError( $e->getMessageObject()->parse() );
1481 }
1482 if ( $slotDiff && $role !== SlotRecord::MAIN ) {
1483 // FIXME: ask SlotRoleHandler::getSlotNameMessage
1484 $slotTitle = $role;
1485 $difftext .= $this->getSlotHeader( $slotTitle );
1486 }
1487 $difftext .= $slotDiff;
1488 }
1489
1490 // Save to cache for 7 days
1491 if ( !$this->hookRunner->onAbortDiffCache( $this ) ) {
1492 $this->incrementStats( 'uncacheable' );
1493 } elseif ( $key !== false ) {
1494 $this->incrementStats( 'miss' );
1495 $cache->set( $key, $difftext, 7 * 86400 );
1496 } else {
1497 $this->incrementStats( 'uncacheable' );
1498 }
1499 // localise line numbers and title attribute text
1500 $difftext = $this->localiseDiff( $difftext );
1501
1502 return $difftext;
1503 }
1504
1511 public function getDiffBodyForRole( $role ) {
1512 $diffRenderers = $this->getSlotDiffRenderers();
1513 if ( !isset( $diffRenderers[$role] ) ) {
1514 return false;
1515 }
1516
1517 $slotContents = $this->getSlotContents();
1518 try {
1519 $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'],
1520 $slotContents[$role]['new'] );
1521 } catch ( IncompatibleDiffTypesException $e ) {
1522 $slotDiff = $this->getSlotError( $e->getMessageObject()->parse() );
1523 }
1524 if ( $slotDiff === '' ) {
1525 return false;
1526 }
1527
1528 if ( $role !== SlotRecord::MAIN ) {
1529 // TODO use human-readable role name at least
1530 $slotTitle = $role;
1531 $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff;
1532 }
1533
1534 return $this->localiseDiff( $slotDiff );
1535 }
1536
1543 protected function getSlotHeader( $headerText ) {
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-header', 'lang' => $userLang ],
1548 Html::element( 'th', [ 'colspan' => $columnCount ], $headerText ) );
1549 }
1550
1557 protected function getSlotError( $errorText ) {
1558 // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1559 $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1560 $userLang = $this->getLanguage()->getHtmlCode();
1561 return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-error', 'lang' => $userLang ],
1562 Html::rawElement( 'td', [ 'colspan' => $columnCount ], $errorText ) );
1563 }
1564
1578 protected function getDiffBodyCacheKeyParams() {
1579 if ( !$this->mOldid || !$this->mNewid ) {
1580 throw new BadMethodCallException( 'mOldid and mNewid must be set to get diff cache key.' );
1581 }
1582
1583 $params = [
1584 'diff',
1585 self::DIFF_VERSION,
1586 "old-{$this->mOldid}",
1587 "rev-{$this->mNewid}"
1588 ];
1589
1590 $extraKeys = [];
1591 if ( !$this->isSlotDiffRenderer ) {
1592 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1593 $extraKeys = array_merge( $extraKeys, $slotDiffRenderer->getExtraCacheKeys() );
1594 }
1595 }
1596 ksort( $extraKeys );
1597 return array_merge( $params, array_values( $extraKeys ) );
1598 }
1599
1607 public function getExtraCacheKeys() {
1608 // This method is called when the DifferenceEngine is used for a slot diff. We only care
1609 // about special things, not the revision IDs, which are added to the cache key by the
1610 // page-level DifferenceEngine, and which might not have a valid value for this object.
1611 $this->mOldid = 123456789;
1612 $this->mNewid = 987654321;
1613
1614 // This will repeat a bunch of unnecessary key fields for each slot. Not nice but harmless.
1615 $params = $this->getDiffBodyCacheKeyParams();
1616
1617 // Try to get rid of the standard keys to keep the cache key human-readable:
1618 // call the getDiffBodyCacheKeyParams implementation of the base class, and if
1619 // the child class includes the same keys, drop them.
1620 // Uses an obscure PHP feature where static calls to non-static methods are allowed
1621 // as long as we are already in a non-static method of the same class, and the call context
1622 // ($this) will be inherited.
1623 // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
1624 $standardParams = DifferenceEngine::getDiffBodyCacheKeyParams();
1625 if ( array_slice( $params, 0, count( $standardParams ) ) === $standardParams ) {
1626 $params = array_slice( $params, count( $standardParams ) );
1627 }
1628
1629 return $params;
1630 }
1631
1642 public function setSlotDiffOptions( $options ) {
1643 $validatedOptions = [];
1644 if ( isset( $options['diff-type'] )
1645 && $this->getTextDiffer()->hasFormat( $options['diff-type'] )
1646 ) {
1647 $validatedOptions['diff-type'] = $options['diff-type'];
1648 }
1649 if ( !empty( $options['expand-url'] ) ) {
1650 $validatedOptions['expand-url'] = true;
1651 }
1652 if ( !empty( $options['inline-toggle'] ) ) {
1653 $validatedOptions['inline-toggle'] = true;
1654 }
1655 $this->slotDiffOptions = $validatedOptions;
1656 }
1657
1665 public function setExtraQueryParams( $params ) {
1666 $this->extraQueryParams = $params;
1667 }
1668
1682 public function generateContentDiffBody( Content $old, Content $new ) {
1683 $slotDiffRenderer = $new->getContentHandler()->getSlotDiffRenderer( $this->getContext() );
1684 if (
1685 $slotDiffRenderer instanceof DifferenceEngineSlotDiffRenderer
1686 && $this->isSlotDiffRenderer
1687 ) {
1688 // Oops, we are just about to enter an infinite loop (the slot-level DifferenceEngine
1689 // called a DifferenceEngineSlotDiffRenderer that wraps the same DifferenceEngine class).
1690 // This will happen when a content model has no custom slot diff renderer, it does have
1691 // a custom difference engine, but that does not override this method.
1692 throw new LogicException( get_class( $this ) . ': could not maintain backwards compatibility. '
1693 . 'Please use a SlotDiffRenderer.' );
1694 }
1695 return $slotDiffRenderer->getDiff( $old, $new ) . $this->getDebugString();
1696 }
1697
1710 public function generateTextDiffBody( $otext, $ntext ) {
1711 $slotDiffRenderer = $this->contentHandlerFactory
1712 ->getContentHandler( CONTENT_MODEL_TEXT )
1713 ->getSlotDiffRenderer( $this->getContext() );
1714 if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1715 // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1716 // This is too unlikely to happen to bother handling properly.
1717 throw new LogicException( 'The slot diff renderer for text content should be a '
1718 . 'TextSlotDiffRenderer subclass' );
1719 }
1720 return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1721 }
1722
1729 public static function getEngine() {
1730 $differenceEngine = new self;
1731 $engine = $differenceEngine->getTextDiffer()->getEngineForFormat( 'table' );
1732 if ( $engine === 'external' ) {
1733 return MediaWikiServices::getInstance()->getMainConfig()
1734 ->get( MainConfigNames::ExternalDiffEngine );
1735 } else {
1736 return $engine;
1737 }
1738 }
1739
1748 protected function debug( $generator = "internal" ) {
1749 if ( !$this->enableDebugComment ) {
1750 return '';
1751 }
1752 $data = [ $generator ];
1753 if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) ) {
1754 $data[] = wfHostname();
1755 }
1756 $data[] = ConvertibleTimestamp::now( TS::DB );
1757
1758 return "<!-- diff generator: " .
1759 implode( " ", array_map( "htmlspecialchars", $data ) ) .
1760 " -->\n";
1761 }
1762
1766 private function getDebugString() {
1767 $engine = self::getEngine();
1768 if ( $engine === 'wikidiff2' ) {
1769 return $this->debug( 'wikidiff2' );
1770 } elseif ( $engine === 'php' ) {
1771 return $this->debug( 'native PHP' );
1772 } else {
1773 return $this->debug( "external $engine" );
1774 }
1775 }
1776
1783 private function localiseDiff( $text ) {
1784 return $this->getTextDiffer()->localize( $this->getTextDiffFormat(), $text );
1785 }
1786
1795 public function localiseLineNumbers( $text ) {
1796 return preg_replace_callback( '/<!--LINE (\d+)-->/',
1797 function ( array $matches ) {
1798 if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
1799 return '';
1800 }
1801 return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
1802 }, $text );
1803 }
1804
1810 public function getMultiNotice() {
1811 // The notice only make sense if we are diffing two saved revisions of the same page.
1812 if (
1813 !$this->mOldRevisionRecord || !$this->mNewRevisionRecord
1814 || !$this->mOldPage || !$this->mNewPage
1815 || !$this->mOldPage->equals( $this->mNewPage )
1816 || $this->mOldRevisionRecord->getId() === null
1817 || $this->mNewRevisionRecord->getId() === null
1818 // (T237709) Deleted revs might have different page IDs
1819 || $this->mNewPage->getArticleID() !== $this->mOldRevisionRecord->getPageId()
1820 || $this->mNewPage->getArticleID() !== $this->mNewRevisionRecord->getPageId()
1821 ) {
1822 return '';
1823 }
1824
1825 if ( $this->mOldRevisionRecord->getTimestamp() > $this->mNewRevisionRecord->getTimestamp() ) {
1826 $oldRevRecord = $this->mNewRevisionRecord; // flip
1827 $newRevRecord = $this->mOldRevisionRecord; // flip
1828 } else { // normal case
1829 $oldRevRecord = $this->mOldRevisionRecord;
1830 $newRevRecord = $this->mNewRevisionRecord;
1831 }
1832
1833 // Don't show the notice if too many rows must be scanned
1834 // @todo show some special message for that case
1835 $nEdits = 0;
1836 $revisionIdList = $this->revisionStore->getRevisionIdsBetween(
1837 $this->mNewPage->getArticleID(),
1838 $oldRevRecord,
1839 $newRevRecord,
1840 1000
1841 );
1842 // only count revisions that are visible
1843 if ( count( $revisionIdList ) > 0 ) {
1844 foreach ( $revisionIdList as $revisionId ) {
1845 $revision = $this->revisionStore->getRevisionById( $revisionId );
1846 if ( $revision->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ) ) {
1847 $nEdits++;
1848 }
1849 }
1850 }
1851 if ( $nEdits > 0 && $nEdits <= 1000 ) {
1852 // Use an invalid username to get the wiki's default gender (as fallback)
1853 $newRevUserForGender = '[HIDDEN]';
1854 $limit = 100; // use diff-multi-manyusers if too many users
1855 try {
1856 $users = $this->revisionStore->getAuthorsBetween(
1857 $this->mNewPage->getArticleID(),
1858 $oldRevRecord,
1859 $newRevRecord,
1860 null,
1861 $limit
1862 );
1863 $numUsers = count( $users );
1864
1865 $newRevUser = $newRevRecord->getUser( RevisionRecord::RAW );
1866 $newRevUserText = $newRevUser ? $newRevUser->getName() : '';
1867 $newRevUserSafe = $newRevRecord->getUser(
1868 RevisionRecord::FOR_THIS_USER,
1869 $this->getAuthority()
1870 );
1871 $newRevUserForGender = $newRevUserSafe ? $newRevUserSafe->getName() : '[HIDDEN]';
1872 if ( $numUsers == 1 && $users[0]->getName() == $newRevUserText ) {
1873 $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1874 }
1875 } catch ( InvalidArgumentException ) {
1876 $numUsers = 0;
1877 }
1878
1879 return self::intermediateEditsMsg( $nEdits, $numUsers, $limit, $newRevUserForGender );
1880 }
1881
1882 return '';
1883 }
1884
1895 public static function intermediateEditsMsg( $numEdits, $numUsers, $limit, $lastUser = '[HIDDEN]' ) {
1896 if ( $numUsers === 0 ) {
1897 $msg = 'diff-multi-sameuser';
1898 return wfMessage( $msg )
1899 ->numParams( $numEdits, $numUsers )
1900 ->params( $lastUser )
1901 ->parse();
1902 } elseif ( $numUsers > $limit ) {
1903 $msg = 'diff-multi-manyusers';
1904 $numUsers = $limit;
1905 } else {
1906 $msg = 'diff-multi-otherusers';
1907 }
1908
1909 return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1910 }
1911
1916 private function userCanEdit( RevisionRecord $revRecord ) {
1917 if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1918 return false;
1919 }
1920
1921 return true;
1922 }
1923
1933 public function getRevisionHeader( RevisionRecord $rev, $complete = '' ) {
1934 $lang = $this->getLanguage();
1935 $user = $this->getUser();
1936 $revtimestamp = $rev->getTimestamp();
1937 $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1938 $dateofrev = $lang->userDate( $revtimestamp, $user );
1939 $timeofrev = $lang->userTime( $revtimestamp, $user );
1940
1941 $header = $this->msg(
1942 $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1943 $timestamp,
1944 $dateofrev,
1945 $timeofrev
1946 );
1947
1948 if ( $complete !== 'complete' ) {
1949 return $header->escaped();
1950 }
1951
1952 $title = $rev->getPageAsLinkTarget();
1953
1954 if ( $this->userCanEdit( $rev ) ) {
1955 $header = $this->linkRenderer->makeKnownLink(
1956 $title,
1957 $header->text(),
1958 [],
1959 [ 'oldid' => $rev->getId() ]
1960 );
1961 $editQuery = [ 'action' => 'edit' ];
1962 if ( !$rev->isCurrent() ) {
1963 $editQuery['oldid'] = $rev->getId();
1964 }
1965
1966 $key = $this->getAuthority()->probablyCan( 'edit', $rev->getPage() ) ? 'editold' : 'viewsourceold';
1967 $msg = $this->msg( $key )->text();
1968 $editLink = $this->linkRenderer->makeKnownLink( $title, $msg, [], $editQuery );
1969 $header .= ' ' . Html::rawElement(
1970 'span',
1971 [ 'class' => 'mw-diff-edit' ],
1972 $editLink
1973 );
1974 } else {
1975 $header = $header->escaped();
1976 }
1977
1978 // Machine readable information
1979 $header .= Html::element( 'span',
1980 [
1981 'class' => 'mw-diff-timestamp',
1982 'data-timestamp' => wfTimestamp( TS::ISO_8601, $revtimestamp ),
1983 ], ''
1984 );
1985
1986 if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1987 return Html::rawElement(
1988 'span',
1989 [ 'class' => Linker::getRevisionDeletedClass( $rev ) ],
1990 $header
1991 );
1992 }
1993
1994 return $header;
1995 }
1996
2009 public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
2010 // shared.css sets diff in interface language/dir, but the actual content
2011 // is often in a different language, mostly the page content language/dir
2012 $header = Html::openElement( 'table', [
2013 'class' => [
2014 'diff',
2015 // The following classes are used here:
2016 // * diff-type-table
2017 // * diff-type-inline
2018 'diff-type-' . $this->getTextDiffFormat(),
2019 // The following classes are used here:
2020 // * diff-contentalign-left
2021 // * diff-contentalign-right
2022 'diff-contentalign-' . $this->getDiffLang()->alignStart(),
2023 // The following classes are used here:
2024 // * diff-editfont-monospace
2025 // * diff-editfont-sans-serif
2026 // * diff-editfont-serif
2027 'diff-editfont-' . $this->userOptionsLookup->getOption(
2028 $this->getUser(),
2029 'editfont'
2030 )
2031 ],
2032 'data-mw-interface' => '',
2033 ] );
2034 $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
2035
2036 if ( !$diff && !$otitle ) {
2037 $header .= "
2038 <tr class=\"diff-title\" lang=\"{$userLang}\">
2039 <td class=\"diff-ntitle\">{$ntitle}</td>
2040 </tr>";
2041 $multiColspan = 1;
2042 } else {
2043 if ( $diff ) { // Safari/Chrome show broken output if cols not used
2044 $header .= "
2045 <col class=\"diff-marker\" />
2046 <col class=\"diff-content\" />
2047 <col class=\"diff-marker\" />
2048 <col class=\"diff-content\" />";
2049 $colspan = 2;
2050 $multiColspan = 4;
2051 } else {
2052 $colspan = 1;
2053 $multiColspan = 2;
2054 }
2055 if ( $otitle || $ntitle ) {
2056 // FIXME Hardcoding values from TableDiffFormatter.
2057 $deletedClass = 'diff-side-deleted';
2058 $addedClass = 'diff-side-added';
2059 $header .= "
2060 <tr class=\"diff-title\" lang=\"{$userLang}\">
2061 <td colspan=\"$colspan\" class=\"diff-otitle {$deletedClass}\">{$otitle}</td>
2062 <td colspan=\"$colspan\" class=\"diff-ntitle {$addedClass}\">{$ntitle}</td>
2063 </tr>";
2064 }
2065 }
2066
2067 if ( $multi != '' ) {
2068 $header .= "<tr><td colspan=\"{$multiColspan}\" " .
2069 "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
2070 }
2071 if ( $notice != '' ) {
2072 $header .= "<tr><td colspan=\"{$multiColspan}\" " .
2073 "class=\"diff-notice\" lang=\"{$userLang}\">{$notice}</td></tr>";
2074 }
2075
2076 return $header . $diff . "</table>";
2077 }
2078
2086 public function setContent( Content $oldContent, Content $newContent ) {
2087 $this->mOldContent = $oldContent;
2088 $this->mNewContent = $newContent;
2089
2090 $this->mTextLoaded = 2;
2091 $this->mRevisionsLoaded = true;
2092 $this->isContentOverridden = true;
2093 $this->slotDiffRenderers = null;
2094 }
2095
2101 public function setRevisions(
2102 ?RevisionRecord $oldRevision, RevisionRecord $newRevision
2103 ) {
2104 if ( $oldRevision ) {
2105 $this->mOldRevisionRecord = $oldRevision;
2106 $this->mOldid = $oldRevision->getId();
2107 $this->mOldPage = Title::newFromPageIdentity( $oldRevision->getPage() );
2108 // This method is meant for edit diffs and such so there is no reason to provide a
2109 // revision that's not readable to the user, but check it just in case.
2110 $this->mOldContent = $oldRevision->getContent( SlotRecord::MAIN,
2111 RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
2112 if ( !$this->mOldContent ) {
2113 $this->addRevisionLoadError( 'old' );
2114 }
2115 } else {
2116 $this->mOldPage = null;
2117 $this->mOldRevisionRecord = $this->mOldid = false;
2118 }
2119 $this->mNewRevisionRecord = $newRevision;
2120 $this->mNewid = $newRevision->getId();
2121 $this->mNewPage = Title::newFromPageIdentity( $newRevision->getPage() );
2122 $this->mNewContent = $newRevision->getContent( SlotRecord::MAIN,
2123 RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
2124 if ( !$this->mNewContent ) {
2125 $this->addRevisionLoadError( 'new' );
2126 }
2127
2128 $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true;
2129 $this->mTextLoaded = $oldRevision ? 2 : 1;
2130 $this->isContentOverridden = false;
2131 $this->slotDiffRenderers = null;
2132 }
2133
2140 public function setTextLanguage( Language $lang ) {
2141 $this->mDiffLang = $lang;
2142 }
2143
2156 public function mapDiffPrevNext( $old, $new ) {
2157 if ( $new === 'prev' ) {
2158 // Show diff between revision $old and the previous one. Get previous one from DB.
2159 $newid = intval( $old );
2160 $oldid = false;
2161 $newRev = $this->revisionStore->getRevisionById( $newid );
2162 if ( $newRev ) {
2163 $oldRev = $this->revisionStore->getPreviousRevision( $newRev );
2164 if ( $oldRev ) {
2165 $oldid = $oldRev->getId();
2166 }
2167 }
2168 } elseif ( $new === 'next' ) {
2169 // Show diff between revision $old and the next one. Get next one from DB.
2170 $oldid = intval( $old );
2171 $newid = false;
2172 $oldRev = $this->revisionStore->getRevisionById( $oldid );
2173 if ( $oldRev ) {
2174 $newRev = $this->revisionStore->getNextRevision( $oldRev );
2175 if ( $newRev ) {
2176 $newid = $newRev->getId();
2177 }
2178 }
2179 } else {
2180 $oldid = intval( $old );
2181 $newid = intval( $new );
2182 }
2183
2184 // @phan-suppress-next-line PhanTypeMismatchReturn getId does not return null here
2185 return [ $oldid, $newid ];
2186 }
2187
2188 private function loadRevisionIds() {
2189 if ( $this->mRevisionsIdsLoaded ) {
2190 return;
2191 }
2192
2193 $this->mRevisionsIdsLoaded = true;
2194
2195 $old = $this->mOldid;
2196 $new = $this->mNewid;
2197
2198 [ $this->mOldid, $this->mNewid ] = self::mapDiffPrevNext( $old, $new );
2199 if ( $new === 'next' && $this->mNewid === false ) {
2200 # if no result, NewId points to the newest old revision. The only newer
2201 # revision is cur, which is "0".
2202 $this->mNewid = 0;
2203 }
2204
2205 $this->hookRunner->onNewDifferenceEngine(
2206 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
2207 $this->getTitle(), $this->mOldid, $this->mNewid, $old, $new );
2208 }
2209
2223 public function loadRevisionData() {
2224 if ( $this->mRevisionsLoaded ) {
2225 return $this->isContentOverridden ||
2226 ( $this->mOldRevisionRecord !== null && $this->mNewRevisionRecord !== null );
2227 }
2228
2229 // Whether it succeeds or fails, we don't want to try again
2230 $this->mRevisionsLoaded = true;
2231
2232 $this->loadRevisionIds();
2233
2234 // Load the new RevisionRecord object
2235 if ( $this->mNewid ) {
2236 $this->mNewRevisionRecord = $this->revisionStore->getRevisionById( $this->mNewid );
2237 } else {
2238 $this->mNewRevisionRecord = $this->revisionStore->getRevisionByTitle( $this->getTitle() );
2239 }
2240
2241 // Load the old RevisionRecord object
2242 $this->mOldRevisionRecord = false;
2243 if ( $this->mOldid ) {
2244 $this->mOldRevisionRecord = $this->revisionStore->getRevisionById( $this->mOldid );
2245 } elseif ( $this->mOldid === 0 && $this->mNewRevisionRecord instanceof RevisionRecord ) {
2246 $revRecord = $this->revisionStore->getPreviousRevision( $this->mNewRevisionRecord );
2247 // No previous revision; mark to show as first-version only.
2248 $this->mOldid = $revRecord ? $revRecord->getId() : false;
2249 $this->mOldRevisionRecord = $revRecord ?? false;
2250 } /* elseif ( $this->mOldid === false ) leave mOldRevisionRecord false; */
2251
2252 if ( $this->mOldRevisionRecord === null || $this->mNewRevisionRecord === null ) {
2253 return false;
2254 }
2255
2256 // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
2257 $this->mNewid = $this->mNewRevisionRecord->getId();
2258 $this->mNewPage = $this->mNewid ?
2259 Title::newFromPageIdentity( $this->mNewRevisionRecord->getPage() ) :
2260 null;
2261
2262 if ( $this->mOldRevisionRecord && $this->mOldRevisionRecord->getId() ) {
2263 $this->mOldPage = Title::newFromPageIdentity( $this->mOldRevisionRecord->getPage() );
2264 } else {
2265 $this->mOldPage = null;
2266 }
2267
2268 // Load tags information for both revisions
2269 $dbr = $this->dbProvider->getReplicaDatabase();
2270 $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
2271 if ( $this->mOldid !== false ) {
2272 $tagIds = $dbr->newSelectQueryBuilder()
2273 ->select( 'ct_tag_id' )
2274 ->from( 'change_tag' )
2275 ->where( [ 'ct_rev_id' => $this->mOldid ] )
2276 ->caller( __METHOD__ )->fetchFieldValues();
2277 $tags = [];
2278 foreach ( $tagIds as $tagId ) {
2279 try {
2280 $tags[] = $changeTagDefStore->getName( (int)$tagId );
2281 } catch ( NameTableAccessException ) {
2282 continue;
2283 }
2284 }
2285 $this->mOldTags = implode( ',', $tags );
2286 } else {
2287 $this->mOldTags = false;
2288 }
2289
2290 $tagIds = $dbr->newSelectQueryBuilder()
2291 ->select( 'ct_tag_id' )
2292 ->from( 'change_tag' )
2293 ->where( [ 'ct_rev_id' => $this->mNewid ] )
2294 ->caller( __METHOD__ )->fetchFieldValues();
2295 $tags = [];
2296 foreach ( $tagIds as $tagId ) {
2297 try {
2298 $tags[] = $changeTagDefStore->getName( (int)$tagId );
2299 } catch ( NameTableAccessException ) {
2300 continue;
2301 }
2302 }
2303 $this->mNewTags = implode( ',', $tags );
2304
2305 return true;
2306 }
2307
2315 public function loadText() {
2316 if ( $this->mTextLoaded == 2 ) {
2317 return $this->loadRevisionData() &&
2318 ( $this->mOldRevisionRecord === false || $this->mOldContent )
2319 && $this->mNewContent;
2320 }
2321
2322 // Whether it succeeds or fails, we don't want to try again
2323 $this->mTextLoaded = 2;
2324
2325 if ( !$this->loadRevisionData() ) {
2326 return false;
2327 }
2328
2329 if ( $this->mOldRevisionRecord ) {
2330 $this->mOldContent = $this->mOldRevisionRecord->getContent(
2331 SlotRecord::MAIN,
2332 RevisionRecord::FOR_THIS_USER,
2333 $this->getAuthority()
2334 );
2335 if ( $this->mOldContent === null ) {
2336 return false;
2337 }
2338 }
2339
2340 $this->mNewContent = $this->mNewRevisionRecord->getContent(
2341 SlotRecord::MAIN,
2342 RevisionRecord::FOR_THIS_USER,
2343 $this->getAuthority()
2344 );
2345 $this->hookRunner->onDifferenceEngineLoadTextAfterNewContentIsLoaded( $this );
2346 if ( $this->mNewContent === null ) {
2347 return false;
2348 }
2349
2350 return true;
2351 }
2352
2358 public function loadNewText() {
2359 if ( $this->mTextLoaded >= 1 ) {
2360 return $this->loadRevisionData();
2361 }
2362
2363 $this->mTextLoaded = 1;
2364
2365 if ( !$this->loadRevisionData() ) {
2366 return false;
2367 }
2368
2369 $this->mNewContent = $this->mNewRevisionRecord->getContent(
2370 SlotRecord::MAIN,
2371 RevisionRecord::FOR_THIS_USER,
2372 $this->getAuthority()
2373 );
2374
2375 $this->hookRunner->onDifferenceEngineAfterLoadNewText( $this );
2376
2377 return true;
2378 }
2379
2385 protected function getTextDiffer() {
2386 if ( $this->textDiffer === null ) {
2387 $this->textDiffer = new ManifoldTextDiffer(
2388 $this->getContext(),
2389 $this->getDiffLang(),
2390 $this->getConfig()->get( MainConfigNames::DiffEngine ),
2391 $this->getConfig()->get( MainConfigNames::ExternalDiffEngine ),
2392 $this->getConfig()->get( MainConfigNames::Wikidiff2Options )
2393 );
2394 }
2395 return $this->textDiffer;
2396 }
2397
2404 public function getSupportedFormats() {
2405 return $this->getTextDiffer()->getFormats();
2406 }
2407
2414 public function getTextDiffFormat() {
2415 return $this->slotDiffOptions['diff-type'] ?? 'table';
2416 }
2417
2423 private function getMobileFooter( ?RevisionRecord $newRevRecord, array $formattedRevisionTools ): string {
2424 $this->getOutput()->addModuleStyles( [ 'codex-styles' ] );
2425 $summary = Html::rawElement(
2426 'summary',
2427 [ "class" => "cdx-accordion--has-icon" ],
2428 Html::rawElement(
2429 'h3',
2430 [ "class" => "cdx-accordion__header" ],
2431 Html::rawElement(
2432 'span',
2433 [ 'class' => 'cdx-accordion__header__title' ],
2434 Linker::revUserTools( $newRevRecord, !$this->unhide )
2435 )
2436 )
2437 );
2438 $rollbackLink = '';
2439 if ( $this->mNewRevisionRecord->isCurrent() &&
2440 $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
2441 ) {
2442 $rollbackLink = Linker::generateRollback(
2443 $this->mNewRevisionRecord,
2444 $this->getContext(),
2445 [ 'noBrackets' ]
2446 );
2447 }
2448 $user = $newRevRecord->getUser();
2449 $userGroups = [];
2450 if ( $user !== null ) {
2451 $userGroups = $this->userGroupManager->getUserGroups( $user );
2452 }
2453 $userGroupCount = count( $userGroups );
2454 $userEditCount = $user === null ? '' : $this->getUserEditCount( $user );
2455 $userGroupList = [];
2456 foreach ( $userGroups as $userGroup ) {
2457 $userGroupList[] = $this->msg( "group-$userGroup" )->escaped();
2458 }
2459 if ( $userGroupCount == 0 ) {
2460 $userGroupsPopover = '';
2461 } else {
2462 $popover = Html::rawElement(
2463 'div',
2464 [ 'class' => 'cdx-popover mw-diff-usergroups-popover', 'role' => 'tooltip' ],
2465 Html::rawElement(
2466 'div',
2467 [ 'class' => 'cdx-popover__body' ],
2468 $this->msg( 'diff-usergroups-list', $this->getLanguage()->commaList( $userGroupList ) )->escaped()
2469 ) . Html::rawElement( 'div', [ 'class' => 'cdx-popover__arrow' ] )
2470 );
2471 $popoverTrigger = Html::element(
2472 'span',
2473 [ 'class' => 'cdx-popover-trigger cdx-button__icon cdx-icon cdx-icon--info ', 'tabindex' => '0' ],
2474 );
2475 $userGroupsPopover = Html::rawElement(
2476 'div',
2477 [ 'class' => 'mw-diff-usermetadata' ],
2478
2479 Html::element( 'span',
2480 [ 'class' => 'mw-diff-usergroups-popover-text' ],
2481 $this->msg( 'diff-usergroups', $userGroupCount )->text()
2482 ) .
2483 Html::rawElement(
2484 'div',
2485 [ 'class' => 'mw-diff-usergroups-popover-wrapper' ],
2486 $popoverTrigger . $popover
2487 )
2488 );
2489 }
2490
2491 $content = Html::rawElement(
2492 'div',
2493 [ "class" => "cdx-accordion__content" ],
2494 $userGroupsPopover
2495 . $userEditCount
2496 . $rollbackLink
2497 . implode( '', $formattedRevisionTools )
2498 );
2499 return Html::rawElement(
2500 'details',
2501 [ "class" => "mw-diff-new-mobile-footer-accordion cdx-accordion cdx-accordion--separation-minimal" ],
2502 $summary . $content
2503 );
2504 }
2505
2506}
2507
2509class_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.
Formats change tags for display in HTML and use filter dropdown menus.
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:48
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
static listParam(array $list, $type=ListType::AND)
Definition Message.php:1355
Legacy class representing an editable page and handling UI for some page actions.
Definition Article.php:66
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.
userCan(int $field, Authority $performer)
Determine if the given authority is allowed to view a particular field of this revision,...
getUser(int $audience=self::FOR_PUBLIC, ?Authority $performer=null)
Fetch revision's author's user identity, if it's available to the specified audience.
isDeleted(int $field)
MCR migration note: this replaced Revision::isDeleted.
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.
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)