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