DifferenceEngine: introduce setDiffLang() to change the language in which the diff...
[lhc/web/wiklou.git] / includes / diff / DifferenceEngine.php
1 <?php
2 /**
3 * User interface for the difference engine
4 *
5 * @file
6 * @ingroup DifferenceEngine
7 */
8
9 /**
10 * Constant to indicate diff cache compatibility.
11 * Bump this when changing the diff formatting in a way that
12 * fixes important bugs or such to force cached diff views to
13 * clear.
14 */
15 define( 'MW_DIFF_VERSION', '1.11a' );
16
17 /**
18 * @todo document
19 * @ingroup DifferenceEngine
20 */
21 class DifferenceEngine {
22 /**#@+
23 * @private
24 */
25 var $mOldid, $mNewid;
26 var $mOldtitle, $mNewtitle, $mPagetitle;
27 var $mOldtext, $mNewtext;
28 var $mDiffLang;
29
30 /**
31 * @var Title
32 */
33 var $mOldPage, $mNewPage, $mTitle;
34 var $mRcidMarkPatrolled;
35
36 /**
37 * @var Revision
38 */
39 var $mOldRev, $mNewRev;
40 var $mRevisionsLoaded = false; // Have the revisions been loaded
41 var $mTextLoaded = 0; // How many text blobs have been loaded, 0, 1 or 2?
42 var $mCacheHit = false; // Was the diff fetched from cache?
43
44 /**
45 * Set this to true to add debug info to the HTML output.
46 * Warning: this may cause RSS readers to spuriously mark articles as "new"
47 * (bug 20601)
48 */
49 var $enableDebugComment = false;
50
51 // If true, line X is not displayed when X is 1, for example to increase
52 // readability and conserve space with many small diffs.
53 protected $mReducedLineNumbers = false;
54
55 protected $unhide = false; # show rev_deleted content if allowed
56 /**#@-*/
57
58 /**
59 * Constructor
60 * @param $titleObj Title object that the diff is associated with
61 * @param $old Integer old ID we want to show and diff with.
62 * @param $new String either 'prev' or 'next'.
63 * @param $rcid Integer ??? FIXME (default 0)
64 * @param $refreshCache boolean If set, refreshes the diff cache
65 * @param $unhide boolean If set, allow viewing deleted revs
66 */
67 function __construct( $titleObj = null, $old = 0, $new = 0, $rcid = 0,
68 $refreshCache = false, $unhide = false )
69 {
70 if ( $titleObj ) {
71 $this->mTitle = $titleObj;
72 } else {
73 global $wgTitle;
74 $this->mTitle = $wgTitle; // @TODO: get rid of this
75 }
76 wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'\n" );
77
78 # Default language in which the diff text is written.
79 $this->mDiffLang = $this->mTitle->getPageLanguage();
80
81 if ( 'prev' === $new ) {
82 # Show diff between revision $old and the previous one.
83 # Get previous one from DB.
84 $this->mNewid = intval( $old );
85 $this->mOldid = $this->mTitle->getPreviousRevisionID( $this->mNewid );
86 } elseif ( 'next' === $new ) {
87 # Show diff between revision $old and the next one.
88 # Get next one from DB.
89 $this->mOldid = intval( $old );
90 $this->mNewid = $this->mTitle->getNextRevisionID( $this->mOldid );
91 if ( false === $this->mNewid ) {
92 # if no result, NewId points to the newest old revision. The only newer
93 # revision is cur, which is "0".
94 $this->mNewid = 0;
95 }
96 } else {
97 $this->mOldid = intval( $old );
98 $this->mNewid = intval( $new );
99 wfRunHooks( 'NewDifferenceEngine', array( &$titleObj, &$this->mOldid, &$this->mNewid, $old, $new ) );
100 }
101 $this->mRcidMarkPatrolled = intval( $rcid ); # force it to be an integer
102 $this->mRefreshCache = $refreshCache;
103 $this->unhide = $unhide;
104 }
105
106 /**
107 * @param $value bool
108 */
109 function setReducedLineNumbers( $value = true ) {
110 $this->mReducedLineNumbers = $value;
111 }
112
113 /**
114 * @return Title
115 */
116 function getTitle() {
117 return $this->mTitle;
118 }
119
120 /**
121 * @return bool
122 */
123 function wasCacheHit() {
124 return $this->mCacheHit;
125 }
126
127 /**
128 * @return int
129 */
130 function getOldid() {
131 return $this->mOldid;
132 }
133
134 /**
135 * @return Bool|int
136 */
137 function getNewid() {
138 return $this->mNewid;
139 }
140
141 /**
142 * Look up a special:Undelete link to the given deleted revision id,
143 * as a workaround for being unable to load deleted diffs in currently.
144 *
145 * @param int $id revision ID
146 * @return mixed URL or false
147 */
148 function deletedLink( $id ) {
149 global $wgUser;
150 if ( $wgUser->isAllowed( 'deletedhistory' ) ) {
151 $dbr = wfGetDB( DB_SLAVE );
152 $row = $dbr->selectRow('archive', '*',
153 array( 'ar_rev_id' => $id ),
154 __METHOD__ );
155 if ( $row ) {
156 $rev = Revision::newFromArchiveRow( $row );
157 $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
158 return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( array(
159 'target' => $title->getPrefixedText(),
160 'timestamp' => $rev->getTimestamp()
161 ));
162 }
163 }
164 return false;
165 }
166
167 /**
168 * Build a wikitext link toward a deleted revision, if viewable.
169 *
170 * @param int $id revision ID
171 * @return string wikitext fragment
172 */
173 function deletedIdMarker( $id ) {
174 $link = $this->deletedLink( $id );
175 if ( $link ) {
176 return "[$link $id]";
177 } else {
178 return $id;
179 }
180 }
181
182 function showDiffPage( $diffOnly = false ) {
183 global $wgUser, $wgOut, $wgUseExternalEditor, $wgUseRCPatrol;
184 wfProfileIn( __METHOD__ );
185
186 # Allow frames except in certain special cases
187 $wgOut->allowClickjacking();
188
189 # If external diffs are enabled both globally and for the user,
190 # we'll use the application/x-external-editor interface to call
191 # an external diff tool like kompare, kdiff3, etc.
192 if ( $wgUseExternalEditor && $wgUser->getOption( 'externaldiff' ) ) {
193 global $wgServer, $wgScript, $wgLang;
194 $wgOut->disable();
195 header ( "Content-type: application/x-external-editor; charset=UTF-8" );
196 $url1 = $this->mTitle->getFullURL( array(
197 'action' => 'raw',
198 'oldid' => $this->mOldid
199 ) );
200 $url2 = $this->mTitle->getFullURL( array(
201 'action' => 'raw',
202 'oldid' => $this->mNewid
203 ) );
204 $special = $wgLang->getNsText( NS_SPECIAL );
205 $control = <<<CONTROL
206 [Process]
207 Type=Diff text
208 Engine=MediaWiki
209 Script={$wgServer}{$wgScript}
210 Special namespace={$special}
211
212 [File]
213 Extension=wiki
214 URL=$url1
215
216 [File 2]
217 Extension=wiki
218 URL=$url2
219 CONTROL;
220 echo( $control );
221
222 wfProfileOut( __METHOD__ );
223 return;
224 }
225
226 $wgOut->setArticleFlag( false );
227 if ( !$this->loadRevisionData() ) {
228 // Sounds like a deleted revision... Let's see what we can do.
229 $t = $this->mTitle->getPrefixedText();
230 $d = wfMsgExt( 'missingarticle-diff', array( 'escape' ),
231 $this->deletedIdMarker( $this->mOldid ),
232 $this->deletedIdMarker( $this->mNewid ) );
233 $wgOut->setPagetitle( wfMsg( 'errorpagetitle' ) );
234 $wgOut->addWikiMsg( 'missing-article', "<nowiki>$t</nowiki>", "<span class='plainlinks'>$d</span>" );
235 wfProfileOut( __METHOD__ );
236 return;
237 }
238
239 wfRunHooks( 'DiffViewHeader', array( $this, $this->mOldRev, $this->mNewRev ) );
240
241 if ( $this->mNewRev->isCurrent() ) {
242 $wgOut->setArticleFlag( true );
243 }
244
245 # mOldid is false if the difference engine is called with a "vague" query for
246 # a diff between a version V and its previous version V' AND the version V
247 # is the first version of that article. In that case, V' does not exist.
248 if ( $this->mOldid === false ) {
249 $this->showFirstRevision();
250 $this->renderNewRevision(); // should we respect $diffOnly here or not?
251 wfProfileOut( __METHOD__ );
252 return;
253 }
254
255 $oldTitle = $this->mOldPage->getPrefixedText();
256 $newTitle = $this->mNewPage->getPrefixedText();
257 if ( $oldTitle == $newTitle ) {
258 $wgOut->setPageTitle( $newTitle );
259 } else {
260 $wgOut->setPageTitle( $oldTitle . ', ' . $newTitle );
261 }
262 if ( $this->mNewPage->equals( $this->mOldPage ) ) {
263 $wgOut->setSubtitle( wfMsgExt( 'difference', array( 'parseinline' ) ) );
264 } else {
265 $wgOut->setSubtitle( wfMsgExt( 'difference-multipage', array( 'parseinline' ) ) );
266 }
267 $wgOut->setRobotPolicy( 'noindex,nofollow' );
268
269 if ( !$this->mOldPage->userCanRead() || !$this->mNewPage->userCanRead() ) {
270 $wgOut->loginToUse();
271 $wgOut->output();
272 $wgOut->disable();
273 wfProfileOut( __METHOD__ );
274 return;
275 }
276
277 $sk = $wgUser->getSkin();
278 if ( method_exists( $sk, 'suppressQuickbar' ) ) {
279 $sk->suppressQuickbar();
280 }
281
282 // Check if page is editable
283 $editable = $this->mNewRev->getTitle()->userCan( 'edit' );
284 if ( $editable && $this->mNewRev->isCurrent() && $wgUser->isAllowed( 'rollback' ) ) {
285 $wgOut->preventClickjacking();
286 $rollback = '&#160;&#160;&#160;' . $sk->generateRollback( $this->mNewRev );
287 } else {
288 $rollback = '';
289 }
290
291 // Prepare a change patrol link, if applicable
292 if ( $wgUseRCPatrol && $this->mTitle->userCan( 'patrol' ) ) {
293 // If we've been given an explicit change identifier, use it; saves time
294 if ( $this->mRcidMarkPatrolled ) {
295 $rcid = $this->mRcidMarkPatrolled;
296 $rc = RecentChange::newFromId( $rcid );
297 // Already patrolled?
298 $rcid = is_object( $rc ) && !$rc->getAttribute( 'rc_patrolled' ) ? $rcid : 0;
299 } else {
300 // Look for an unpatrolled change corresponding to this diff
301 $db = wfGetDB( DB_SLAVE );
302 $change = RecentChange::newFromConds(
303 array(
304 // Redundant user,timestamp condition so we can use the existing index
305 'rc_user_text' => $this->mNewRev->getRawUserText(),
306 'rc_timestamp' => $db->timestamp( $this->mNewRev->getTimestamp() ),
307 'rc_this_oldid' => $this->mNewid,
308 'rc_last_oldid' => $this->mOldid,
309 'rc_patrolled' => 0
310 ),
311 __METHOD__
312 );
313 if ( $change instanceof RecentChange ) {
314 $rcid = $change->mAttribs['rc_id'];
315 $this->mRcidMarkPatrolled = $rcid;
316 } else {
317 // None found
318 $rcid = 0;
319 }
320 }
321 // Build the link
322 if ( $rcid ) {
323 $wgOut->preventClickjacking();
324 $token = $wgUser->editToken( $rcid );
325 $patrol = ' <span class="patrollink">[' . $sk->link(
326 $this->mTitle,
327 wfMsgHtml( 'markaspatrolleddiff' ),
328 array(),
329 array(
330 'action' => 'markpatrolled',
331 'rcid' => $rcid,
332 'token' => $token,
333 ),
334 array(
335 'known',
336 'noclasses'
337 )
338 ) . ']</span>';
339 } else {
340 $patrol = '';
341 }
342 } else {
343 $patrol = '';
344 }
345
346 # Carry over 'diffonly' param via navigation links
347 if ( $diffOnly != $wgUser->getBoolOption( 'diffonly' ) ) {
348 $query['diffonly'] = $diffOnly;
349 }
350
351 # Make "previous revision link"
352 $query['diff'] = 'prev';
353 $query['oldid'] = $this->mOldid;
354 # Cascade unhide param in links for easy deletion browsing
355 if ( $this->unhide ) {
356 $query['unhide'] = 1;
357 }
358 if ( !$this->mOldRev->getPrevious() ) {
359 $prevlink = '&#160;';
360 } else {
361 $prevlink = $sk->link(
362 $this->mTitle,
363 wfMsgHtml( 'previousdiff' ),
364 array(
365 'id' => 'differences-prevlink'
366 ),
367 $query,
368 array(
369 'known',
370 'noclasses'
371 )
372 );
373 }
374
375 # Make "next revision link"
376 $query['diff'] = 'next';
377 $query['oldid'] = $this->mNewid;
378 # Skip next link on the top revision
379 if ( $this->mNewRev->isCurrent() ) {
380 $nextlink = '&#160;';
381 } else {
382 $nextlink = $sk->link(
383 $this->mTitle,
384 wfMsgHtml( 'nextdiff' ),
385 array(
386 'id' => 'differences-nextlink'
387 ),
388 $query,
389 array(
390 'known',
391 'noclasses'
392 )
393 );
394 }
395
396 $oldminor = '';
397 $newminor = '';
398
399 if ( $this->mOldRev->isMinor() ) {
400 $oldminor = ChangesList::flag( 'minor' );
401 }
402 if ( $this->mNewRev->isMinor() ) {
403 $newminor = ChangesList::flag( 'minor' );
404 }
405
406 # Handle RevisionDelete links...
407 $ldel = $this->revisionDeleteLink( $this->mOldRev );
408 $rdel = $this->revisionDeleteLink( $this->mNewRev );
409
410 $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $this->mOldtitle . '</strong></div>' .
411 '<div id="mw-diff-otitle2">' .
412 $sk->revUserTools( $this->mOldRev, !$this->unhide ) . '</div>' .
413 '<div id="mw-diff-otitle3">' . $oldminor .
414 $sk->revComment( $this->mOldRev, !$diffOnly, !$this->unhide ) . $ldel . '</div>' .
415 '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
416 $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $this->mNewtitle . '</strong></div>' .
417 '<div id="mw-diff-ntitle2">' . $sk->revUserTools( $this->mNewRev, !$this->unhide ) .
418 " $rollback</div>" .
419 '<div id="mw-diff-ntitle3">' . $newminor .
420 $sk->revComment( $this->mNewRev, !$diffOnly, !$this->unhide ) . $rdel . '</div>' .
421 '<div id="mw-diff-ntitle4">' . $nextlink . $patrol . '</div>';
422
423 # Check if this user can see the revisions
424 $allowed = $this->mOldRev->userCan( Revision::DELETED_TEXT )
425 && $this->mNewRev->userCan( Revision::DELETED_TEXT );
426 # Check if one of the revisions is deleted/suppressed
427 $deleted = $suppressed = false;
428 if ( $this->mOldRev->isDeleted( Revision::DELETED_TEXT ) ) {
429 $deleted = true; // old revisions text is hidden
430 if ( $this->mOldRev->isDeleted( Revision::DELETED_RESTRICTED ) )
431 $suppressed = true; // also suppressed
432 }
433 if ( $this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
434 $deleted = true; // new revisions text is hidden
435 if ( $this->mNewRev->isDeleted( Revision::DELETED_RESTRICTED ) )
436 $suppressed = true; // also suppressed
437 }
438 # If the diff cannot be shown due to a deleted revision, then output
439 # the diff header and links to unhide (if available)...
440 if ( $deleted && ( !$this->unhide || !$allowed ) ) {
441 $this->showDiffStyle();
442 $multi = $this->getMultiNotice();
443 $wgOut->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
444 if ( !$allowed ) {
445 $msg = $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff';
446 # Give explanation for why revision is not visible
447 $wgOut->wrapWikiMsg( "<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
448 array( $msg ) );
449 } else {
450 # Give explanation and add a link to view the diff...
451 $link = $this->mTitle->getFullUrl( array(
452 'diff' => $this->mNewid,
453 'oldid' => $this->mOldid,
454 'unhide' => 1
455 ) );
456 $msg = $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff';
457 $wgOut->wrapWikiMsg( "<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n", array( $msg, $link ) );
458 }
459 # Otherwise, output a regular diff...
460 } else {
461 # Add deletion notice if the user is viewing deleted content
462 $notice = '';
463 if ( $deleted ) {
464 $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
465 $notice = "<div id='mw-$msg' class='mw-warning plainlinks'>\n" . wfMsgExt( $msg, 'parseinline' ) . "</div>\n";
466 }
467 $this->showDiff( $oldHeader, $newHeader, $notice );
468 if ( !$diffOnly ) {
469 $this->renderNewRevision();
470 }
471 }
472 wfProfileOut( __METHOD__ );
473 }
474
475 /**
476 * @param $rev Revision
477 * @return String
478 */
479 protected function revisionDeleteLink( $rev ) {
480 global $wgUser;
481 $link = '';
482 $canHide = $wgUser->isAllowed( 'deleterevision' );
483 // Show del/undel link if:
484 // (a) the user can delete revisions, or
485 // (b) the user can view deleted revision *and* this one is deleted
486 if ( $canHide || ( $rev->getVisibility() && $wgUser->isAllowed( 'deletedhistory' ) ) ) {
487 $sk = $wgUser->getSkin();
488 if ( !$rev->userCan( Revision::DELETED_RESTRICTED ) ) {
489 $link = $sk->revDeleteLinkDisabled( $canHide ); // revision was hidden from sysops
490 } else {
491 $query = array(
492 'type' => 'revision',
493 'target' => $rev->mTitle->getPrefixedDbkey(),
494 'ids' => $rev->getId()
495 );
496 $link = $sk->revDeleteLink( $query,
497 $rev->isDeleted( Revision::DELETED_RESTRICTED ), $canHide );
498 }
499 $link = '&#160;&#160;&#160;' . $link . ' ';
500 }
501 return $link;
502 }
503
504 /**
505 * Show the new revision of the page.
506 */
507 function renderNewRevision() {
508 global $wgOut, $wgUser;
509 wfProfileIn( __METHOD__ );
510 # Add "current version as of X" title
511 $wgOut->addHTML( "<hr class='diff-hr' />
512 <h2 class='diff-currentversion-title'>{$this->mPagetitle}</h2>\n" );
513 # Page content may be handled by a hooked call instead...
514 if ( wfRunHooks( 'ArticleContentOnDiff', array( $this, $wgOut ) ) ) {
515 # Use the current version parser cache if applicable
516 $pCache = true;
517 if ( !$this->mNewRev->isCurrent() ) {
518 $oldEditSectionSetting = $wgOut->parserOptions()->setEditSection( false );
519 $pCache = false;
520 }
521
522 $this->loadNewText();
523 $wgOut->setRevisionId( $this->mNewRev->getId() );
524
525 if ( $this->mTitle->isCssJsSubpage() || $this->mTitle->isCssOrJsPage() ) {
526 // Stolen from Article::view --AG 2007-10-11
527 // Give hooks a chance to customise the output
528 // @TODO: standardize this crap into one function
529 if ( wfRunHooks( 'ShowRawCssJs', array( $this->mNewtext, $this->mTitle, $wgOut ) ) ) {
530 // Wrap the whole lot in a <pre> and don't parse
531 $m = array();
532 preg_match( '!\.(css|js)$!u', $this->mTitle->getText(), $m );
533 $wgOut->addHTML( "<pre class=\"mw-code mw-{$m[1]}\" dir=\"ltr\">\n" );
534 $wgOut->addHTML( htmlspecialchars( $this->mNewtext ) );
535 $wgOut->addHTML( "\n</pre>\n" );
536 }
537 } elseif ( $pCache ) {
538 $article = new Article( $this->mTitle, 0 );
539 $pOutput = ParserCache::singleton()->get( $article, $wgOut->parserOptions() );
540 if( $pOutput ) {
541 $wgOut->addParserOutput( $pOutput );
542 } else {
543 $article->doViewParse();
544 }
545 } else {
546 $wgOut->addWikiTextTidy( $this->mNewtext );
547 }
548
549 if ( !$this->mNewRev->isCurrent() ) {
550 $wgOut->parserOptions()->setEditSection( $oldEditSectionSetting );
551 }
552 }
553 # Add redundant patrol link on bottom...
554 if ( $this->mRcidMarkPatrolled && $this->mTitle->quickUserCan( 'patrol' ) ) {
555 $sk = $wgUser->getSkin();
556 $token = $wgUser->editToken( $this->mRcidMarkPatrolled );
557 $wgOut->preventClickjacking();
558 $wgOut->addHTML(
559 "<div class='patrollink'>[" . $sk->link(
560 $this->mTitle,
561 wfMsgHtml( 'markaspatrolleddiff' ),
562 array(),
563 array(
564 'action' => 'markpatrolled',
565 'rcid' => $this->mRcidMarkPatrolled,
566 'token' => $token,
567 )
568 ) . ']</div>'
569 );
570 }
571
572 wfProfileOut( __METHOD__ );
573 }
574
575 /**
576 * Show the first revision of an article. Uses normal diff headers in
577 * contrast to normal "old revision" display style.
578 */
579 function showFirstRevision() {
580 global $wgOut, $wgUser;
581 wfProfileIn( __METHOD__ );
582
583 # Get article text from the DB
584 #
585 if ( ! $this->loadNewText() ) {
586 $t = $this->mTitle->getPrefixedText();
587 $d = wfMsgExt( 'missingarticle-diff', array( 'escape' ),
588 $this->deletedIdMarker( $this->mOldid ),
589 $this->deletedIdMarker( $this->mNewid ) );
590 $wgOut->setPagetitle( wfMsg( 'errorpagetitle' ) );
591 $wgOut->addWikiMsg( 'missing-article', "<nowiki>$t</nowiki>", "<span class='plainlinks'>$d</span>" );
592 wfProfileOut( __METHOD__ );
593 return;
594 }
595 if ( $this->mNewRev->isCurrent() ) {
596 $wgOut->setArticleFlag( true );
597 }
598
599 # Check if user is allowed to look at this page. If not, bail out.
600 #
601 if ( !$this->mTitle->userCanRead() ) {
602 $wgOut->loginToUse();
603 $wgOut->output();
604 wfProfileOut( __METHOD__ );
605 throw new MWException( "Permission Error: you do not have access to view this page" );
606 }
607
608 # Prepare the header box
609 #
610 $sk = $wgUser->getSkin();
611
612 $next = $this->mTitle->getNextRevisionID( $this->mNewid );
613 if ( !$next ) {
614 $nextlink = '';
615 } else {
616 $nextlink = '<br />' . $sk->link(
617 $this->mTitle,
618 wfMsgHtml( 'nextdiff' ),
619 array(
620 'id' => 'differences-nextlink'
621 ),
622 array(
623 'diff' => 'next',
624 'oldid' => $this->mNewid,
625 ),
626 array(
627 'known',
628 'noclasses'
629 )
630 );
631 }
632 $header = "<div class=\"firstrevisionheader\" style=\"text-align: center\">" .
633 $sk->revUserTools( $this->mNewRev ) . "<br />" . $sk->revComment( $this->mNewRev ) . $nextlink . "</div>\n";
634
635 $wgOut->addHTML( $header );
636
637 $wgOut->setSubtitle( wfMsgExt( 'difference', array( 'parseinline' ) ) );
638 $wgOut->setRobotPolicy( 'noindex,nofollow' );
639
640 wfProfileOut( __METHOD__ );
641 }
642
643 /**
644 * Get the diff text, send it to $wgOut
645 * Returns false if the diff could not be generated, otherwise returns true
646 *
647 * @return bool
648 */
649 function showDiff( $otitle, $ntitle, $notice = '' ) {
650 global $wgOut;
651 $diff = $this->getDiff( $otitle, $ntitle, $notice );
652 if ( $diff === false ) {
653 $wgOut->addWikiMsg( 'missing-article', "<nowiki>(fixme, bug)</nowiki>", '' );
654 return false;
655 } else {
656 $this->showDiffStyle();
657 $wgOut->addHTML( $diff );
658 return true;
659 }
660 }
661
662 /**
663 * Add style sheets and supporting JS for diff display.
664 */
665 function showDiffStyle() {
666 global $wgOut;
667 $wgOut->addModuleStyles( 'mediawiki.action.history.diff' );
668 }
669
670 /**
671 * Get complete diff table, including header
672 *
673 * @param $otitle Title: old title
674 * @param $ntitle Title: new title
675 * @param $notice String: HTML between diff header and body
676 * @return mixed
677 */
678 function getDiff( $otitle, $ntitle, $notice = '' ) {
679 $body = $this->getDiffBody();
680 if ( $body === false ) {
681 return false;
682 } else {
683 $multi = $this->getMultiNotice();
684 return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
685 }
686 }
687
688 /**
689 * Get the diff table body, without header
690 *
691 * @return mixed (string/false)
692 */
693 public function getDiffBody() {
694 global $wgMemc;
695 wfProfileIn( __METHOD__ );
696 $this->mCacheHit = true;
697 // Check if the diff should be hidden from this user
698 if ( !$this->loadRevisionData() ) {
699 wfProfileOut( __METHOD__ );
700 return false;
701 } elseif ( $this->mOldRev && !$this->mOldRev->userCan( Revision::DELETED_TEXT ) ) {
702 wfProfileOut( __METHOD__ );
703 return false;
704 } elseif ( $this->mNewRev && !$this->mNewRev->userCan( Revision::DELETED_TEXT ) ) {
705 wfProfileOut( __METHOD__ );
706 return false;
707 }
708 // Short-circuit
709 if ( $this->mOldRev && $this->mNewRev
710 && $this->mOldRev->getID() == $this->mNewRev->getID() )
711 {
712 wfProfileOut( __METHOD__ );
713 return '';
714 }
715 // Cacheable?
716 $key = false;
717 if ( $this->mOldid && $this->mNewid ) {
718 $key = wfMemcKey( 'diff', 'version', MW_DIFF_VERSION,
719 'oldid', $this->mOldid, 'newid', $this->mNewid );
720 // Try cache
721 if ( !$this->mRefreshCache ) {
722 $difftext = $wgMemc->get( $key );
723 if ( $difftext ) {
724 wfIncrStats( 'diff_cache_hit' );
725 $difftext = $this->localiseLineNumbers( $difftext );
726 $difftext .= "\n<!-- diff cache key $key -->\n";
727 wfProfileOut( __METHOD__ );
728 return $difftext;
729 }
730 } // don't try to load but save the result
731 }
732 $this->mCacheHit = false;
733
734 // Loadtext is permission safe, this just clears out the diff
735 if ( !$this->loadText() ) {
736 wfProfileOut( __METHOD__ );
737 return false;
738 }
739
740 $difftext = $this->generateDiffBody( $this->mOldtext, $this->mNewtext );
741
742 // Save to cache for 7 days
743 if ( !wfRunHooks( 'AbortDiffCache', array( &$this ) ) ) {
744 wfIncrStats( 'diff_uncacheable' );
745 } elseif ( $key !== false && $difftext !== false ) {
746 wfIncrStats( 'diff_cache_miss' );
747 $wgMemc->set( $key, $difftext, 7 * 86400 );
748 } else {
749 wfIncrStats( 'diff_uncacheable' );
750 }
751 // Replace line numbers with the text in the user's language
752 if ( $difftext !== false ) {
753 $difftext = $this->localiseLineNumbers( $difftext );
754 }
755 wfProfileOut( __METHOD__ );
756 return $difftext;
757 }
758
759 /**
760 * Make sure the proper modules are loaded before we try to
761 * make the diff
762 */
763 private function initDiffEngines() {
764 global $wgExternalDiffEngine;
765 if ( $wgExternalDiffEngine == 'wikidiff' && !function_exists( 'wikidiff_do_diff' ) ) {
766 wfProfileIn( __METHOD__ . '-php_wikidiff.so' );
767 wfDl( 'php_wikidiff' );
768 wfProfileOut( __METHOD__ . '-php_wikidiff.so' );
769 }
770 elseif ( $wgExternalDiffEngine == 'wikidiff2' && !function_exists( 'wikidiff2_do_diff' ) ) {
771 wfProfileIn( __METHOD__ . '-php_wikidiff2.so' );
772 wfDl( 'wikidiff2' );
773 wfProfileOut( __METHOD__ . '-php_wikidiff2.so' );
774 }
775 }
776
777 /**
778 * Generate a diff, no caching
779 *
780 * @param $otext String: old text, must be already segmented
781 * @param $ntext String: new text, must be already segmented
782 */
783 function generateDiffBody( $otext, $ntext ) {
784 global $wgExternalDiffEngine, $wgContLang;
785
786 wfProfileIn( __METHOD__ );
787
788 $otext = str_replace( "\r\n", "\n", $otext );
789 $ntext = str_replace( "\r\n", "\n", $ntext );
790
791 $this->initDiffEngines();
792
793 if ( $wgExternalDiffEngine == 'wikidiff' && function_exists( 'wikidiff_do_diff' ) ) {
794 # For historical reasons, external diff engine expects
795 # input text to be HTML-escaped already
796 $otext = htmlspecialchars ( $wgContLang->segmentForDiff( $otext ) );
797 $ntext = htmlspecialchars ( $wgContLang->segmentForDiff( $ntext ) );
798 wfProfileOut( __METHOD__ );
799 return $wgContLang->unsegmentForDiff( wikidiff_do_diff( $otext, $ntext, 2 ) ) .
800 $this->debug( 'wikidiff1' );
801 }
802
803 if ( $wgExternalDiffEngine == 'wikidiff2' && function_exists( 'wikidiff2_do_diff' ) ) {
804 # Better external diff engine, the 2 may some day be dropped
805 # This one does the escaping and segmenting itself
806 wfProfileIn( 'wikidiff2_do_diff' );
807 $text = wikidiff2_do_diff( $otext, $ntext, 2 );
808 $text .= $this->debug( 'wikidiff2' );
809 wfProfileOut( 'wikidiff2_do_diff' );
810 wfProfileOut( __METHOD__ );
811 return $text;
812 }
813 if ( $wgExternalDiffEngine != 'wikidiff3' && $wgExternalDiffEngine !== false ) {
814 # Diff via the shell
815 global $wgTmpDirectory;
816 $tempName1 = tempnam( $wgTmpDirectory, 'diff_' );
817 $tempName2 = tempnam( $wgTmpDirectory, 'diff_' );
818
819 $tempFile1 = fopen( $tempName1, "w" );
820 if ( !$tempFile1 ) {
821 wfProfileOut( __METHOD__ );
822 return false;
823 }
824 $tempFile2 = fopen( $tempName2, "w" );
825 if ( !$tempFile2 ) {
826 wfProfileOut( __METHOD__ );
827 return false;
828 }
829 fwrite( $tempFile1, $otext );
830 fwrite( $tempFile2, $ntext );
831 fclose( $tempFile1 );
832 fclose( $tempFile2 );
833 $cmd = wfEscapeShellArg( $wgExternalDiffEngine, $tempName1, $tempName2 );
834 wfProfileIn( __METHOD__ . "-shellexec" );
835 $difftext = wfShellExec( $cmd );
836 $difftext .= $this->debug( "external $wgExternalDiffEngine" );
837 wfProfileOut( __METHOD__ . "-shellexec" );
838 unlink( $tempName1 );
839 unlink( $tempName2 );
840 wfProfileOut( __METHOD__ );
841 return $difftext;
842 }
843
844 # Native PHP diff
845 $ota = explode( "\n", $wgContLang->segmentForDiff( $otext ) );
846 $nta = explode( "\n", $wgContLang->segmentForDiff( $ntext ) );
847 $diffs = new Diff( $ota, $nta );
848 $formatter = new TableDiffFormatter();
849 $difftext = $wgContLang->unsegmentForDiff( $formatter->format( $diffs ) ) .
850 wfProfileOut( __METHOD__ );
851 return $difftext;
852 }
853
854 /**
855 * Generate a debug comment indicating diff generating time,
856 * server node, and generator backend.
857 */
858 protected function debug( $generator = "internal" ) {
859 global $wgShowHostnames;
860 if ( !$this->enableDebugComment ) {
861 return '';
862 }
863 $data = array( $generator );
864 if ( $wgShowHostnames ) {
865 $data[] = wfHostname();
866 }
867 $data[] = wfTimestamp( TS_DB );
868 return "<!-- diff generator: " .
869 implode( " ",
870 array_map(
871 "htmlspecialchars",
872 $data ) ) .
873 " -->\n";
874 }
875
876 /**
877 * Replace line numbers with the text in the user's language
878 */
879 function localiseLineNumbers( $text ) {
880 return preg_replace_callback( '/<!--LINE (\d+)-->/',
881 array( &$this, 'localiseLineNumbersCb' ), $text );
882 }
883
884 function localiseLineNumbersCb( $matches ) {
885 global $wgLang;
886 if ( $matches[1] === '1' && $this->mReducedLineNumbers ) return '';
887 return wfMsgExt( 'lineno', 'escape', $wgLang->formatNum( $matches[1] ) );
888 }
889
890
891 /**
892 * If there are revisions between the ones being compared, return a note saying so.
893 * @return string
894 */
895 function getMultiNotice() {
896 if ( !is_object( $this->mOldRev ) || !is_object( $this->mNewRev ) ) {
897 return '';
898 } elseif ( !$this->mOldPage->equals( $this->mNewPage ) ) {
899 // Comparing two different pages? Count would be meaningless.
900 return '';
901 }
902
903 if ( $this->mOldRev->getTimestamp() > $this->mNewRev->getTimestamp() ) {
904 $oldRev = $this->mNewRev; // flip
905 $newRev = $this->mOldRev; // flip
906 } else { // normal case
907 $oldRev = $this->mOldRev;
908 $newRev = $this->mNewRev;
909 }
910
911 $nEdits = $this->mTitle->countRevisionsBetween( $oldRev, $newRev );
912 if ( $nEdits > 0 ) {
913 $limit = 100; // use diff-multi-manyusers if too many users
914 $numUsers = $this->mTitle->countAuthorsBetween( $oldRev, $newRev, $limit );
915 return self::intermediateEditsMsg( $nEdits, $numUsers, $limit );
916 }
917 return ''; // nothing
918 }
919
920 /**
921 * Get a notice about how many intermediate edits and users there are
922 * @param $numEdits int
923 * @param $numUsers int
924 * @param $limit int
925 * @return string
926 */
927 public static function intermediateEditsMsg( $numEdits, $numUsers, $limit ) {
928 global $wgLang;
929 if ( $numUsers > $limit ) {
930 $msg = 'diff-multi-manyusers';
931 $numUsers = $limit;
932 } else {
933 $msg = 'diff-multi';
934 }
935 return wfMsgExt( $msg, 'parseinline',
936 $wgLang->formatnum( $numEdits ), $wgLang->formatnum( $numUsers ) );
937 }
938
939 /**
940 * Add the header to a diff body
941 *
942 * @return string
943 */
944 function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
945 // shared.css sets diff in interface language/dir, but the actual content
946 // is often in a different language, mostly the page content language/dir
947 $tableClass = 'diff diff-contentalign-' . htmlspecialchars( $this->mDiffLang->alignStart() );
948 $header = "<table class='$tableClass'>";
949 if ( $diff ) { // Safari/Chrome show broken output if cols not used
950 $header .= "
951 <col class='diff-marker' />
952 <col class='diff-content' />
953 <col class='diff-marker' />
954 <col class='diff-content' />";
955 $colspan = 2;
956 $multiColspan = 4;
957 } else {
958 $colspan = 1;
959 $multiColspan = 2;
960 }
961 $header .= "
962 <tr valign='top'>
963 <td colspan='$colspan' class='diff-otitle'>{$otitle}</td>
964 <td colspan='$colspan' class='diff-ntitle'>{$ntitle}</td>
965 </tr>";
966
967 if ( $multi != '' ) {
968 $header .= "<tr><td colspan='{$multiColspan}' align='center' class='diff-multi'>{$multi}</td></tr>";
969 }
970 if ( $notice != '' ) {
971 $header .= "<tr><td colspan='{$multiColspan}' align='center'>{$notice}</td></tr>";
972 }
973
974 return $header . $diff . "</table>";
975 }
976
977 /**
978 * Use specified text instead of loading from the database
979 */
980 function setText( $oldText, $newText ) {
981 $this->mOldtext = $oldText;
982 $this->mNewtext = $newText;
983 $this->mTextLoaded = 2;
984 $this->mRevisionsLoaded = true;
985 }
986
987 /**
988 * Set the language in which the diff text is written
989 * (Defaults to page content language).
990 * @since 1.19
991 */
992 function setDiffLang( $lang ) {
993 $this->mDiffLang = wfGetLangObj( $lang );
994 }
995
996 /**
997 * Load revision metadata for the specified articles. If newid is 0, then compare
998 * the old article in oldid to the current article; if oldid is 0, then
999 * compare the current article to the immediately previous one (ignoring the
1000 * value of newid).
1001 *
1002 * If oldid is false, leave the corresponding revision object set
1003 * to false. This is impossible via ordinary user input, and is provided for
1004 * API convenience.
1005 *
1006 * @return bool
1007 */
1008 function loadRevisionData() {
1009 global $wgLang, $wgUser;
1010 if ( $this->mRevisionsLoaded ) {
1011 return true;
1012 } else {
1013 // Whether it succeeds or fails, we don't want to try again
1014 $this->mRevisionsLoaded = true;
1015 }
1016
1017 // Load the new revision object
1018 $this->mNewRev = $this->mNewid
1019 ? Revision::newFromId( $this->mNewid )
1020 : Revision::newFromTitle( $this->mTitle );
1021 if ( !$this->mNewRev instanceof Revision ) {
1022 return false;
1023 }
1024
1025 // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
1026 $this->mNewid = $this->mNewRev->getId();
1027
1028 // Check if page is editable
1029 $editable = $this->mNewRev->getTitle()->userCan( 'edit' );
1030
1031 // Set assorted variables
1032 $timestamp = $wgLang->timeanddate( $this->mNewRev->getTimestamp(), true );
1033 $dateofrev = $wgLang->date( $this->mNewRev->getTimestamp(), true );
1034 $timeofrev = $wgLang->time( $this->mNewRev->getTimestamp(), true );
1035 $this->mNewPage = $this->mNewRev->getTitle();
1036 if ( $this->mNewRev->isCurrent() ) {
1037 $newLink = $this->mNewPage->escapeLocalUrl( array(
1038 'oldid' => $this->mNewid
1039 ) );
1040 $this->mPagetitle = htmlspecialchars( wfMsg(
1041 'currentrev-asof',
1042 $timestamp,
1043 $dateofrev,
1044 $timeofrev
1045 ) );
1046 $newEdit = $this->mNewPage->escapeLocalUrl( array(
1047 'action' => 'edit'
1048 ) );
1049
1050 $this->mNewtitle = "<a href='$newLink'>{$this->mPagetitle}</a>";
1051 $this->mNewtitle .= " (<a href='$newEdit'>" . wfMsgHtml( $editable ? 'editold' : 'viewsourceold' ) . "</a>)";
1052 } else {
1053 $newLink = $this->mNewPage->escapeLocalUrl( array(
1054 'oldid' => $this->mNewid
1055 ) );
1056 $newEdit = $this->mNewPage->escapeLocalUrl( array(
1057 'action' => 'edit',
1058 'oldid' => $this->mNewid
1059 ) );
1060 $this->mPagetitle = htmlspecialchars( wfMsg(
1061 'revisionasof',
1062 $timestamp,
1063 $dateofrev,
1064 $timeofrev
1065 ) );
1066
1067 $this->mNewtitle = "<a href='$newLink'>{$this->mPagetitle}</a>";
1068 $this->mNewtitle .= " (<a href='$newEdit'>" . wfMsgHtml( $editable ? 'editold' : 'viewsourceold' ) . "</a>)";
1069 }
1070 if ( !$this->mNewRev->userCan( Revision::DELETED_TEXT ) ) {
1071 $this->mNewtitle = "<span class='history-deleted'>{$this->mPagetitle}</span>";
1072 } elseif ( $this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
1073 $this->mNewtitle = "<span class='history-deleted'>{$this->mNewtitle}</span>";
1074 }
1075
1076 // Load the old revision object
1077 $this->mOldRev = false;
1078 if ( $this->mOldid ) {
1079 $this->mOldRev = Revision::newFromId( $this->mOldid );
1080 } elseif ( $this->mOldid === 0 ) {
1081 $rev = $this->mNewRev->getPrevious();
1082 if ( $rev ) {
1083 $this->mOldid = $rev->getId();
1084 $this->mOldRev = $rev;
1085 } else {
1086 // No previous revision; mark to show as first-version only.
1087 $this->mOldid = false;
1088 $this->mOldRev = false;
1089 }
1090 } /* elseif ( $this->mOldid === false ) leave mOldRev false; */
1091
1092 if ( is_null( $this->mOldRev ) ) {
1093 return false;
1094 }
1095
1096 if ( $this->mOldRev ) {
1097 $this->mOldPage = $this->mOldRev->getTitle();
1098
1099 $t = $wgLang->timeanddate( $this->mOldRev->getTimestamp(), true );
1100 $dateofrev = $wgLang->date( $this->mOldRev->getTimestamp(), true );
1101 $timeofrev = $wgLang->time( $this->mOldRev->getTimestamp(), true );
1102 $oldLink = $this->mOldPage->escapeLocalUrl( array(
1103 'oldid' => $this->mOldid
1104 ) );
1105 $oldEdit = $this->mOldPage->escapeLocalUrl( array(
1106 'action' => 'edit',
1107 'oldid' => $this->mOldid
1108 ) );
1109 $this->mOldPagetitle = htmlspecialchars( wfMsg( 'revisionasof', $t, $dateofrev, $timeofrev ) );
1110
1111 $this->mOldtitle = "<a href='$oldLink'>{$this->mOldPagetitle}</a>"
1112 . " (<a href='$oldEdit'>" . wfMsgHtml( $editable ? 'editold' : 'viewsourceold' ) . "</a>)";
1113 // Add an "undo" link
1114 if ( $editable && !$this->mOldRev->isDeleted( Revision::DELETED_TEXT ) && !$this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
1115 $undoLink = Html::element( 'a', array(
1116 'href' => $this->mNewPage->getLocalUrl( array(
1117 'action' => 'edit',
1118 'undoafter' => $this->mOldid,
1119 'undo' => $this->mNewid ) ),
1120 'title' => $wgUser->getSkin()->titleAttrib( 'undo' )
1121 ), wfMsg( 'editundo' ) );
1122 $this->mNewtitle .= ' (' . $undoLink . ')';
1123 }
1124
1125 if ( !$this->mOldRev->userCan( Revision::DELETED_TEXT ) ) {
1126 $this->mOldtitle = '<span class="history-deleted">' . $this->mOldPagetitle . '</span>';
1127 } elseif ( $this->mOldRev->isDeleted( Revision::DELETED_TEXT ) ) {
1128 $this->mOldtitle = '<span class="history-deleted">' . $this->mOldtitle . '</span>';
1129 }
1130 }
1131
1132 return true;
1133 }
1134
1135 /**
1136 * Load the text of the revisions, as well as revision data.
1137 *
1138 * @return bool
1139 */
1140 function loadText() {
1141 if ( $this->mTextLoaded == 2 ) {
1142 return true;
1143 } else {
1144 // Whether it succeeds or fails, we don't want to try again
1145 $this->mTextLoaded = 2;
1146 }
1147
1148 if ( !$this->loadRevisionData() ) {
1149 return false;
1150 }
1151 if ( $this->mOldRev ) {
1152 $this->mOldtext = $this->mOldRev->getText( Revision::FOR_THIS_USER );
1153 if ( $this->mOldtext === false ) {
1154 return false;
1155 }
1156 }
1157 if ( $this->mNewRev ) {
1158 $this->mNewtext = $this->mNewRev->getText( Revision::FOR_THIS_USER );
1159 if ( $this->mNewtext === false ) {
1160 return false;
1161 }
1162 }
1163 return true;
1164 }
1165
1166 /**
1167 * Load the text of the new revision, not the old one
1168 *
1169 * @return bool
1170 */
1171 function loadNewText() {
1172 if ( $this->mTextLoaded >= 1 ) {
1173 return true;
1174 } else {
1175 $this->mTextLoaded = 1;
1176 }
1177 if ( !$this->loadRevisionData() ) {
1178 return false;
1179 }
1180 $this->mNewtext = $this->mNewRev->getText( Revision::FOR_THIS_USER );
1181 return true;
1182 }
1183 }