MediaWiki REL1_37
DifferenceEngine.php
Go to the documentation of this file.
1<?php
35
59
61
68 private const DIFF_VERSION = '1.12';
69
76 protected $mOldid;
77
84 protected $mNewid;
85
97
107
113 protected $mOldPage;
114
120 protected $mNewPage;
121
126 private $mOldTags;
127
132 private $mNewTags;
133
140
147
149 protected $mDiffLang;
150
152 private $mRevisionsIdsLoaded = false;
153
155 protected $mRevisionsLoaded = false;
156
158 protected $mTextLoaded = 0;
159
168 protected $isContentOverridden = false;
169
171 protected $mCacheHit = false;
172
179 public $enableDebugComment = false;
180
184 protected $mReducedLineNumbers = false;
185
187 protected $mMarkPatrolledLink = null;
188
190 protected $unhide = false;
191
193 protected $mRefreshCache = false;
194
196 protected $slotDiffRenderers = null;
197
204 protected $isSlotDiffRenderer = false;
205
210 private $slotDiffOptions = [];
211
215 protected $linkRenderer;
216
221
226
228 private $hookRunner;
229
232
243 public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
244 $refreshCache = false, $unhide = false
245 ) {
246 $this->deprecatePublicProperty( 'mOldid', '1.32', __CLASS__ );
247 $this->deprecatePublicProperty( 'mNewid', '1.32', __CLASS__ );
248 $this->deprecatePublicProperty( 'mOldPage', '1.32', __CLASS__ );
249 $this->deprecatePublicProperty( 'mNewPage', '1.32', __CLASS__ );
250 $this->deprecatePublicProperty( 'mOldContent', '1.32', __CLASS__ );
251 $this->deprecatePublicProperty( 'mNewContent', '1.32', __CLASS__ );
252 $this->deprecatePublicProperty( 'mRevisionsLoaded', '1.32', __CLASS__ );
253 $this->deprecatePublicProperty( 'mTextLoaded', '1.32', __CLASS__ );
254 $this->deprecatePublicProperty( 'mCacheHit', '1.32', __CLASS__ );
255
256 if ( $context instanceof IContextSource ) {
257 $this->setContext( $context );
258 }
259
260 wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'" );
261
262 $this->mOldid = $old;
263 $this->mNewid = $new;
264 $this->mRefreshCache = $refreshCache;
265 $this->unhide = $unhide;
266
267 $services = MediaWikiServices::getInstance();
268 $this->linkRenderer = $services->getLinkRenderer();
269 $this->contentHandlerFactory = $services->getContentHandlerFactory();
270 $this->revisionStore = $services->getRevisionStore();
271 $this->hookRunner = new HookRunner( $services->getHookContainer() );
272 $this->wikiPageFactory = $services->getWikiPageFactory();
273 }
274
279 protected function getSlotDiffRenderers() {
280 if ( $this->isSlotDiffRenderer ) {
281 throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
282 }
283
284 if ( $this->slotDiffRenderers === null ) {
285 if ( !$this->loadRevisionData() ) {
286 return [];
287 }
288
289 $slotContents = $this->getSlotContents();
290 $this->slotDiffRenderers = array_map( function ( $contents ) {
292 $content = $contents['new'] ?: $contents['old'];
293 $context = $this->getContext();
294
295 return $content->getContentHandler()->getSlotDiffRenderer(
296 $context,
297 $this->slotDiffOptions
298 );
299 }, $slotContents );
300 }
301 return $this->slotDiffRenderers;
302 }
303
310 public function markAsSlotDiffRenderer() {
311 $this->isSlotDiffRenderer = true;
312 }
313
319 protected function getSlotContents() {
320 if ( $this->isContentOverridden ) {
321 return [
322 SlotRecord::MAIN => [
323 'old' => $this->mOldContent,
324 'new' => $this->mNewContent,
325 ]
326 ];
327 } elseif ( !$this->loadRevisionData() ) {
328 return [];
329 }
330
331 $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
332 if ( $this->mOldRevisionRecord ) {
333 $oldSlots = $this->mOldRevisionRecord->getPrimarySlots()->getSlots();
334 } else {
335 $oldSlots = [];
336 }
337 // The order here will determine the visual order of the diff. The current logic is
338 // slots of the new revision first in natural order, then deleted ones. This is ad hoc
339 // and should not be relied on - in the future we may want the ordering to depend
340 // on the page type.
341 $roles = array_merge( array_keys( $newSlots ), array_keys( $oldSlots ) );
342
343 $slots = [];
344 foreach ( $roles as $role ) {
345 $slots[$role] = [
346 'old' => isset( $oldSlots[$role] ) ? $oldSlots[$role]->getContent() : null,
347 'new' => isset( $newSlots[$role] ) ? $newSlots[$role]->getContent() : null,
348 ];
349 }
350 // move main slot to front
351 if ( isset( $slots[SlotRecord::MAIN] ) ) {
352 $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
353 }
354 return $slots;
355 }
356
358 public function getTitle() {
359 // T202454 avoid errors when there is no title
360 return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
361 }
362
369 public function setReducedLineNumbers( $value = true ) {
370 $this->mReducedLineNumbers = $value;
371 }
372
378 public function getDiffLang() {
379 if ( $this->mDiffLang === null ) {
380 # Default language in which the diff text is written.
381 $this->mDiffLang = $this->getTitle()->getPageLanguage();
382 }
383
384 return $this->mDiffLang;
385 }
386
390 public function wasCacheHit() {
391 return $this->mCacheHit;
392 }
393
401 public function getOldid() {
402 $this->loadRevisionIds();
403
404 return $this->mOldid;
405 }
406
413 public function getNewid() {
414 $this->loadRevisionIds();
415
416 return $this->mNewid;
417 }
418
425 public function getOldRevision() {
426 return $this->mOldRevisionRecord ?: null;
427 }
428
434 public function getNewRevision() {
435 return $this->mNewRevisionRecord;
436 }
437
446 public function deletedLink( $id ) {
447 if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
449 $revStore = $this->revisionStore;
450 $arQuery = $revStore->getArchiveQueryInfo();
451 $row = $dbr->selectRow(
452 $arQuery['tables'],
453 array_merge( $arQuery['fields'], [ 'ar_namespace', 'ar_title' ] ),
454 [ 'ar_rev_id' => $id ],
455 __METHOD__,
456 [],
457 $arQuery['joins']
458 );
459 if ( $row ) {
460 $revRecord = $revStore->newRevisionFromArchiveRow( $row );
461 $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
462
463 return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
464 'target' => $title->getPrefixedText(),
465 'timestamp' => $revRecord->getTimestamp()
466 ] );
467 }
468 }
469
470 return false;
471 }
472
480 public function deletedIdMarker( $id ) {
481 $link = $this->deletedLink( $id );
482 if ( $link ) {
483 return "[$link $id]";
484 } else {
485 return (string)$id;
486 }
487 }
488
489 private function showMissingRevision() {
490 $out = $this->getOutput();
491
492 $missing = [];
493 if ( $this->mOldRevisionRecord === null ||
494 ( $this->mOldRevisionRecord && $this->mOldContent === null )
495 ) {
496 $missing[] = $this->deletedIdMarker( $this->mOldid );
497 }
498 if ( $this->mNewRevisionRecord === null ||
499 ( $this->mNewRevisionRecord && $this->mNewContent === null )
500 ) {
501 $missing[] = $this->deletedIdMarker( $this->mNewid );
502 }
503
504 $out->setPageTitle( $this->msg( 'errorpagetitle' ) );
505 $msg = $this->msg( 'difference-missing-revision' )
506 ->params( $this->getLanguage()->listToText( $missing ) )
507 ->numParams( count( $missing ) )
508 ->parseAsBlock();
509 $out->addHTML( $msg );
510 }
511
517 public function hasDeletedRevision() {
518 $this->loadRevisionData();
519 return (
520 $this->mNewRevisionRecord &&
521 $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
522 ) ||
523 (
524 $this->mOldRevisionRecord &&
525 $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
526 );
527 }
528
535 public function getPermissionErrors( Authority $performer ) {
536 $this->loadRevisionData();
537 $permStatus = PermissionStatus::newEmpty();
538 if ( $this->mNewPage ) {
539 $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
540 }
541 if ( $this->mOldPage ) {
542 $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
543 }
544 return $permStatus->toLegacyErrorArray();
545 }
546
552 public function hasSuppressedRevision() {
553 return $this->hasDeletedRevision() && (
554 ( $this->mOldRevisionRecord &&
555 $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
556 ( $this->mNewRevisionRecord &&
557 $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
558 );
559 }
560
572 public function isUserAllowedToSeeRevisions( Authority $performer ) {
573 $this->loadRevisionData();
574 // $this->mNewRev will only be falsy if a loading error occurred
575 // (in which case the user is allowed to see).
576 $allowed = !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
577 RevisionRecord::DELETED_TEXT,
578 $performer
579 );
580 if ( $this->mOldRevisionRecord &&
581 !$this->mOldRevisionRecord->userCan(
582 RevisionRecord::DELETED_TEXT,
583 $performer
584 )
585 ) {
586 $allowed = false;
587 }
588 return $allowed;
589 }
590
598 public function shouldBeHiddenFromUser( Authority $performer ) {
599 return $this->hasDeletedRevision() && ( !$this->unhide ||
600 !$this->isUserAllowedToSeeRevisions( $performer ) );
601 }
602
606 public function showDiffPage( $diffOnly = false ) {
607 # Allow frames except in certain special cases
608 $out = $this->getOutput();
609 $out->allowClickjacking();
610 $out->setRobotPolicy( 'noindex,nofollow' );
611
612 // Allow extensions to add any extra output here
613 $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
614
615 if ( !$this->loadRevisionData() ) {
616 if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
617 $this->showMissingRevision();
618 }
619 return;
620 }
621
622 $user = $this->getUser();
623 $permErrors = $this->getPermissionErrors( $this->getAuthority() );
624 if ( count( $permErrors ) ) {
625 throw new PermissionsError( 'read', $permErrors );
626 }
627
628 $rollback = '';
629
630 $query = $this->slotDiffOptions;
631 # Carry over 'diffonly' param via navigation links
632 if ( $diffOnly != MediaWikiServices::getInstance()
633 ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
634 ) {
635 $query['diffonly'] = $diffOnly;
636 }
637 # Cascade unhide param in links for easy deletion browsing
638 if ( $this->unhide ) {
639 $query['unhide'] = 1;
640 }
641
642 # Check if one of the revisions is deleted/suppressed
643 $deleted = $this->hasDeletedRevision();
644 $suppressed = $this->hasSuppressedRevision();
645 $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
646
647 $revisionTools = [];
648
649 # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
650 # a diff between a version V and its previous version V' AND the version V
651 # is the first version of that article. In that case, V' does not exist.
652 if ( $this->mOldRevisionRecord === false ) {
653 if ( $this->mNewPage ) {
654 $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
655 }
656 $samePage = true;
657 $oldHeader = '';
658 // Allow extensions to change the $oldHeader variable
659 $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
660 } else {
661 $this->hookRunner->onDifferenceEngineViewHeader( $this );
662
663 if ( !$this->mOldPage || !$this->mNewPage ) {
664 // XXX say something to the user?
665 $samePage = false;
666 } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
667 $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
668 $samePage = true;
669 } else {
670 $out->setPageTitle( $this->msg( 'difference-title-multipage',
671 $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
672 $out->addSubtitle( $this->msg( 'difference-multipage' ) );
673 $samePage = false;
674 }
675
676 if ( $samePage && $this->mNewPage &&
677 $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
678 ) {
679 if ( $this->mNewRevisionRecord->isCurrent() &&
680 $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
681 ) {
682 $rollbackLink = Linker::generateRollback(
683 $this->mNewRevisionRecord,
684 $this->getContext(),
685 [ 'noBrackets' ]
686 );
687 if ( $rollbackLink ) {
688 $out->preventClickjacking();
689 $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
690 }
691 }
692
693 if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
694 $this->userCanEdit( $this->mNewRevisionRecord )
695 ) {
696 $undoLink = Html::element( 'a', [
697 'href' => $this->mNewPage->getLocalURL( [
698 'action' => 'edit',
699 'undoafter' => $this->mOldid,
700 'undo' => $this->mNewid
701 ] ),
702 'title' => Linker::titleAttrib( 'undo' ),
703 ],
704 $this->msg( 'editundo' )->text()
705 );
706 $revisionTools['mw-diff-undo'] = $undoLink;
707 }
708 }
709 # Make "previous revision link"
710 $hasPrevious = $samePage && $this->mOldPage &&
711 $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
712 if ( $hasPrevious ) {
713 $prevlink = $this->linkRenderer->makeKnownLink(
714 $this->mOldPage,
715 $this->msg( 'previousdiff' )->text(),
716 [ 'id' => 'differences-prevlink' ],
717 [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query
718 );
719 } else {
720 $prevlink = "\u{00A0}";
721 }
722
723 if ( $this->mOldRevisionRecord->isMinor() ) {
724 $oldminor = ChangesList::flag( 'minor' );
725 } else {
726 $oldminor = '';
727 }
728
729 $oldRevRecord = $this->mOldRevisionRecord;
730
731 $ldel = $this->revisionDeleteLink( $oldRevRecord );
732 $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
733 $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
734
735 $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
736 '<div id="mw-diff-otitle2">' .
737 Linker::revUserTools( $oldRevRecord, !$this->unhide ) . '</div>' .
738 '<div id="mw-diff-otitle3">' . $oldminor .
739 Linker::revComment( $oldRevRecord, !$diffOnly, !$this->unhide ) . $ldel . '</div>' .
740 '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
741 '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
742
743 // Allow extensions to change the $oldHeader variable
744 $this->hookRunner->onDifferenceEngineOldHeader(
745 $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
746 }
747
748 $out->addJsConfigVars( [
749 'wgDiffOldId' => $this->mOldid,
750 'wgDiffNewId' => $this->mNewid,
751 ] );
752
753 # Make "next revision link"
754 # Skip next link on the top revision
755 if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
756 $nextlink = $this->linkRenderer->makeKnownLink(
757 $this->mNewPage,
758 $this->msg( 'nextdiff' )->text(),
759 [ 'id' => 'differences-nextlink' ],
760 [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query
761 );
762 } else {
763 $nextlink = "\u{00A0}";
764 }
765
766 if ( $this->mNewRevisionRecord->isMinor() ) {
767 $newminor = ChangesList::flag( 'minor' );
768 } else {
769 $newminor = '';
770 }
771
772 # Handle RevisionDelete links...
773 $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
774
775 # Allow extensions to define their own revision tools
776 $this->hookRunner->onDiffTools(
777 $this->mNewRevisionRecord,
778 $revisionTools,
779 $this->mOldRevisionRecord ?: null,
780 $user
781 );
782
783 $formattedRevisionTools = [];
784 // Put each one in parentheses (poor man's button)
785 foreach ( $revisionTools as $key => $tool ) {
786 $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
787 $element = Html::rawElement(
788 'span',
789 [ 'class' => $toolClass ],
790 $this->msg( 'parentheses' )->rawParams( $tool )->escaped()
791 );
792 $formattedRevisionTools[] = $element;
793 }
794
795 $newRevRecord = $this->mNewRevisionRecord;
796
797 $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
798 ' ' . implode( ' ', $formattedRevisionTools );
799 $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
800
801 $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
802 '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
803 " $rollback</div>" .
804 '<div id="mw-diff-ntitle3">' . $newminor .
805 Linker::revComment( $newRevRecord, !$diffOnly, !$this->unhide ) . $rdel . '</div>' .
806 '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
807 '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
808
809 // Allow extensions to change the $newHeader variable
810 $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
811 $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
812 $rdel, $this->unhide );
813
814 # If the diff cannot be shown due to a deleted revision, then output
815 # the diff header and links to unhide (if available)...
816 if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
817 $this->showDiffStyle();
818 $multi = $this->getMultiNotice();
819 $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
820 if ( !$allowed ) {
821 $msg = $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff';
822 # Give explanation for why revision is not visible
823 $out->addHtml(
824 Html::warningBox(
825 $this->msg( $msg )->parse(),
826 'plainlinks'
827 )
828 );
829 } else {
830 # Give explanation and add a link to view the diff...
831 $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
832 $link = $this->getTitle()->getFullURL( $query );
833 $msg = $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff';
834 $out->addHtml(
835 Html::warningBox(
836 $this->msg( $msg, $link )->parse(),
837 'plainlinks'
838 )
839 );
840 }
841 # Otherwise, output a regular diff...
842 } else {
843 # Add deletion notice if the user is viewing deleted content
844 $notice = '';
845 if ( $deleted ) {
846 $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
847 $notice = Html::warningBox(
848 $this->msg( $msg )->parse(),
849 'plainlinks'
850 );
851 }
852 $this->showDiff( $oldHeader, $newHeader, $notice );
853 if ( !$diffOnly ) {
854 $this->renderNewRevision();
855 }
856 }
857 }
858
869 public function markPatrolledLink() {
870 if ( $this->mMarkPatrolledLink === null ) {
871 $linkInfo = $this->getMarkPatrolledLinkInfo();
872 // If false, there is no patrol link needed/allowed
873 if ( !$linkInfo || !$this->mNewPage ) {
874 $this->mMarkPatrolledLink = '';
875 } else {
876 $this->mMarkPatrolledLink = ' <span class="patrollink" data-mw="interface">[' .
877 $this->linkRenderer->makeKnownLink(
878 $this->mNewPage,
879 $this->msg( 'markaspatrolleddiff' )->text(),
880 [],
881 [
882 'action' => 'markpatrolled',
883 'rcid' => $linkInfo['rcid'],
884 ]
885 ) . ']</span>';
886 // Allow extensions to change the markpatrolled link
887 $this->hookRunner->onDifferenceEngineMarkPatrolledLink( $this,
888 $this->mMarkPatrolledLink, $linkInfo['rcid'] );
889 }
890 }
891 return $this->mMarkPatrolledLink;
892 }
893
901 protected function getMarkPatrolledLinkInfo() {
902 $user = $this->getUser();
903 $config = $this->getConfig();
904
905 // Prepare a change patrol link, if applicable
906 if (
907 // Is patrolling enabled and the user allowed to?
908 $config->get( 'UseRCPatrol' ) &&
909 $this->mNewPage &&
910 $this->getAuthority()->probablyCan( 'patrol', $this->mNewPage ) &&
911 // Only do this if the revision isn't more than 6 hours older
912 // than the Max RC age (6h because the RC might not be cleaned out regularly)
913 RecentChange::isInRCLifespan( $this->mNewRevisionRecord->getTimestamp(), 21600 )
914 ) {
915 // Look for an unpatrolled change corresponding to this diff
916 $change = RecentChange::newFromConds(
917 [
918 'rc_this_oldid' => $this->mNewid,
919 'rc_patrolled' => RecentChange::PRC_UNPATROLLED
920 ],
921 __METHOD__
922 );
923
924 if ( $change && !$change->getPerformerIdentity()->equals( $user ) ) {
925 $rcid = $change->getAttribute( 'rc_id' );
926 } else {
927 // None found or the page has been created by the current user.
928 // If the user could patrol this it already would be patrolled
929 $rcid = 0;
930 }
931
932 // Allow extensions to possibly change the rcid here
933 // For example the rcid might be set to zero due to the user
934 // being the same as the performer of the change but an extension
935 // might still want to show it under certain conditions
936 $this->hookRunner->onDifferenceEngineMarkPatrolledRCID( $rcid, $this, $change, $user );
937
938 // Build the link
939 if ( $rcid ) {
940 $this->getOutput()->preventClickjacking();
941 if ( $this->getAuthority()->isAllowed( 'writeapi' ) ) {
942 $this->getOutput()->addModules( 'mediawiki.misc-authed-curate' );
943 }
944
945 return [
946 'rcid' => $rcid,
947 ];
948 }
949 }
950
951 // No mark as patrolled link applicable
952 return false;
953 }
954
960 private function revisionDeleteLink( RevisionRecord $revRecord ) {
962 $this->getAuthority(),
963 $revRecord,
964 $revRecord->getPageAsLinkTarget()
965 );
966 if ( $link !== '' ) {
967 $link = "\u{00A0}\u{00A0}\u{00A0}" . $link . ' ';
968 }
969
970 return $link;
971 }
972
978 public function renderNewRevision() {
979 if ( $this->isContentOverridden ) {
980 // The code below only works with a RevisionRecord object. We could construct a
981 // fake RevisionRecord (here or in setContent), but since this does not seem
982 // needed at the moment, we'll just fail for now.
983 throw new LogicException(
984 __METHOD__
985 . ' is not supported after calling setContent(). Use setRevisions() instead.'
986 );
987 }
988
989 $out = $this->getOutput();
990 $revHeader = $this->getRevisionHeader( $this->mNewRevisionRecord );
991 # Add "current version as of X" title
992 $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
993 <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
994 # Page content may be handled by a hooked call instead...
995 if ( $this->hookRunner->onArticleContentOnDiff( $this, $out ) ) {
996 $this->loadNewText();
997 if ( !$this->mNewPage ) {
998 // New revision is unsaved; bail out.
999 // TODO in theory rendering the new revision is a meaningful thing to do
1000 // even if it's unsaved, but a lot of untangling is required to do it safely.
1001 return;
1002 }
1003
1004 $out->setRevisionId( $this->mNewid );
1005 $out->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() );
1006 $out->setArticleFlag( true );
1007
1008 if ( !$this->hookRunner->onArticleRevisionViewCustom(
1009 $this->mNewRevisionRecord, $this->mNewPage, $this->mOldid, $out )
1010 ) {
1011 // Handled by extension
1012 // NOTE: sync with hooks called in Article::view()
1013 } else {
1014 // Normal page
1015 if ( $this->getTitle()->equals( $this->mNewPage ) ) {
1016 // If the Title stored in the context is the same as the one
1017 // of the new revision, we can use its associated WikiPage
1018 // object.
1019 $wikiPage = $this->getWikiPage();
1020 } else {
1021 // Otherwise we need to create our own WikiPage object
1022 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->mNewPage );
1023 }
1024
1025 $parserOutput = $this->getParserOutput( $wikiPage, $this->mNewRevisionRecord );
1026
1027 # WikiPage::getParserOutput() should not return false, but just in case
1028 if ( $parserOutput ) {
1029 // Allow extensions to change parser output here
1030 if ( $this->hookRunner->onDifferenceEngineRenderRevisionAddParserOutput(
1031 $this, $out, $parserOutput, $wikiPage )
1032 ) {
1033 $out->addParserOutput( $parserOutput, [
1034 'enableSectionEditLinks' => $this->mNewRevisionRecord->isCurrent()
1035 && $this->getAuthority()->probablyCan(
1036 'edit',
1037 $this->mNewRevisionRecord->getPage()
1038 )
1039 ] );
1040 }
1041 }
1042 }
1043 }
1044
1045 // Allow extensions to optionally not show the final patrolled link
1046 if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1047 # Add redundant patrol link on bottom...
1048 $out->addHTML( $this->markPatrolledLink() );
1049 }
1050 }
1051
1058 protected function getParserOutput( WikiPage $page, RevisionRecord $revRecord ) {
1059 if ( !$revRecord->getId() ) {
1060 // WikiPage::getParserOutput wants a revision ID. Passing 0 will incorrectly show
1061 // the current revision, so fail instead. If need be, WikiPage::getParserOutput
1062 // could be made to accept a RevisionRecord instead of the id.
1063 return false;
1064 }
1065
1066 $parserOptions = $page->makeParserOptions( $this->getContext() );
1067 $parserOutput = $page->getParserOutput( $parserOptions, $revRecord->getId() );
1068
1069 return $parserOutput;
1070 }
1071
1082 public function showDiff( $otitle, $ntitle, $notice = '' ) {
1083 // Allow extensions to affect the output here
1084 $this->hookRunner->onDifferenceEngineShowDiff( $this );
1085
1086 $diff = $this->getDiff( $otitle, $ntitle, $notice );
1087 if ( $diff === false ) {
1088 $this->showMissingRevision();
1089
1090 return false;
1091 } else {
1092 $this->showDiffStyle();
1093 $this->getOutput()->addHTML( $diff );
1094
1095 return true;
1096 }
1097 }
1098
1102 public function showDiffStyle() {
1103 if ( !$this->isSlotDiffRenderer ) {
1104 $this->getOutput()->addModules( 'mediawiki.diff' );
1105 $this->getOutput()->addModuleStyles( [
1106 'mediawiki.interface.helpers.styles',
1107 'mediawiki.diff.styles'
1108 ] );
1109 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1110 $slotDiffRenderer->addModules( $this->getOutput() );
1111 }
1112 }
1113 }
1114
1124 public function getDiff( $otitle, $ntitle, $notice = '' ) {
1125 $body = $this->getDiffBody();
1126 if ( $body === false ) {
1127 return false;
1128 }
1129
1130 $multi = $this->getMultiNotice();
1131 // Display a message when the diff is empty
1132 if ( $body === '' ) {
1133 $notice .= '<div class="mw-diff-empty">' .
1134 $this->msg( 'diff-empty' )->parse() .
1135 "</div>\n";
1136 }
1137
1138 return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
1139 }
1140
1146 public function getDiffBody() {
1147 $this->mCacheHit = true;
1148 // Check if the diff should be hidden from this user
1149 if ( !$this->isContentOverridden ) {
1150 if ( !$this->loadRevisionData() ) {
1151 return false;
1152 } elseif ( $this->mOldRevisionRecord &&
1153 !$this->mOldRevisionRecord->userCan(
1154 RevisionRecord::DELETED_TEXT,
1155 $this->getAuthority()
1156 )
1157 ) {
1158 return false;
1159 } elseif ( $this->mNewRevisionRecord &&
1160 !$this->mNewRevisionRecord->userCan(
1161 RevisionRecord::DELETED_TEXT,
1162 $this->getAuthority()
1163 ) ) {
1164 return false;
1165 }
1166 // Short-circuit
1167 if ( $this->mOldRevisionRecord === false || (
1168 $this->mOldRevisionRecord &&
1169 $this->mNewRevisionRecord &&
1170 $this->mOldRevisionRecord->getId() &&
1171 $this->mOldRevisionRecord->getId() == $this->mNewRevisionRecord->getId()
1172 ) ) {
1173 if ( $this->hookRunner->onDifferenceEngineShowEmptyOldContent( $this ) ) {
1174 return '';
1175 }
1176 }
1177 }
1178
1179 // Cacheable?
1180 $key = false;
1181 $services = MediaWikiServices::getInstance();
1182 $cache = $services->getMainWANObjectCache();
1183 $stats = $services->getStatsdDataFactory();
1184 if ( $this->mOldid && $this->mNewid ) {
1185 // Check if subclass is still using the old way
1186 // for backwards-compatibility
1187 $key = $this->getDiffBodyCacheKey();
1188 if ( $key === null ) {
1189 $key = $cache->makeKey( ...$this->getDiffBodyCacheKeyParams() );
1190 }
1191
1192 // Try cache
1193 if ( !$this->mRefreshCache ) {
1194 $difftext = $cache->get( $key );
1195 if ( is_string( $difftext ) ) {
1196 $stats->updateCount( 'diff_cache.hit', 1 );
1197 $difftext = $this->localiseDiff( $difftext );
1198 $difftext .= "\n<!-- diff cache key $key -->\n";
1199
1200 return $difftext;
1201 }
1202 } // don't try to load but save the result
1203 }
1204 $this->mCacheHit = false;
1205
1206 // Loadtext is permission safe, this just clears out the diff
1207 if ( !$this->loadText() ) {
1208 return false;
1209 }
1210
1211 $difftext = '';
1212 // We've checked for revdelete at the beginning of this method; it's OK to ignore
1213 // read permissions here.
1214 $slotContents = $this->getSlotContents();
1215 foreach ( $this->getSlotDiffRenderers() as $role => $slotDiffRenderer ) {
1216 $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'],
1217 $slotContents[$role]['new'] );
1218 if ( $slotDiff && $role !== SlotRecord::MAIN ) {
1219 // FIXME: ask SlotRoleHandler::getSlotNameMessage
1220 $slotTitle = $role;
1221 $difftext .= $this->getSlotHeader( $slotTitle );
1222 }
1223 $difftext .= $slotDiff;
1224 }
1225
1226 // Save to cache for 7 days
1227 if ( !$this->hookRunner->onAbortDiffCache( $this ) ) {
1228 $stats->updateCount( 'diff_cache.uncacheable', 1 );
1229 } elseif ( $key !== false ) {
1230 $stats->updateCount( 'diff_cache.miss', 1 );
1231 $cache->set( $key, $difftext, 7 * 86400 );
1232 } else {
1233 $stats->updateCount( 'diff_cache.uncacheable', 1 );
1234 }
1235 // localise line numbers and title attribute text
1236 $difftext = $this->localiseDiff( $difftext );
1237
1238 return $difftext;
1239 }
1240
1247 public function getDiffBodyForRole( $role ) {
1248 $diffRenderers = $this->getSlotDiffRenderers();
1249 if ( !isset( $diffRenderers[$role] ) ) {
1250 return false;
1251 }
1252
1253 $slotContents = $this->getSlotContents();
1254 $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'],
1255 $slotContents[$role]['new'] );
1256 if ( !$slotDiff ) {
1257 return false;
1258 }
1259
1260 if ( $role !== SlotRecord::MAIN ) {
1261 // TODO use human-readable role name at least
1262 $slotTitle = $role;
1263 $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff;
1264 }
1265
1266 return $this->localiseDiff( $slotDiff );
1267 }
1268
1276 protected function getSlotHeader( $headerText ) {
1277 // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1278 $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1279 $userLang = $this->getLanguage()->getHtmlCode();
1280 return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-header', 'lang' => $userLang ],
1281 Html::element( 'th', [ 'colspan' => $columnCount ], $headerText ) );
1282 }
1283
1293 protected function getDiffBodyCacheKey() {
1294 return null;
1295 }
1296
1310 protected function getDiffBodyCacheKeyParams() {
1311 if ( !$this->mOldid || !$this->mNewid ) {
1312 throw new MWException( 'mOldid and mNewid must be set to get diff cache key.' );
1313 }
1314
1315 $engine = $this->getEngine();
1316 $params = [
1317 'diff',
1318 $engine === 'php' ? false : $engine, // Back compat
1319 self::DIFF_VERSION,
1320 "old-{$this->mOldid}",
1321 "rev-{$this->mNewid}"
1322 ];
1323
1324 if ( $engine === 'wikidiff2' ) {
1325 $params[] = phpversion( 'wikidiff2' );
1326 }
1327
1328 if ( !$this->isSlotDiffRenderer ) {
1329 foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1330 $params = array_merge( $params, $slotDiffRenderer->getExtraCacheKeys() );
1331 }
1332 }
1333
1334 return $params;
1335 }
1336
1344 public function getExtraCacheKeys() {
1345 // This method is called when the DifferenceEngine is used for a slot diff. We only care
1346 // about special things, not the revision IDs, which are added to the cache key by the
1347 // page-level DifferenceEngine, and which might not have a valid value for this object.
1348 $this->mOldid = 123456789;
1349 $this->mNewid = 987654321;
1350
1351 // This will repeat a bunch of unnecessary key fields for each slot. Not nice but harmless.
1352 $cacheString = $this->getDiffBodyCacheKey();
1353 if ( $cacheString ) {
1354 return [ $cacheString ];
1355 }
1356
1357 $params = $this->getDiffBodyCacheKeyParams();
1358
1359 // Try to get rid of the standard keys to keep the cache key human-readable:
1360 // call the getDiffBodyCacheKeyParams implementation of the base class, and if
1361 // the child class includes the same keys, drop them.
1362 // Uses an obscure PHP feature where static calls to non-static methods are allowed
1363 // as long as we are already in a non-static method of the same class, and the call context
1364 // ($this) will be inherited.
1365 // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
1367 if ( array_slice( $params, 0, count( $standardParams ) ) === $standardParams ) {
1368 $params = array_slice( $params, count( $standardParams ) );
1369 }
1370
1371 return $params;
1372 }
1373
1377 public function setSlotDiffOptions( $options ) {
1378 $this->slotDiffOptions = $options;
1379 }
1380
1394 public function generateContentDiffBody( Content $old, Content $new ) {
1395 $slotDiffRenderer = $new->getContentHandler()->getSlotDiffRenderer( $this->getContext() );
1396 if (
1397 $slotDiffRenderer instanceof DifferenceEngineSlotDiffRenderer
1398 && $this->isSlotDiffRenderer
1399 ) {
1400 // Oops, we are just about to enter an infinite loop (the slot-level DifferenceEngine
1401 // called a DifferenceEngineSlotDiffRenderer that wraps the same DifferenceEngine class).
1402 // This will happen when a content model has no custom slot diff renderer, it does have
1403 // a custom difference engine, but that does not override this method.
1404 throw new Exception( get_class( $this ) . ': could not maintain backwards compatibility. '
1405 . 'Please use a SlotDiffRenderer.' );
1406 }
1407 return $slotDiffRenderer->getDiff( $old, $new ) . $this->getDebugString();
1408 }
1409
1422 public function generateTextDiffBody( $otext, $ntext ) {
1423 $slotDiffRenderer = $this->contentHandlerFactory
1424 ->getContentHandler( CONTENT_MODEL_TEXT )
1425 ->getSlotDiffRenderer( $this->getContext() );
1426 if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1427 // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1428 // This is too unlikely to happen to bother handling properly.
1429 throw new Exception( 'The slot diff renderer for text content should be a '
1430 . 'TextSlotDiffRenderer subclass' );
1431 }
1432 return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1433 }
1434
1441 public static function getEngine() {
1442 $diffEngine = MediaWikiServices::getInstance()->getMainConfig()
1443 ->get( 'DiffEngine' );
1444 $externalDiffEngine = MediaWikiServices::getInstance()->getMainConfig()
1445 ->get( 'ExternalDiffEngine' );
1446
1447 if ( $diffEngine === null ) {
1448 $engines = [ 'external', 'wikidiff2', 'php' ];
1449 } else {
1450 $engines = [ $diffEngine ];
1451 }
1452
1453 $failureReason = null;
1454 foreach ( $engines as $engine ) {
1455 switch ( $engine ) {
1456 case 'external':
1457 if ( is_string( $externalDiffEngine ) ) {
1458 if ( is_executable( $externalDiffEngine ) ) {
1459 return $externalDiffEngine;
1460 }
1461 $failureReason = 'ExternalDiffEngine config points to a non-executable';
1462 if ( $diffEngine === null ) {
1463 wfDebug( "$failureReason, ignoring" );
1464 }
1465 } else {
1466 $failureReason = 'ExternalDiffEngine config is set to a non-string value';
1467 if ( $diffEngine === null && $externalDiffEngine ) {
1468 wfWarn( "$failureReason, ignoring" );
1469 }
1470 }
1471 break;
1472
1473 case 'wikidiff2':
1474 if ( function_exists( 'wikidiff2_do_diff' ) ) {
1475 return 'wikidiff2';
1476 }
1477 $failureReason = 'wikidiff2 is not available';
1478 break;
1479
1480 case 'php':
1481 // Always available.
1482 return 'php';
1483
1484 default:
1485 throw new DomainException( 'Invalid value for $wgDiffEngine: ' . $engine );
1486 }
1487 }
1488 throw new UnexpectedValueException( "Cannot use diff engine '$engine': $failureReason" );
1489 }
1490
1503 protected function textDiff( $otext, $ntext ) {
1504 $slotDiffRenderer = $this->contentHandlerFactory
1505 ->getContentHandler( CONTENT_MODEL_TEXT )
1506 ->getSlotDiffRenderer( $this->getContext() );
1507 if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1508 // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1509 // This is too unlikely to happen to bother handling properly.
1510 throw new Exception( 'The slot diff renderer for text content should be a '
1511 . 'TextSlotDiffRenderer subclass' );
1512 }
1513 return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1514 }
1515
1524 protected function debug( $generator = "internal" ) {
1525 if ( !$this->enableDebugComment ) {
1526 return '';
1527 }
1528 $data = [ $generator ];
1529 if ( $this->getConfig()->get( 'ShowHostnames' ) ) {
1530 $data[] = wfHostname();
1531 }
1532 $data[] = wfTimestamp( TS_DB );
1533
1534 return "<!-- diff generator: " .
1535 implode( " ", array_map( "htmlspecialchars", $data ) ) .
1536 " -->\n";
1537 }
1538
1542 private function getDebugString() {
1543 $engine = self::getEngine();
1544 if ( $engine === 'wikidiff2' ) {
1545 return $this->debug( 'wikidiff2' );
1546 } elseif ( $engine === 'php' ) {
1547 return $this->debug( 'native PHP' );
1548 } else {
1549 return $this->debug( "external $engine" );
1550 }
1551 }
1552
1559 private function localiseDiff( $text ) {
1560 $text = $this->localiseLineNumbers( $text );
1561 if ( $this->getEngine() === 'wikidiff2' &&
1562 version_compare( phpversion( 'wikidiff2' ), '1.5.1', '>=' )
1563 ) {
1564 $text = $this->addLocalisedTitleTooltips( $text );
1565 }
1566 return $text;
1567 }
1568
1576 public function localiseLineNumbers( $text ) {
1577 return preg_replace_callback(
1578 '/<!--LINE (\d+)-->/',
1579 [ $this, 'localiseLineNumbersCb' ],
1580 $text
1581 );
1582 }
1583
1588 public function localiseLineNumbersCb( $matches ) {
1589 if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
1590 return '';
1591 }
1592
1593 return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
1594 }
1595
1602 private function addLocalisedTitleTooltips( $text ) {
1603 return preg_replace_callback(
1604 '/class="mw-diff-movedpara-(left|right)"/',
1605 [ $this, 'addLocalisedTitleTooltipsCb' ],
1606 $text
1607 );
1608 }
1609
1614 private function addLocalisedTitleTooltipsCb( array $matches ) {
1615 $key = $matches[1] === 'right' ?
1616 'diff-paragraph-moved-toold' :
1617 'diff-paragraph-moved-tonew';
1618 return $matches[0] . ' title="' . $this->msg( $key )->escaped() . '"';
1619 }
1620
1626 public function getMultiNotice() {
1627 // The notice only make sense if we are diffing two saved revisions of the same page.
1628 if (
1629 !$this->mOldRevisionRecord || !$this->mNewRevisionRecord
1630 || !$this->mOldPage || !$this->mNewPage
1631 || !$this->mOldPage->equals( $this->mNewPage )
1632 || $this->mOldRevisionRecord->getId() === null
1633 || $this->mNewRevisionRecord->getId() === null
1634 // (T237709) Deleted revs might have different page IDs
1635 || $this->mNewPage->getArticleID() !== $this->mOldRevisionRecord->getPageId()
1636 || $this->mNewPage->getArticleID() !== $this->mNewRevisionRecord->getPageId()
1637 ) {
1638 return '';
1639 }
1640
1641 if ( $this->mOldRevisionRecord->getTimestamp() > $this->mNewRevisionRecord->getTimestamp() ) {
1642 $oldRevRecord = $this->mNewRevisionRecord; // flip
1643 $newRevRecord = $this->mOldRevisionRecord; // flip
1644 } else { // normal case
1645 $oldRevRecord = $this->mOldRevisionRecord;
1646 $newRevRecord = $this->mNewRevisionRecord;
1647 }
1648
1649 // Sanity: don't show the notice if too many rows must be scanned
1650 // @todo show some special message for that case
1651 $nEdits = $this->revisionStore->countRevisionsBetween(
1652 $this->mNewPage->getArticleID(),
1653 $oldRevRecord,
1654 $newRevRecord,
1655 1000
1656 );
1657 if ( $nEdits > 0 && $nEdits <= 1000 ) {
1658 $limit = 100; // use diff-multi-manyusers if too many users
1659 try {
1660 $users = $this->revisionStore->getAuthorsBetween(
1661 $this->mNewPage->getArticleID(),
1662 $oldRevRecord,
1663 $newRevRecord,
1664 null,
1665 $limit
1666 );
1667 $numUsers = count( $users );
1668
1669 $newRevUser = $newRevRecord->getUser( RevisionRecord::RAW );
1670 $newRevUserText = $newRevUser ? $newRevUser->getName() : '';
1671 if ( $numUsers == 1 && $users[0]->getName() == $newRevUserText ) {
1672 $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1673 }
1674 } catch ( InvalidArgumentException $e ) {
1675 $numUsers = 0;
1676 }
1677
1678 return self::intermediateEditsMsg( $nEdits, $numUsers, $limit );
1679 }
1680
1681 return '';
1682 }
1683
1693 public static function intermediateEditsMsg( $numEdits, $numUsers, $limit ) {
1694 if ( $numUsers === 0 ) {
1695 $msg = 'diff-multi-sameuser';
1696 } elseif ( $numUsers > $limit ) {
1697 $msg = 'diff-multi-manyusers';
1698 $numUsers = $limit;
1699 } else {
1700 $msg = 'diff-multi-otherusers';
1701 }
1702
1703 return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1704 }
1705
1710 private function userCanEdit( RevisionRecord $revRecord ) {
1711 if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1712 return false;
1713 }
1714
1715 return true;
1716 }
1717
1727 public function getRevisionHeader( RevisionRecord $rev, $complete = '' ) {
1728 $lang = $this->getLanguage();
1729 $user = $this->getUser();
1730 $revtimestamp = $rev->getTimestamp();
1731 $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1732 $dateofrev = $lang->userDate( $revtimestamp, $user );
1733 $timeofrev = $lang->userTime( $revtimestamp, $user );
1734
1735 $header = $this->msg(
1736 $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1737 $timestamp,
1738 $dateofrev,
1739 $timeofrev
1740 );
1741
1742 if ( $complete !== 'complete' ) {
1743 return $header->escaped();
1744 }
1745
1746 $title = $rev->getPageAsLinkTarget();
1747
1748 $header = $this->linkRenderer->makeKnownLink( $title, $header->text(), [],
1749 [ 'oldid' => $rev->getId() ] );
1750
1751 if ( $this->userCanEdit( $rev ) ) {
1752 $editQuery = [ 'action' => 'edit' ];
1753 if ( !$rev->isCurrent() ) {
1754 $editQuery['oldid'] = $rev->getId();
1755 }
1756
1757 $key = $this->getAuthority()->probablyCan( 'edit', $rev->getPage() ) ? 'editold' : 'viewsourceold';
1758 $msg = $this->msg( $key )->text();
1759 $editLink = $this->msg( 'parentheses' )->rawParams(
1760 $this->linkRenderer->makeKnownLink( $title, $msg, [], $editQuery ) )->escaped();
1761 $header .= ' ' . Html::rawElement(
1762 'span',
1763 [ 'class' => 'mw-diff-edit' ],
1764 $editLink
1765 );
1766 if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1767 $header = Html::rawElement(
1768 'span',
1769 [ 'class' => Linker::getRevisionDeletedClass( $rev ) ],
1770 $header
1771 );
1772 }
1773 } else {
1774 $header = Html::rawElement( 'span', [ 'class' => 'history-deleted' ], $header );
1775 }
1776
1777 return $header;
1778 }
1779
1792 public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
1793 // shared.css sets diff in interface language/dir, but the actual content
1794 // is often in a different language, mostly the page content language/dir
1795 $header = Html::openElement( 'table', [
1796 'class' => [
1797 'diff',
1798 'diff-contentalign-' . $this->getDiffLang()->alignStart(),
1799 'diff-editfont-' . $this->getUser()->getOption( 'editfont' )
1800 ],
1801 'data-mw' => 'interface',
1802 ] );
1803 $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
1804
1805 if ( !$diff && !$otitle ) {
1806 $header .= "
1807 <tr class=\"diff-title\" lang=\"{$userLang}\">
1808 <td class=\"diff-ntitle\">{$ntitle}</td>
1809 </tr>";
1810 $multiColspan = 1;
1811 } else {
1812 if ( $diff ) { // Safari/Chrome show broken output if cols not used
1813 $header .= "
1814 <col class=\"diff-marker\" />
1815 <col class=\"diff-content\" />
1816 <col class=\"diff-marker\" />
1817 <col class=\"diff-content\" />";
1818 $colspan = 2;
1819 $multiColspan = 4;
1820 } else {
1821 $colspan = 1;
1822 $multiColspan = 2;
1823 }
1824 if ( $otitle || $ntitle ) {
1825 // FIXME Hardcoding values from TableDiffFormatter.
1826 $deletedClass = 'diff-side-deleted';
1827 $addedClass = 'diff-side-added';
1828 $header .= "
1829 <tr class=\"diff-title\" lang=\"{$userLang}\">
1830 <td colspan=\"$colspan\" class=\"diff-otitle {$deletedClass}\">{$otitle}</td>
1831 <td colspan=\"$colspan\" class=\"diff-ntitle {$addedClass}\">{$ntitle}</td>
1832 </tr>";
1833 }
1834 }
1835
1836 if ( $multi != '' ) {
1837 $header .= "<tr><td colspan=\"{$multiColspan}\" " .
1838 "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
1839 }
1840 if ( $notice != '' ) {
1841 $header .= "<tr><td colspan=\"{$multiColspan}\" " .
1842 "class=\"diff-notice\" lang=\"{$userLang}\">{$notice}</td></tr>";
1843 }
1844
1845 return $header . $diff . "</table>";
1846 }
1847
1855 public function setContent( Content $oldContent, Content $newContent ) {
1856 $this->mOldContent = $oldContent;
1857 $this->mNewContent = $newContent;
1858
1859 $this->mTextLoaded = 2;
1860 $this->mRevisionsLoaded = true;
1861 $this->isContentOverridden = true;
1862 $this->slotDiffRenderers = null;
1863 }
1864
1870 public function setRevisions(
1871 ?RevisionRecord $oldRevision, RevisionRecord $newRevision
1872 ) {
1873 if ( $oldRevision ) {
1874 $this->mOldRevisionRecord = $oldRevision;
1875 $this->mOldid = $oldRevision->getId();
1876 $this->mOldPage = Title::newFromLinkTarget( $oldRevision->getPageAsLinkTarget() );
1877 // This method is meant for edit diffs and such so there is no reason to provide a
1878 // revision that's not readable to the user, but check it just in case.
1879 $this->mOldContent = $oldRevision->getContent( SlotRecord::MAIN,
1880 RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
1881 } else {
1882 $this->mOldPage = null;
1883 $this->mOldRevisionRecord = $this->mOldid = false;
1884 }
1885 $this->mNewRevisionRecord = $newRevision;
1886 $this->mNewid = $newRevision->getId();
1887 $this->mNewPage = Title::newFromLinkTarget( $newRevision->getPageAsLinkTarget() );
1888 $this->mNewContent = $newRevision->getContent( SlotRecord::MAIN,
1889 RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
1890
1891 $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true;
1892 $this->mTextLoaded = $oldRevision ? 2 : 1;
1893 $this->isContentOverridden = false;
1894 $this->slotDiffRenderers = null;
1895 }
1896
1903 public function setTextLanguage( Language $lang ) {
1904 $this->mDiffLang = $lang;
1905 }
1906
1919 public function mapDiffPrevNext( $old, $new ) {
1920 if ( $new === 'prev' ) {
1921 // Show diff between revision $old and the previous one. Get previous one from DB.
1922 $newid = intval( $old );
1923 $oldid = false;
1924 $newRev = $this->revisionStore->getRevisionById( $newid );
1925 if ( $newRev ) {
1926 $oldRev = $this->revisionStore->getPreviousRevision( $newRev );
1927 if ( $oldRev ) {
1928 $oldid = $oldRev->getId();
1929 }
1930 }
1931 } elseif ( $new === 'next' ) {
1932 // Show diff between revision $old and the next one. Get next one from DB.
1933 $oldid = intval( $old );
1934 $newid = false;
1935 $oldRev = $this->revisionStore->getRevisionById( $oldid );
1936 if ( $oldRev ) {
1937 $newRev = $this->revisionStore->getNextRevision( $oldRev );
1938 if ( $newRev ) {
1939 $newid = $newRev->getId();
1940 }
1941 }
1942 } else {
1943 $oldid = intval( $old );
1944 $newid = intval( $new );
1945 }
1946
1947 return [ $oldid, $newid ];
1948 }
1949
1950 private function loadRevisionIds() {
1951 if ( $this->mRevisionsIdsLoaded ) {
1952 return;
1953 }
1954
1955 $this->mRevisionsIdsLoaded = true;
1956
1957 $old = $this->mOldid;
1958 $new = $this->mNewid;
1959
1960 list( $this->mOldid, $this->mNewid ) = self::mapDiffPrevNext( $old, $new );
1961 if ( $new === 'next' && $this->mNewid === false ) {
1962 # if no result, NewId points to the newest old revision. The only newer
1963 # revision is cur, which is "0".
1964 $this->mNewid = 0;
1965 }
1966
1967 $this->hookRunner->onNewDifferenceEngine(
1968 $this->getTitle(), $this->mOldid, $this->mNewid, $old, $new );
1969 }
1970
1984 public function loadRevisionData() {
1985 if ( $this->mRevisionsLoaded ) {
1986 return $this->isContentOverridden ||
1987 ( $this->mOldRevisionRecord !== null && $this->mNewRevisionRecord !== null );
1988 }
1989
1990 // Whether it succeeds or fails, we don't want to try again
1991 $this->mRevisionsLoaded = true;
1992
1993 $this->loadRevisionIds();
1994
1995 // Load the new RevisionRecord object
1996 if ( $this->mNewid ) {
1997 $this->mNewRevisionRecord = $this->revisionStore->getRevisionById( $this->mNewid );
1998 } else {
1999 $this->mNewRevisionRecord = $this->revisionStore->getRevisionByTitle( $this->getTitle() );
2000 }
2001
2002 if ( !$this->mNewRevisionRecord instanceof RevisionRecord ) {
2003 return false;
2004 }
2005
2006 // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
2007 $this->mNewid = $this->mNewRevisionRecord->getId();
2008 if ( $this->mNewid ) {
2009 $this->mNewPage = Title::newFromLinkTarget(
2010 $this->mNewRevisionRecord->getPageAsLinkTarget()
2011 );
2012 } else {
2013 $this->mNewPage = null;
2014 }
2015
2016 // Load the old RevisionRecord object
2017 $this->mOldRevisionRecord = false;
2018 if ( $this->mOldid ) {
2019 $this->mOldRevisionRecord = $this->revisionStore->getRevisionById( $this->mOldid );
2020 } elseif ( $this->mOldid === 0 ) {
2021 $revRecord = $this->revisionStore->getPreviousRevision( $this->mNewRevisionRecord );
2022 if ( $revRecord ) {
2023 $this->mOldid = $revRecord->getId();
2024 $this->mOldRevisionRecord = $revRecord;
2025 } else {
2026 // No previous revision; mark to show as first-version only.
2027 $this->mOldid = false;
2028 $this->mOldRevisionRecord = false;
2029 }
2030 } /* elseif ( $this->mOldid === false ) leave mOldRevisionRecord false; */
2031
2032 if ( $this->mOldRevisionRecord === null ) {
2033 return false;
2034 }
2035
2036 if ( $this->mOldRevisionRecord && $this->mOldRevisionRecord->getId() ) {
2037 $this->mOldPage = Title::newFromLinkTarget(
2038 $this->mOldRevisionRecord->getPageAsLinkTarget()
2039 );
2040 } else {
2041 $this->mOldPage = null;
2042 }
2043
2044 // Load tags information for both revisions
2045 $dbr = wfGetDB( DB_REPLICA );
2046 $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
2047 if ( $this->mOldid !== false ) {
2048 $tagIds = $dbr->selectFieldValues(
2049 'change_tag',
2050 'ct_tag_id',
2051 [ 'ct_rev_id' => $this->mOldid ],
2052 __METHOD__
2053 );
2054 $tags = [];
2055 foreach ( $tagIds as $tagId ) {
2056 try {
2057 $tags[] = $changeTagDefStore->getName( (int)$tagId );
2058 } catch ( NameTableAccessException $exception ) {
2059 continue;
2060 }
2061 }
2062 $this->mOldTags = implode( ',', $tags );
2063 } else {
2064 $this->mOldTags = false;
2065 }
2066
2067 $tagIds = $dbr->selectFieldValues(
2068 'change_tag',
2069 'ct_tag_id',
2070 [ 'ct_rev_id' => $this->mNewid ],
2071 __METHOD__
2072 );
2073 $tags = [];
2074 foreach ( $tagIds as $tagId ) {
2075 try {
2076 $tags[] = $changeTagDefStore->getName( (int)$tagId );
2077 } catch ( NameTableAccessException $exception ) {
2078 continue;
2079 }
2080 }
2081 $this->mNewTags = implode( ',', $tags );
2082
2083 return true;
2084 }
2085
2094 public function loadText() {
2095 if ( $this->mTextLoaded == 2 ) {
2096 return $this->loadRevisionData() &&
2097 ( $this->mOldRevisionRecord === false || $this->mOldContent )
2098 && $this->mNewContent;
2099 }
2100
2101 // Whether it succeeds or fails, we don't want to try again
2102 $this->mTextLoaded = 2;
2103
2104 if ( !$this->loadRevisionData() ) {
2105 return false;
2106 }
2107
2108 if ( $this->mOldRevisionRecord ) {
2109 $this->mOldContent = $this->mOldRevisionRecord->getContent(
2110 SlotRecord::MAIN,
2111 RevisionRecord::FOR_THIS_USER,
2112 $this->getAuthority()
2113 );
2114 if ( $this->mOldContent === null ) {
2115 return false;
2116 }
2117 }
2118
2119 $this->mNewContent = $this->mNewRevisionRecord->getContent(
2120 SlotRecord::MAIN,
2121 RevisionRecord::FOR_THIS_USER,
2122 $this->getAuthority()
2123 );
2124 $this->hookRunner->onDifferenceEngineLoadTextAfterNewContentIsLoaded( $this );
2125 if ( $this->mNewContent === null ) {
2126 return false;
2127 }
2128
2129 return true;
2130 }
2131
2137 public function loadNewText() {
2138 if ( $this->mTextLoaded >= 1 ) {
2139 return $this->loadRevisionData();
2140 }
2141
2142 $this->mTextLoaded = 1;
2143
2144 if ( !$this->loadRevisionData() ) {
2145 return false;
2146 }
2147
2148 $this->mNewContent = $this->mNewRevisionRecord->getContent(
2149 SlotRecord::MAIN,
2150 RevisionRecord::FOR_THIS_USER,
2151 $this->getAuthority()
2152 );
2153
2154 $this->hookRunner->onDifferenceEngineAfterLoadNewText( $this );
2155
2156 return true;
2157 }
2158
2159}
getAuthority()
const NS_SPECIAL
Definition Defines.php:53
const CONTENT_MODEL_TEXT
Definition Defines.php:211
deprecatePublicProperty( $property, $version, $class=null, $component=null)
Mark a property as deprecated.
trait DeprecationHelper
Use this trait in classes which have properties for which public access is deprecated or implementati...
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfWarn( $msg, $callerOffset=1, $level=E_USER_NOTICE)
Send a warning either to the debug log or in a PHP error depending on $wgDevelopmentWarnings.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
wfHostname()
Get host name of the current machine, for use in error reporting.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
getContext()
static formatSummaryRow( $tags, $page, MessageLocalizer $localizer=null)
Creates HTML for the given tags.
The simplest way of implementing IContextSource is to hold a RequestContext as a member variable and ...
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
IContextSource $context
getWikiPage()
Get the WikiPage object.
setContext(IContextSource $context)
B/C adapter for turning a DifferenceEngine into a SlotDiffRenderer.
DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
getDiffBodyCacheKey()
Returns the cache key for diff body text or content.
bool $enableDebugComment
Set this to true to add debug info to the HTML output.
bool $unhide
Show rev_deleted content if allowed.
bool $isContentOverridden
Was the content overridden via setContent()? If the content was overridden, most internal state (e....
getExtraCacheKeys()
Implements DifferenceEngineSlotDiffRenderer::getExtraCacheKeys().
markAsSlotDiffRenderer()
Mark this DifferenceEngine as a slot renderer (as opposed to a page renderer).
getSlotHeader( $headerText)
Get a slot header for inclusion in a diff body (as a table row).
bool $mRevisionsIdsLoaded
Have the revisions IDs been loaded.
string[] null $mOldTags
Change tags of old revision or null if it does not exist / is not saved.
setSlotDiffOptions( $options)
hasDeletedRevision()
Checks whether one of the given Revisions was deleted.
IContentHandlerFactory $contentHandlerFactory
int $mTextLoaded
How many text blobs have been loaded, 0, 1 or 2?
deletedIdMarker( $id)
Build a wikitext link toward a deleted revision, if viewable.
getDiffBodyForRole( $role)
Get the diff table body for one slot, without header.
WikiPageFactory $wikiPageFactory
RevisionStore $revisionStore
revisionDeleteLink(RevisionRecord $revRecord)
getOldid()
Get the ID of old revision (left pane) of the diff.
setRevisions(?RevisionRecord $oldRevision, RevisionRecord $newRevision)
Use specified text instead of loading from the database.
bool $isSlotDiffRenderer
Temporary hack for B/C while slot diff related methods of DifferenceEngine are being deprecated.
generateTextDiffBody( $otext, $ntext)
Generate a diff, no caching.
loadNewText()
Load the text of the new revision, not the old one.
showDiffPage( $diffOnly=false)
loadText()
Load the text of the revisions, as well as revision data.
int string false null $mNewid
Revision ID for the new revision.
const DIFF_VERSION
Constant to indicate diff cache compatibility.
mapDiffPrevNext( $old, $new)
Maps a revision pair definition as accepted by DifferenceEngine constructor to a pair of actual integ...
getPermissionErrors(Authority $performer)
Get the permission errors associated with the revisions for the current diff.
Content null $mNewContent
getDiffBody()
Get the diff table body, without header.
getTitle()
1.18 to override Title|null
string[] null $mNewTags
Change tags of new revision or null if it does not exist / is not saved.
getParserOutput(WikiPage $page, RevisionRecord $revRecord)
loadRevisionData()
Load revision metadata for the specified revisions.
static getEngine()
Process DiffEngine config and get a sane, usable engine.
bool $mRevisionsLoaded
Have the revisions been loaded.
Content null $mOldContent
getNewRevision()
Get the right side of the diff.
showDiff( $otitle, $ntitle, $notice='')
Get the diff text, send it to the OutputPage object Returns false if the diff could not be generated,...
localiseLineNumbers( $text)
Replace line numbers with the text in the user's language.
getSlotContents()
Get the old and new content objects for all slots.
string $mMarkPatrolledLink
Link to action=markpatrolled.
localiseLineNumbersCb( $matches)
array $slotDiffOptions
A set of options that will be passed to the SlotDiffRenderer upon creation.
deletedLink( $id)
Look up a special:Undelete link to the given deleted revision id, as a workaround for being unable to...
bool $mReducedLineNumbers
If true, line X is not displayed when X is 1, for example to increase readability and conserve space ...
__construct( $context=null, $old=0, $new=0, $rcid=0, $refreshCache=false, $unhide=false)
#-
Title null $mNewPage
Title of new revision or null if the new revision does not exist or does not belong to a page.
bool $mCacheHit
Was the diff fetched from cache?
getMultiNotice()
If there are revisions between the ones being compared, return a note saying so.
isUserAllowedToSeeRevisions(Authority $performer)
Checks whether the current user has permission for accessing the revisions of the diff.
int false null $mOldid
Revision ID for the old revision.
debug( $generator="internal")
Generate a debug comment indicating diff generating time, server node, and generator backend.
addHeader( $diff, $otitle, $ntitle, $multi='', $notice='')
Add the header to a diff body.
bool $mRefreshCache
Refresh the diff cache.
LinkRenderer $linkRenderer
getDiffBodyCacheKeyParams()
Get the cache key parameters.
getDiff( $otitle, $ntitle, $notice='')
Get complete diff table, including header.
RevisionRecord null $mNewRevisionRecord
New revision (right pane).
getNewid()
Get the ID of new revision (right pane) of the diff.
renderNewRevision()
Show the new revision of the page.
addLocalisedTitleTooltips( $text)
Add title attributes for tooltips on moved paragraph indicators.
setContent(Content $oldContent, Content $newContent)
Use specified text instead of loading from the database.
setTextLanguage(Language $lang)
Set the language in which the diff text is written.
generateContentDiffBody(Content $old, Content $new)
Generate a diff, no caching.
localiseDiff( $text)
Localise diff output.
shouldBeHiddenFromUser(Authority $performer)
Checks whether the diff should be hidden from the current user This is based on whether the user is a...
getRevisionHeader(RevisionRecord $rev, $complete='')
Get a header for a specified revision.
getMarkPatrolledLinkInfo()
Returns an array of meta data needed to build a "mark as patrolled" link and adds a JS module to the ...
setReducedLineNumbers( $value=true)
Set reduced line numbers mode.
RevisionRecord null false $mOldRevisionRecord
Old revision (left pane).
textDiff( $otext, $ntext)
Generates diff, to be wrapped internally in a logging/instrumentation.
static intermediateEditsMsg( $numEdits, $numUsers, $limit)
Get a notice about how many intermediate edits and users there are.
Title null $mOldPage
Title of old revision or null if the old revision does not exist or does not belong to a page.
SlotDiffRenderer[] $slotDiffRenderers
DifferenceEngine classes for the slots, keyed by role name.
getDiffLang()
Get the language of the difference engine, defaults to page content language.
showDiffStyle()
Add style sheets for diff display.
addLocalisedTitleTooltipsCb(array $matches)
markPatrolledLink()
Build a link to mark a change as patrolled.
hasSuppressedRevision()
Checks whether one of the given Revisions was suppressed.
getOldRevision()
Get the left side of the diff.
userCanEdit(RevisionRecord $revRecord)
Internationalisation code See https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation for more...
Definition Language.php:42
static titleAttrib( $name, $options=null, array $msgParams=[])
Given the id of an interface element, constructs the appropriate title attribute from the system mess...
Definition Linker.php:2276
static getRevisionDeletedClass(RevisionRecord $revisionRecord)
Returns css class of a deleted revision.
Definition Linker.php:1299
static revComment(RevisionRecord $revRecord, $local=false, $isPublic=false, $useParentheses=true)
Wrap and format the given revision's comment block, if the current user is allowed to view it.
Definition Linker.php:1782
static getRevDeleteLink(Authority $performer, RevisionRecord $revRecord, LinkTarget $title)
Get a revision-deletion link, or disabled link, or nothing, depending on user permissions & the setti...
Definition Linker.php:2360
static generateRollback(RevisionRecord $revRecord, IContextSource $context=null, $options=[ 'verify'])
Generate a rollback link for a given revision.
Definition Linker.php:2031
static revUserTools(RevisionRecord $revRecord, $isPublic=false, $useParentheses=true)
Generate a user tool link cluster if the current user is allowed to view it.
Definition Linker.php:1319
MediaWiki exception.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Class that generates HTML links for pages.
MediaWikiServices is the service locator for the application scope of MediaWiki.
A StatusValue for permission errors.
Page revision base class.
getPage()
Returns the page this revision belongs to.
isCurrent()
Checks whether the revision record is a stored current revision.
getTimestamp()
MCR migration note: this replaced Revision::getTimestamp.
getContent( $role, $audience=self::FOR_PUBLIC, Authority $performer=null)
Returns the Content of the given slot of this revision.
getPageAsLinkTarget()
Returns the title of the page this revision is associated with as a LinkTarget object.
userCan( $field, Authority $performer)
Determine if the give authority is allowed to view a particular field of this revision,...
isDeleted( $field)
MCR migration note: this replaced Revision::isDeleted.
getId( $wikiId=self::LOCAL)
Get revision ID.
Service for looking up page revisions.
Value object representing a content slot associated with a page revision.
Exception representing a failure to look up a row from a name table.
Show an error when a user tries to do something they do not have the necessary permissions for.
Renders a diff for a single slot (that is, a diff between two content objects).
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
Renders a slot diff by doing a text diff on the native representation.
Represents a title within MediaWiki.
Definition Title.php:48
Class representing a MediaWiki article and history.
Definition WikiPage.php:60
getParserOutput(ParserOptions $parserOptions, $oldid=null, $noCache=false)
Get a ParserOutput for the given ParserOptions and revision ID.
makeParserOptions( $context)
Get parser options suitable for rendering the primary article wikitext.
Base interface for content objects.
Definition Content.php:35
getContentHandler()
Convenience method that returns the ContentHandler singleton for handling the content model that this...
Interface for objects which can provide a MediaWiki context on request.
This interface represents the authority associated the current execution context, such as a web reque...
Definition Authority.php:37
authorizeRead(string $action, PageIdentity $target, PermissionStatus $status=null)
Authorize read access.
$cache
Definition mcc.php:33
const DB_REPLICA
Definition defines.php:25
$content
Definition router.php:76
if(!isset( $args[0])) $lang
$header