Completed
Branch master (4b8315)
by
unknown
17:52
created

includes/diff/DifferenceEngine.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * User interface for the difference engine.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @ingroup DifferenceEngine
22
 */
23
24
/** @deprecated use class constant instead */
25
define( 'MW_DIFF_VERSION', '1.11a' );
26
27
/**
28
 * @todo document
29
 * @ingroup DifferenceEngine
30
 */
31
class DifferenceEngine extends ContextSource {
32
	/**
33
	 * Constant to indicate diff cache compatibility.
34
	 * Bump this when changing the diff formatting in a way that
35
	 * fixes important bugs or such to force cached diff views to
36
	 * clear.
37
	 */
38
	const DIFF_VERSION = MW_DIFF_VERSION;
0 ignored issues
show
Deprecated Code introduced by
The constant MW_DIFF_VERSION has been deprecated with message: use class constant instead

This class constant has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
39
40
	/** @var int */
41
	public $mOldid;
42
43
	/** @var int */
44
	public $mNewid;
45
46
	private $mOldTags;
47
	private $mNewTags;
48
49
	/** @var Content */
50
	public $mOldContent;
51
52
	/** @var Content */
53
	public $mNewContent;
54
55
	/** @var Language */
56
	protected $mDiffLang;
57
58
	/** @var Title */
59
	public $mOldPage;
60
61
	/** @var Title */
62
	public $mNewPage;
63
64
	/** @var Revision */
65
	public $mOldRev;
66
67
	/** @var Revision */
68
	public $mNewRev;
69
70
	/** @var bool Have the revisions IDs been loaded */
71
	private $mRevisionsIdsLoaded = false;
72
73
	/** @var bool Have the revisions been loaded */
74
	public $mRevisionsLoaded = false;
75
76
	/** @var int How many text blobs have been loaded, 0, 1 or 2? */
77
	public $mTextLoaded = 0;
78
79
	/** @var bool Was the diff fetched from cache? */
80
	public $mCacheHit = false;
81
82
	/**
83
	 * Set this to true to add debug info to the HTML output.
84
	 * Warning: this may cause RSS readers to spuriously mark articles as "new"
85
	 * (bug 20601)
86
	 */
87
	public $enableDebugComment = false;
88
89
	/** @var bool If true, line X is not displayed when X is 1, for example
90
	 *    to increase readability and conserve space with many small diffs.
91
	 */
92
	protected $mReducedLineNumbers = false;
93
94
	/** @var string Link to action=markpatrolled */
95
	protected $mMarkPatrolledLink = null;
96
97
	/** @var bool Show rev_deleted content if allowed */
98
	protected $unhide = false;
99
100
	/** @var bool Refresh the diff cache */
101
	protected $mRefreshCache = false;
102
103
	/**#@-*/
104
105
	/**
106
	 * Constructor
107
	 * @param IContextSource $context Context to use, anything else will be ignored
108
	 * @param int $old Old ID we want to show and diff with.
109
	 * @param string|int $new Either revision ID or 'prev' or 'next'. Default: 0.
110
	 * @param int $rcid Deprecated, no longer used!
111
	 * @param bool $refreshCache If set, refreshes the diff cache
112
	 * @param bool $unhide If set, allow viewing deleted revs
113
	 */
114
	public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
115
		$refreshCache = false, $unhide = false
116
	) {
117
		if ( $context instanceof IContextSource ) {
118
			$this->setContext( $context );
119
		}
120
121
		wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'\n" );
122
123
		$this->mOldid = $old;
124
		$this->mNewid = $new;
125
		$this->mRefreshCache = $refreshCache;
126
		$this->unhide = $unhide;
127
	}
128
129
	/**
130
	 * @param bool $value
131
	 */
132
	public function setReducedLineNumbers( $value = true ) {
133
		$this->mReducedLineNumbers = $value;
134
	}
135
136
	/**
137
	 * @return Language
138
	 */
139
	public function getDiffLang() {
140
		if ( $this->mDiffLang === null ) {
141
			# Default language in which the diff text is written.
142
			$this->mDiffLang = $this->getTitle()->getPageLanguage();
143
		}
144
145
		return $this->mDiffLang;
146
	}
147
148
	/**
149
	 * @return bool
150
	 */
151
	public function wasCacheHit() {
152
		return $this->mCacheHit;
153
	}
154
155
	/**
156
	 * @return int
157
	 */
158
	public function getOldid() {
159
		$this->loadRevisionIds();
160
161
		return $this->mOldid;
162
	}
163
164
	/**
165
	 * @return bool|int
166
	 */
167
	public function getNewid() {
168
		$this->loadRevisionIds();
169
170
		return $this->mNewid;
171
	}
172
173
	/**
174
	 * Look up a special:Undelete link to the given deleted revision id,
175
	 * as a workaround for being unable to load deleted diffs in currently.
176
	 *
177
	 * @param int $id Revision ID
178
	 *
179
	 * @return string|bool Link HTML or false
180
	 */
181
	public function deletedLink( $id ) {
182
		if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
183
			$dbr = wfGetDB( DB_REPLICA );
184
			$row = $dbr->selectRow( 'archive', '*',
185
				[ 'ar_rev_id' => $id ],
186
				__METHOD__ );
187
			if ( $row ) {
188
				$rev = Revision::newFromArchiveRow( $row );
189
				$title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
190
191
				return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
192
					'target' => $title->getPrefixedText(),
193
					'timestamp' => $rev->getTimestamp()
194
				] );
195
			}
196
		}
197
198
		return false;
199
	}
200
201
	/**
202
	 * Build a wikitext link toward a deleted revision, if viewable.
203
	 *
204
	 * @param int $id Revision ID
205
	 *
206
	 * @return string Wikitext fragment
207
	 */
208
	public function deletedIdMarker( $id ) {
209
		$link = $this->deletedLink( $id );
210
		if ( $link ) {
211
			return "[$link $id]";
212
		} else {
213
			return $id;
214
		}
215
	}
216
217
	private function showMissingRevision() {
218
		$out = $this->getOutput();
219
220
		$missing = [];
221
		if ( $this->mOldRev === null ||
222
			( $this->mOldRev && $this->mOldContent === null )
223
		) {
224
			$missing[] = $this->deletedIdMarker( $this->mOldid );
225
		}
226
		if ( $this->mNewRev === null ||
227
			( $this->mNewRev && $this->mNewContent === null )
228
		) {
229
			$missing[] = $this->deletedIdMarker( $this->mNewid );
230
		}
231
232
		$out->setPageTitle( $this->msg( 'errorpagetitle' ) );
233
		$msg = $this->msg( 'difference-missing-revision' )
234
			->params( $this->getLanguage()->listToText( $missing ) )
235
			->numParams( count( $missing ) )
236
			->parseAsBlock();
237
		$out->addHTML( $msg );
238
	}
239
240
	public function showDiffPage( $diffOnly = false ) {
241
		# Allow frames except in certain special cases
242
		$out = $this->getOutput();
243
		$out->allowClickjacking();
244
		$out->setRobotPolicy( 'noindex,nofollow' );
245
246
		// Allow extensions to add any extra output here
247
		Hooks::run( 'DifferenceEngineShowDiffPage', [ $out ] );
248
249
		if ( !$this->loadRevisionData() ) {
250
			$this->showMissingRevision();
251
252
			return;
253
		}
254
255
		$user = $this->getUser();
256
		$permErrors = $this->mNewPage->getUserPermissionsErrors( 'read', $user );
257
		if ( $this->mOldPage ) { # mOldPage might not be set, see below.
258
			$permErrors = wfMergeErrorArrays( $permErrors,
259
				$this->mOldPage->getUserPermissionsErrors( 'read', $user ) );
260
		}
261
		if ( count( $permErrors ) ) {
262
			throw new PermissionsError( 'read', $permErrors );
263
		}
264
265
		$rollback = '';
266
267
		$query = [];
268
		# Carry over 'diffonly' param via navigation links
269
		if ( $diffOnly != $user->getBoolOption( 'diffonly' ) ) {
270
			$query['diffonly'] = $diffOnly;
271
		}
272
		# Cascade unhide param in links for easy deletion browsing
273
		if ( $this->unhide ) {
274
			$query['unhide'] = 1;
275
		}
276
277
		# Check if one of the revisions is deleted/suppressed
278
		$deleted = $suppressed = false;
279
		$allowed = $this->mNewRev->userCan( Revision::DELETED_TEXT, $user );
280
281
		$revisionTools = [];
282
283
		# mOldRev is false if the difference engine is called with a "vague" query for
284
		# a diff between a version V and its previous version V' AND the version V
285
		# is the first version of that article. In that case, V' does not exist.
286
		if ( $this->mOldRev === false ) {
287
			$out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
288
			$samePage = true;
289
			$oldHeader = '';
290
			// Allow extensions to change the $oldHeader variable
291
			Hooks::run( 'DifferenceEngineOldHeaderNoOldRev', [ &$oldHeader ] );
292
		} else {
293
			Hooks::run( 'DiffViewHeader', [ $this, $this->mOldRev, $this->mNewRev ] );
294
295
			if ( $this->mNewPage->equals( $this->mOldPage ) ) {
296
				$out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
297
				$samePage = true;
298
			} else {
299
				$out->setPageTitle( $this->msg( 'difference-title-multipage',
300
					$this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
301
				$out->addSubtitle( $this->msg( 'difference-multipage' ) );
302
				$samePage = false;
303
			}
304
305
			if ( $samePage && $this->mNewPage->quickUserCan( 'edit', $user ) ) {
306
				if ( $this->mNewRev->isCurrent() && $this->mNewPage->userCan( 'rollback', $user ) ) {
307
					$rollbackLink = Linker::generateRollback( $this->mNewRev, $this->getContext() );
308
					if ( $rollbackLink ) {
309
						$out->preventClickjacking();
310
						$rollback = '&#160;&#160;&#160;' . $rollbackLink;
311
					}
312
				}
313
314
				if ( !$this->mOldRev->isDeleted( Revision::DELETED_TEXT ) &&
315
					!$this->mNewRev->isDeleted( Revision::DELETED_TEXT )
316
				) {
317
					$undoLink = Html::element( 'a', [
318
							'href' => $this->mNewPage->getLocalURL( [
319
								'action' => 'edit',
320
								'undoafter' => $this->mOldid,
321
								'undo' => $this->mNewid
322
							] ),
323
							'title' => Linker::titleAttrib( 'undo' ),
324
						],
325
						$this->msg( 'editundo' )->text()
326
					);
327
					$revisionTools['mw-diff-undo'] = $undoLink;
328
				}
329
			}
330
331
			# Make "previous revision link"
332 View Code Duplication
			if ( $samePage && $this->mOldRev->getPrevious() ) {
333
				$prevlink = Linker::linkKnown(
334
					$this->mOldPage,
335
					$this->msg( 'previousdiff' )->escaped(),
336
					[ 'id' => 'differences-prevlink' ],
337
					[ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query
338
				);
339
			} else {
340
				$prevlink = '&#160;';
341
			}
342
343
			if ( $this->mOldRev->isMinor() ) {
344
				$oldminor = ChangesList::flag( 'minor' );
345
			} else {
346
				$oldminor = '';
347
			}
348
349
			$ldel = $this->revisionDeleteLink( $this->mOldRev );
350
			$oldRevisionHeader = $this->getRevisionHeader( $this->mOldRev, 'complete' );
351
			$oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
352
353
			$oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
354
				'<div id="mw-diff-otitle2">' .
355
				Linker::revUserTools( $this->mOldRev, !$this->unhide ) . '</div>' .
356
				'<div id="mw-diff-otitle3">' . $oldminor .
357
				Linker::revComment( $this->mOldRev, !$diffOnly, !$this->unhide ) . $ldel . '</div>' .
358
				'<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
359
				'<div id="mw-diff-otitle4">' . $prevlink . '</div>';
360
361
			// Allow extensions to change the $oldHeader variable
362
			Hooks::run( 'DifferenceEngineOldHeader', [ $this, &$oldHeader, $prevlink, $oldminor,
363
				$diffOnly, $ldel, $this->unhide ] );
364
365 View Code Duplication
			if ( $this->mOldRev->isDeleted( Revision::DELETED_TEXT ) ) {
366
				$deleted = true; // old revisions text is hidden
367
				if ( $this->mOldRev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
368
					$suppressed = true; // also suppressed
369
				}
370
			}
371
372
			# Check if this user can see the revisions
373
			if ( !$this->mOldRev->userCan( Revision::DELETED_TEXT, $user ) ) {
374
				$allowed = false;
375
			}
376
		}
377
378
		# Make "next revision link"
379
		# Skip next link on the top revision
380 View Code Duplication
		if ( $samePage && !$this->mNewRev->isCurrent() ) {
381
			$nextlink = Linker::linkKnown(
382
				$this->mNewPage,
383
				$this->msg( 'nextdiff' )->escaped(),
384
				[ 'id' => 'differences-nextlink' ],
385
				[ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query
386
			);
387
		} else {
388
			$nextlink = '&#160;';
389
		}
390
391
		if ( $this->mNewRev->isMinor() ) {
392
			$newminor = ChangesList::flag( 'minor' );
393
		} else {
394
			$newminor = '';
395
		}
396
397
		# Handle RevisionDelete links...
398
		$rdel = $this->revisionDeleteLink( $this->mNewRev );
399
400
		# Allow extensions to define their own revision tools
401
		Hooks::run( 'DiffRevisionTools',
402
			[ $this->mNewRev, &$revisionTools, $this->mOldRev, $user ] );
403
		$formattedRevisionTools = [];
404
		// Put each one in parentheses (poor man's button)
405
		foreach ( $revisionTools as $key => $tool ) {
406
			$toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
407
			$element = Html::rawElement(
408
				'span',
409
				[ 'class' => $toolClass ],
410
				$this->msg( 'parentheses' )->rawParams( $tool )->escaped()
411
			);
412
			$formattedRevisionTools[] = $element;
413
		}
414
		$newRevisionHeader = $this->getRevisionHeader( $this->mNewRev, 'complete' ) .
415
			' ' . implode( ' ', $formattedRevisionTools );
416
		$newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
417
418
		$newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
419
			'<div id="mw-diff-ntitle2">' . Linker::revUserTools( $this->mNewRev, !$this->unhide ) .
420
			" $rollback</div>" .
421
			'<div id="mw-diff-ntitle3">' . $newminor .
422
			Linker::revComment( $this->mNewRev, !$diffOnly, !$this->unhide ) . $rdel . '</div>' .
423
			'<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
424
			'<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
425
426
		// Allow extensions to change the $newHeader variable
427
		Hooks::run( 'DifferenceEngineNewHeader', [ $this, &$newHeader, $formattedRevisionTools,
428
			$nextlink, $rollback, $newminor, $diffOnly, $rdel, $this->unhide ] );
429
430 View Code Duplication
		if ( $this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
431
			$deleted = true; // new revisions text is hidden
432
			if ( $this->mNewRev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
433
				$suppressed = true; // also suppressed
434
			}
435
		}
436
437
		# If the diff cannot be shown due to a deleted revision, then output
438
		# the diff header and links to unhide (if available)...
439
		if ( $deleted && ( !$this->unhide || !$allowed ) ) {
440
			$this->showDiffStyle();
441
			$multi = $this->getMultiNotice();
442
			$out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
443
			if ( !$allowed ) {
444
				$msg = $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff';
445
				# Give explanation for why revision is not visible
446
				$out->wrapWikiMsg( "<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
447
					[ $msg ] );
448
			} else {
449
				# Give explanation and add a link to view the diff...
450
				$query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
451
				$link = $this->getTitle()->getFullURL( $query );
452
				$msg = $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff';
453
				$out->wrapWikiMsg(
454
					"<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
455
					[ $msg, $link ]
456
				);
457
			}
458
		# Otherwise, output a regular diff...
459
		} else {
460
			# Add deletion notice if the user is viewing deleted content
461
			$notice = '';
462
			if ( $deleted ) {
463
				$msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
464
				$notice = "<div id='mw-$msg' class='mw-warning plainlinks'>\n" .
465
					$this->msg( $msg )->parse() .
466
					"</div>\n";
467
			}
468
			$this->showDiff( $oldHeader, $newHeader, $notice );
469
			if ( !$diffOnly ) {
470
				$this->renderNewRevision();
471
			}
472
		}
473
	}
474
475
	/**
476
	 * Build a link to mark a change as patrolled.
477
	 *
478
	 * Returns empty string if there's either no revision to patrol or the user is not allowed to.
479
	 * Side effect: When the patrol link is build, this method will call
480
	 * OutputPage::preventClickjacking() and load mediawiki.page.patrol.ajax.
481
	 *
482
	 * @return string HTML or empty string
483
	 */
484
	protected function markPatrolledLink() {
485
		if ( $this->mMarkPatrolledLink === null ) {
486
			$linkInfo = $this->getMarkPatrolledLinkInfo();
487
			// If false, there is no patrol link needed/allowed
488
			if ( !$linkInfo ) {
489
				$this->mMarkPatrolledLink = '';
490
			} else {
491
				$this->mMarkPatrolledLink = ' <span class="patrollink" data-mw="interface">[' .
492
					Linker::linkKnown(
493
						$this->mNewPage,
494
						$this->msg( 'markaspatrolleddiff' )->escaped(),
495
						[],
496
						[
497
							'action' => 'markpatrolled',
498
							'rcid' => $linkInfo['rcid'],
499
						]
500
					) . ']</span>';
501
				// Allow extensions to change the markpatrolled link
502
				Hooks::run( 'DifferenceEngineMarkPatrolledLink', [ $this,
503
					&$this->mMarkPatrolledLink, $linkInfo['rcid'] ] );
504
			}
505
		}
506
		return $this->mMarkPatrolledLink;
507
	}
508
509
	/**
510
	 * Returns an array of meta data needed to build a "mark as patrolled" link and
511
	 * adds the mediawiki.page.patrol.ajax to the output.
512
	 *
513
	 * @return array|false An array of meta data for a patrol link (rcid only)
514
	 *  or false if no link is needed
515
	 */
516
	protected function getMarkPatrolledLinkInfo() {
517
		global $wgUseRCPatrol, $wgEnableAPI, $wgEnableWriteAPI;
518
519
		$user = $this->getUser();
520
521
		// Prepare a change patrol link, if applicable
522
		if (
523
			// Is patrolling enabled and the user allowed to?
524
			$wgUseRCPatrol && $this->mNewPage->quickUserCan( 'patrol', $user ) &&
525
			// Only do this if the revision isn't more than 6 hours older
526
			// than the Max RC age (6h because the RC might not be cleaned out regularly)
527
			RecentChange::isInRCLifespan( $this->mNewRev->getTimestamp(), 21600 )
528
		) {
529
			// Look for an unpatrolled change corresponding to this diff
530
			$db = wfGetDB( DB_REPLICA );
531
			$change = RecentChange::newFromConds(
532
				[
533
					'rc_timestamp' => $db->timestamp( $this->mNewRev->getTimestamp() ),
534
					'rc_this_oldid' => $this->mNewid,
535
					'rc_patrolled' => 0
536
				],
537
				__METHOD__
538
			);
539
540
			if ( $change && !$change->getPerformer()->equals( $user ) ) {
541
				$rcid = $change->getAttribute( 'rc_id' );
542
			} else {
543
				// None found or the page has been created by the current user.
544
				// If the user could patrol this it already would be patrolled
545
				$rcid = 0;
546
			}
547
548
			// Allow extensions to possibly change the rcid here
549
			// For example the rcid might be set to zero due to the user
550
			// being the same as the performer of the change but an extension
551
			// might still want to show it under certain conditions
552
			Hooks::run( 'DifferenceEngineMarkPatrolledRCID', [ &$rcid, $this, $change, $user ] );
553
554
			// Build the link
555
			if ( $rcid ) {
556
				$this->getOutput()->preventClickjacking();
557
				if ( $wgEnableAPI && $wgEnableWriteAPI
558
					&& $user->isAllowed( 'writeapi' )
559
				) {
560
					$this->getOutput()->addModules( 'mediawiki.page.patrol.ajax' );
561
				}
562
563
				return [
564
					'rcid' => $rcid,
565
				];
566
			}
567
		}
568
569
		// No mark as patrolled link applicable
570
		return false;
571
	}
572
573
	/**
574
	 * @param Revision $rev
575
	 *
576
	 * @return string
577
	 */
578
	protected function revisionDeleteLink( $rev ) {
579
		$link = Linker::getRevDeleteLink( $this->getUser(), $rev, $rev->getTitle() );
580
		if ( $link !== '' ) {
581
			$link = '&#160;&#160;&#160;' . $link . ' ';
582
		}
583
584
		return $link;
585
	}
586
587
	/**
588
	 * Show the new revision of the page.
589
	 */
590
	public function renderNewRevision() {
591
		$out = $this->getOutput();
592
		$revHeader = $this->getRevisionHeader( $this->mNewRev );
593
		# Add "current version as of X" title
594
		$out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
595
		<h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
596
		# Page content may be handled by a hooked call instead...
597
		# @codingStandardsIgnoreStart Ignoring long lines.
598
		if ( Hooks::run( 'ArticleContentOnDiff', [ $this, $out ] ) ) {
599
			$this->loadNewText();
600
			$out->setRevisionId( $this->mNewid );
601
			$out->setRevisionTimestamp( $this->mNewRev->getTimestamp() );
602
			$out->setArticleFlag( true );
603
604
			// NOTE: only needed for B/C: custom rendering of JS/CSS via hook
605
			if ( $this->mNewPage->isCssJsSubpage() || $this->mNewPage->isCssOrJsPage() ) {
606
				// This needs to be synchronised with Article::showCssOrJsPage(), which sucks
607
				// Give hooks a chance to customise the output
608
				// @todo standardize this crap into one function
609
				if ( ContentHandler::runLegacyHooks( 'ShowRawCssJs', [ $this->mNewContent, $this->mNewPage, $out ], '1.24' ) ) {
610
					// NOTE: deprecated hook, B/C only
611
					// use the content object's own rendering
612
					$cnt = $this->mNewRev->getContent();
613
					$po = $cnt ? $cnt->getParserOutput( $this->mNewRev->getTitle(), $this->mNewRev->getId() ) : null;
614
					if ( $po ) {
615
						$out->addParserOutputContent( $po );
616
					}
617
				}
618
			} elseif ( !Hooks::run( 'ArticleContentViewCustom', [ $this->mNewContent, $this->mNewPage, $out ] ) ) {
619
				// Handled by extension
620
			} elseif ( !ContentHandler::runLegacyHooks(
621
				'ArticleViewCustom',
622
				[ $this->mNewContent, $this->mNewPage, $out ],
623
				'1.21'
624
			) ) {
625
				// NOTE: deprecated hook, B/C only
626
				// Handled by extension
627
			} else {
628
				// Normal page
629
				if ( $this->getTitle()->equals( $this->mNewPage ) ) {
630
					// If the Title stored in the context is the same as the one
631
					// of the new revision, we can use its associated WikiPage
632
					// object.
633
					$wikiPage = $this->getWikiPage();
634
				} else {
635
					// Otherwise we need to create our own WikiPage object
636
					$wikiPage = WikiPage::factory( $this->mNewPage );
637
				}
638
639
				$parserOutput = $this->getParserOutput( $wikiPage, $this->mNewRev );
640
641
				# WikiPage::getParserOutput() should not return false, but just in case
642
				if ( $parserOutput ) {
643
					// Allow extensions to change parser output here
644
					if ( Hooks::run( 'DifferenceEngineRenderRevisionAddParserOutput', [ $this, $out, $parserOutput, $wikiPage ] ) ) {
645
						$out->addParserOutput( $parserOutput );
646
					}
647
				}
648
			}
649
		}
650
		# @codingStandardsIgnoreEnd
651
652
		// Allow extensions to optionally not show the final patrolled link
653
		if ( Hooks::run( 'DifferenceEngineRenderRevisionShowFinalPatrolLink' ) ) {
654
			# Add redundant patrol link on bottom...
655
			$out->addHTML( $this->markPatrolledLink() );
656
		}
657
	}
658
659
	protected function getParserOutput( WikiPage $page, Revision $rev ) {
660
		$parserOptions = $page->makeParserOptions( $this->getContext() );
661
662
		if ( !$rev->isCurrent() || !$rev->getTitle()->quickUserCan( 'edit', $this->getUser() ) ) {
663
			$parserOptions->setEditSection( false );
664
		}
665
666
		$parserOutput = $page->getParserOutput( $parserOptions, $rev->getId() );
667
668
		return $parserOutput;
669
	}
670
671
	/**
672
	 * Get the diff text, send it to the OutputPage object
673
	 * Returns false if the diff could not be generated, otherwise returns true
674
	 *
675
	 * @param string|bool $otitle Header for old text or false
676
	 * @param string|bool $ntitle Header for new text or false
677
	 * @param string $notice HTML between diff header and body
678
	 *
679
	 * @return bool
680
	 */
681
	public function showDiff( $otitle, $ntitle, $notice = '' ) {
682
		// Allow extensions to affect the output here
683
		Hooks::run( 'DifferenceEngineShowDiff', [ $this ] );
684
685
		$diff = $this->getDiff( $otitle, $ntitle, $notice );
686
		if ( $diff === false ) {
687
			$this->showMissingRevision();
688
689
			return false;
690
		} else {
691
			$this->showDiffStyle();
692
			$this->getOutput()->addHTML( $diff );
693
694
			return true;
695
		}
696
	}
697
698
	/**
699
	 * Add style sheets for diff display.
700
	 */
701
	public function showDiffStyle() {
702
		$this->getOutput()->addModuleStyles( 'mediawiki.diff.styles' );
703
	}
704
705
	/**
706
	 * Get complete diff table, including header
707
	 *
708
	 * @param string|bool $otitle Header for old text or false
709
	 * @param string|bool $ntitle Header for new text or false
710
	 * @param string $notice HTML between diff header and body
711
	 *
712
	 * @return mixed
713
	 */
714
	public function getDiff( $otitle, $ntitle, $notice = '' ) {
715
		$body = $this->getDiffBody();
716
		if ( $body === false ) {
717
			return false;
718
		}
719
720
		$multi = $this->getMultiNotice();
721
		// Display a message when the diff is empty
722
		if ( $body === '' ) {
723
			$notice .= '<div class="mw-diff-empty">' .
724
				$this->msg( 'diff-empty' )->parse() .
725
				"</div>\n";
726
		}
727
728
		return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
729
	}
730
731
	/**
732
	 * Get the diff table body, without header
733
	 *
734
	 * @return mixed (string/false)
735
	 */
736
	public function getDiffBody() {
737
		$this->mCacheHit = true;
738
		// Check if the diff should be hidden from this user
739
		if ( !$this->loadRevisionData() ) {
740
			return false;
741
		} elseif ( $this->mOldRev &&
742
			!$this->mOldRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
743
		) {
744
			return false;
745
		} elseif ( $this->mNewRev &&
746
			!$this->mNewRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
747
		) {
748
			return false;
749
		}
750
		// Short-circuit
751
		if ( $this->mOldRev === false || ( $this->mOldRev && $this->mNewRev
752
			&& $this->mOldRev->getId() == $this->mNewRev->getId() )
753
		) {
754
			if ( Hooks::run( 'DifferenceEngineShowEmptyOldContent', [ $this ] ) ) {
755
				return '';
756
			}
757
		}
758
		// Cacheable?
759
		$key = false;
760
		$cache = ObjectCache::getMainWANInstance();
761
		if ( $this->mOldid && $this->mNewid ) {
762
			$key = $this->getDiffBodyCacheKey();
763
764
			// Try cache
765
			if ( !$this->mRefreshCache ) {
766
				$difftext = $cache->get( $key );
767
				if ( $difftext ) {
768
					wfIncrStats( 'diff_cache.hit' );
769
					$difftext = $this->localiseLineNumbers( $difftext );
770
					$difftext .= "\n<!-- diff cache key $key -->\n";
771
772
					return $difftext;
773
				}
774
			} // don't try to load but save the result
775
		}
776
		$this->mCacheHit = false;
777
778
		// Loadtext is permission safe, this just clears out the diff
779
		if ( !$this->loadText() ) {
780
			return false;
781
		}
782
783
		$difftext = $this->generateContentDiffBody( $this->mOldContent, $this->mNewContent );
784
785
		// Save to cache for 7 days
786
		if ( !Hooks::run( 'AbortDiffCache', [ &$this ] ) ) {
787
			wfIncrStats( 'diff_cache.uncacheable' );
788
		} elseif ( $key !== false && $difftext !== false ) {
789
			wfIncrStats( 'diff_cache.miss' );
790
			$cache->set( $key, $difftext, 7 * 86400 );
791
		} else {
792
			wfIncrStats( 'diff_cache.uncacheable' );
793
		}
794
		// Replace line numbers with the text in the user's language
795
		if ( $difftext !== false ) {
796
			$difftext = $this->localiseLineNumbers( $difftext );
797
		}
798
799
		return $difftext;
800
	}
801
802
	/**
803
	 * Returns the cache key for diff body text or content.
804
	 *
805
	 * @since 1.23
806
	 *
807
	 * @throws MWException
808
	 * @return string
809
	 */
810
	protected function getDiffBodyCacheKey() {
811
		if ( !$this->mOldid || !$this->mNewid ) {
812
			throw new MWException( 'mOldid and mNewid must be set to get diff cache key.' );
813
		}
814
815
		return wfMemcKey( 'diff', 'version', self::DIFF_VERSION,
816
			'oldid', $this->mOldid, 'newid', $this->mNewid );
817
	}
818
819
	/**
820
	 * Generate a diff, no caching.
821
	 *
822
	 * This implementation uses generateTextDiffBody() to generate a diff based on the default
823
	 * serialization of the given Content objects. This will fail if $old or $new are not
824
	 * instances of TextContent.
825
	 *
826
	 * Subclasses may override this to provide a different rendering for the diff,
827
	 * perhaps taking advantage of the content's native form. This is required for all content
828
	 * models that are not text based.
829
	 *
830
	 * @since 1.21
831
	 *
832
	 * @param Content $old Old content
833
	 * @param Content $new New content
834
	 *
835
	 * @throws MWException If old or new content is not an instance of TextContent.
836
	 * @return bool|string
837
	 */
838
	public function generateContentDiffBody( Content $old, Content $new ) {
839 View Code Duplication
		if ( !( $old instanceof TextContent ) ) {
840
			throw new MWException( "Diff not implemented for " . get_class( $old ) . "; " .
841
				"override generateContentDiffBody to fix this." );
842
		}
843
844 View Code Duplication
		if ( !( $new instanceof TextContent ) ) {
845
			throw new MWException( "Diff not implemented for " . get_class( $new ) . "; "
846
				. "override generateContentDiffBody to fix this." );
847
		}
848
849
		$otext = $old->serialize();
850
		$ntext = $new->serialize();
851
852
		return $this->generateTextDiffBody( $otext, $ntext );
853
	}
854
855
	/**
856
	 * Generate a diff, no caching
857
	 *
858
	 * @todo move this to TextDifferenceEngine, make DifferenceEngine abstract. At some point.
859
	 *
860
	 * @param string $otext Old text, must be already segmented
861
	 * @param string $ntext New text, must be already segmented
862
	 *
863
	 * @return bool|string
864
	 */
865
	public function generateTextDiffBody( $otext, $ntext ) {
866
		$diff = function() use ( $otext, $ntext ) {
867
			$time = microtime( true );
868
869
			$result = $this->textDiff( $otext, $ntext );
870
871
			$time = intval( ( microtime( true ) - $time ) * 1000 );
872
			$this->getStats()->timing( 'diff_time', $time );
873
			// Log requests slower than 99th percentile
874
			if ( $time > 100 && $this->mOldPage && $this->mNewPage ) {
875
				wfDebugLog( 'diff',
876
					"$time ms diff: {$this->mOldid} -> {$this->mNewid} {$this->mNewPage}" );
877
			}
878
879
			return $result;
880
		};
881
882
		/**
883
		 * @param Status $status
884
		 * @throws FatalError
885
		 */
886
		$error = function( $status ) {
887
			throw new FatalError( $status->getWikiText() );
888
		};
889
890
		// Use PoolCounter if the diff looks like it can be expensive
891
		if ( strlen( $otext ) + strlen( $ntext ) > 20000 ) {
892
			$work = new PoolCounterWorkViaCallback( 'diff',
893
				md5( $otext ) . md5( $ntext ),
894
				[ 'doWork' => $diff, 'error' => $error ]
895
			);
896
			return $work->execute();
897
		}
898
899
		return $diff();
900
	}
901
902
	/**
903
	 * Generates diff, to be wrapped internally in a logging/instrumentation
904
	 *
905
	 * @param string $otext Old text, must be already segmented
906
	 * @param string $ntext New text, must be already segmented
907
	 * @return bool|string
908
	 */
909
	protected function textDiff( $otext, $ntext ) {
910
		global $wgExternalDiffEngine, $wgContLang;
911
912
		$otext = str_replace( "\r\n", "\n", $otext );
913
		$ntext = str_replace( "\r\n", "\n", $ntext );
914
915
		if ( $wgExternalDiffEngine == 'wikidiff' || $wgExternalDiffEngine == 'wikidiff3' ) {
916
			wfDeprecated( "\$wgExternalDiffEngine = '{$wgExternalDiffEngine}'", '1.27' );
917
			$wgExternalDiffEngine = false;
918
		} elseif ( $wgExternalDiffEngine == 'wikidiff2' ) {
919
			// Same as above, but with no deprecation warnings
920
			$wgExternalDiffEngine = false;
921
		} elseif ( !is_string( $wgExternalDiffEngine ) && $wgExternalDiffEngine !== false ) {
922
			// And prevent people from shooting themselves in the foot...
923
			wfWarn( '$wgExternalDiffEngine is set to a non-string value, forcing it to false' );
924
			$wgExternalDiffEngine = false;
925
		}
926
927
		if ( function_exists( 'wikidiff2_do_diff' ) && $wgExternalDiffEngine === false ) {
928
			# Better external diff engine, the 2 may some day be dropped
929
			# This one does the escaping and segmenting itself
930
			$text = wikidiff2_do_diff( $otext, $ntext, 2 );
931
			$text .= $this->debug( 'wikidiff2' );
932
933
			return $text;
934
		} elseif ( $wgExternalDiffEngine !== false && is_executable( $wgExternalDiffEngine ) ) {
935
			# Diff via the shell
936
			$tmpDir = wfTempDir();
937
			$tempName1 = tempnam( $tmpDir, 'diff_' );
938
			$tempName2 = tempnam( $tmpDir, 'diff_' );
939
940
			$tempFile1 = fopen( $tempName1, "w" );
941
			if ( !$tempFile1 ) {
942
				return false;
943
			}
944
			$tempFile2 = fopen( $tempName2, "w" );
945
			if ( !$tempFile2 ) {
946
				return false;
947
			}
948
			fwrite( $tempFile1, $otext );
949
			fwrite( $tempFile2, $ntext );
950
			fclose( $tempFile1 );
951
			fclose( $tempFile2 );
952
			$cmd = wfEscapeShellArg( $wgExternalDiffEngine, $tempName1, $tempName2 );
953
			$difftext = wfShellExec( $cmd );
954
			$difftext .= $this->debug( "external $wgExternalDiffEngine" );
955
			unlink( $tempName1 );
956
			unlink( $tempName2 );
957
958
			return $difftext;
959
		}
960
961
		# Native PHP diff
962
		$ota = explode( "\n", $wgContLang->segmentForDiff( $otext ) );
963
		$nta = explode( "\n", $wgContLang->segmentForDiff( $ntext ) );
964
		$diffs = new Diff( $ota, $nta );
965
		$formatter = new TableDiffFormatter();
966
		$difftext = $wgContLang->unsegmentForDiff( $formatter->format( $diffs ) );
967
968
		return $difftext;
969
	}
970
971
	/**
972
	 * Generate a debug comment indicating diff generating time,
973
	 * server node, and generator backend.
974
	 *
975
	 * @param string $generator : What diff engine was used
976
	 *
977
	 * @return string
978
	 */
979
	protected function debug( $generator = "internal" ) {
980
		global $wgShowHostnames;
981
		if ( !$this->enableDebugComment ) {
982
			return '';
983
		}
984
		$data = [ $generator ];
985
		if ( $wgShowHostnames ) {
986
			$data[] = wfHostname();
987
		}
988
		$data[] = wfTimestamp( TS_DB );
989
990
		return "<!-- diff generator: " .
991
			implode( " ", array_map( "htmlspecialchars", $data ) ) .
992
			" -->\n";
993
	}
994
995
	/**
996
	 * Replace line numbers with the text in the user's language
997
	 *
998
	 * @param string $text
999
	 *
1000
	 * @return mixed
1001
	 */
1002
	public function localiseLineNumbers( $text ) {
1003
		return preg_replace_callback(
1004
			'/<!--LINE (\d+)-->/',
1005
			[ &$this, 'localiseLineNumbersCb' ],
1006
			$text
1007
		);
1008
	}
1009
1010
	public function localiseLineNumbersCb( $matches ) {
1011
		if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
1012
			return '';
1013
		}
1014
1015
		return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
1016
	}
1017
1018
	/**
1019
	 * If there are revisions between the ones being compared, return a note saying so.
1020
	 *
1021
	 * @return string
1022
	 */
1023
	public function getMultiNotice() {
1024
		if ( !is_object( $this->mOldRev ) || !is_object( $this->mNewRev ) ) {
1025
			return '';
1026
		} elseif ( !$this->mOldPage->equals( $this->mNewPage ) ) {
1027
			// Comparing two different pages? Count would be meaningless.
1028
			return '';
1029
		}
1030
1031
		if ( $this->mOldRev->getTimestamp() > $this->mNewRev->getTimestamp() ) {
1032
			$oldRev = $this->mNewRev; // flip
1033
			$newRev = $this->mOldRev; // flip
1034
		} else { // normal case
1035
			$oldRev = $this->mOldRev;
1036
			$newRev = $this->mNewRev;
1037
		}
1038
1039
		// Sanity: don't show the notice if too many rows must be scanned
1040
		// @todo show some special message for that case
1041
		$nEdits = $this->mNewPage->countRevisionsBetween( $oldRev, $newRev, 1000 );
1042
		if ( $nEdits > 0 && $nEdits <= 1000 ) {
1043
			$limit = 100; // use diff-multi-manyusers if too many users
1044
			$users = $this->mNewPage->getAuthorsBetween( $oldRev, $newRev, $limit );
1045
			$numUsers = count( $users );
1046
1047
			if ( $numUsers == 1 && $users[0] == $newRev->getUserText( Revision::RAW ) ) {
1048
				$numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1049
			}
1050
1051
			return self::intermediateEditsMsg( $nEdits, $numUsers, $limit );
1052
		}
1053
1054
		return ''; // nothing
1055
	}
1056
1057
	/**
1058
	 * Get a notice about how many intermediate edits and users there are
1059
	 *
1060
	 * @param int $numEdits
1061
	 * @param int $numUsers
1062
	 * @param int $limit
1063
	 *
1064
	 * @return string
1065
	 */
1066
	public static function intermediateEditsMsg( $numEdits, $numUsers, $limit ) {
1067
		if ( $numUsers === 0 ) {
1068
			$msg = 'diff-multi-sameuser';
1069
		} elseif ( $numUsers > $limit ) {
1070
			$msg = 'diff-multi-manyusers';
1071
			$numUsers = $limit;
1072
		} else {
1073
			$msg = 'diff-multi-otherusers';
1074
		}
1075
1076
		return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1077
	}
1078
1079
	/**
1080
	 * Get a header for a specified revision.
1081
	 *
1082
	 * @param Revision $rev
1083
	 * @param string $complete 'complete' to get the header wrapped depending
1084
	 *        the visibility of the revision and a link to edit the page.
1085
	 *
1086
	 * @return string HTML fragment
1087
	 */
1088
	protected function getRevisionHeader( Revision $rev, $complete = '' ) {
1089
		$lang = $this->getLanguage();
1090
		$user = $this->getUser();
1091
		$revtimestamp = $rev->getTimestamp();
1092
		$timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1093
		$dateofrev = $lang->userDate( $revtimestamp, $user );
1094
		$timeofrev = $lang->userTime( $revtimestamp, $user );
1095
1096
		$header = $this->msg(
1097
			$rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1098
			$timestamp,
1099
			$dateofrev,
1100
			$timeofrev
1101
		)->escaped();
1102
1103
		if ( $complete !== 'complete' ) {
1104
			return $header;
1105
		}
1106
1107
		$title = $rev->getTitle();
1108
1109
		$header = Linker::linkKnown( $title, $header, [],
1110
			[ 'oldid' => $rev->getId() ] );
1111
1112
		if ( $rev->userCan( Revision::DELETED_TEXT, $user ) ) {
1113
			$editQuery = [ 'action' => 'edit' ];
1114
			if ( !$rev->isCurrent() ) {
1115
				$editQuery['oldid'] = $rev->getId();
1116
			}
1117
1118
			$key = $title->quickUserCan( 'edit', $user ) ? 'editold' : 'viewsourceold';
1119
			$msg = $this->msg( $key )->escaped();
1120
			$editLink = $this->msg( 'parentheses' )->rawParams(
1121
				Linker::linkKnown( $title, $msg, [], $editQuery ) )->escaped();
1122
			$header .= ' ' . Html::rawElement(
1123
				'span',
1124
				[ 'class' => 'mw-diff-edit' ],
1125
				$editLink
1126
			);
1127
			if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
1128
				$header = Html::rawElement(
1129
					'span',
1130
					[ 'class' => 'history-deleted' ],
1131
					$header
1132
				);
1133
			}
1134
		} else {
1135
			$header = Html::rawElement( 'span', [ 'class' => 'history-deleted' ], $header );
1136
		}
1137
1138
		return $header;
1139
	}
1140
1141
	/**
1142
	 * Add the header to a diff body
1143
	 *
1144
	 * @param string $diff Diff body
1145
	 * @param string $otitle Old revision header
1146
	 * @param string $ntitle New revision header
1147
	 * @param string $multi Notice telling user that there are intermediate
1148
	 *   revisions between the ones being compared
1149
	 * @param string $notice Other notices, e.g. that user is viewing deleted content
1150
	 *
1151
	 * @return string
1152
	 */
1153
	public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
1154
		// shared.css sets diff in interface language/dir, but the actual content
1155
		// is often in a different language, mostly the page content language/dir
1156
		$header = Html::openElement( 'table', [
1157
			'class' => [ 'diff', 'diff-contentalign-' . $this->getDiffLang()->alignStart() ],
1158
			'data-mw' => 'interface',
1159
		] );
1160
		$userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
1161
1162
		if ( !$diff && !$otitle ) {
1163
			$header .= "
1164
			<tr style='vertical-align: top;' lang='{$userLang}'>
1165
			<td class='diff-ntitle'>{$ntitle}</td>
1166
			</tr>";
1167
			$multiColspan = 1;
1168
		} else {
1169
			if ( $diff ) { // Safari/Chrome show broken output if cols not used
1170
				$header .= "
1171
				<col class='diff-marker' />
1172
				<col class='diff-content' />
1173
				<col class='diff-marker' />
1174
				<col class='diff-content' />";
1175
				$colspan = 2;
1176
				$multiColspan = 4;
1177
			} else {
1178
				$colspan = 1;
1179
				$multiColspan = 2;
1180
			}
1181
			if ( $otitle || $ntitle ) {
1182
				$header .= "
1183
				<tr style='vertical-align: top;' lang='{$userLang}'>
1184
				<td colspan='$colspan' class='diff-otitle'>{$otitle}</td>
1185
				<td colspan='$colspan' class='diff-ntitle'>{$ntitle}</td>
1186
				</tr>";
1187
			}
1188
		}
1189
1190
		if ( $multi != '' ) {
1191
			$header .= "<tr><td colspan='{$multiColspan}' style='text-align: center;' " .
1192
				"class='diff-multi' lang='{$userLang}'>{$multi}</td></tr>";
1193
		}
1194
		if ( $notice != '' ) {
1195
			$header .= "<tr><td colspan='{$multiColspan}' style='text-align: center;' " .
1196
				"lang='{$userLang}'>{$notice}</td></tr>";
1197
		}
1198
1199
		return $header . $diff . "</table>";
1200
	}
1201
1202
	/**
1203
	 * Use specified text instead of loading from the database
1204
	 * @param Content $oldContent
1205
	 * @param Content $newContent
1206
	 * @since 1.21
1207
	 */
1208
	public function setContent( Content $oldContent, Content $newContent ) {
1209
		$this->mOldContent = $oldContent;
1210
		$this->mNewContent = $newContent;
1211
1212
		$this->mTextLoaded = 2;
1213
		$this->mRevisionsLoaded = true;
1214
	}
1215
1216
	/**
1217
	 * Set the language in which the diff text is written
1218
	 * (Defaults to page content language).
1219
	 * @param Language|string $lang
1220
	 * @since 1.19
1221
	 */
1222
	public function setTextLanguage( $lang ) {
1223
		$this->mDiffLang = wfGetLangObj( $lang );
1224
	}
1225
1226
	/**
1227
	 * Maps a revision pair definition as accepted by DifferenceEngine constructor
1228
	 * to a pair of actual integers representing revision ids.
1229
	 *
1230
	 * @param int $old Revision id, e.g. from URL parameter 'oldid'
1231
	 * @param int|string $new Revision id or strings 'next' or 'prev', e.g. from URL parameter 'diff'
1232
	 *
1233
	 * @return int[] List of two revision ids, older first, later second.
1234
	 *     Zero signifies invalid argument passed.
1235
	 *     false signifies that there is no previous/next revision ($old is the oldest/newest one).
1236
	 */
1237
	public function mapDiffPrevNext( $old, $new ) {
1238
		if ( $new === 'prev' ) {
1239
			// Show diff between revision $old and the previous one. Get previous one from DB.
1240
			$newid = intval( $old );
1241
			$oldid = $this->getTitle()->getPreviousRevisionID( $newid );
1242
		} elseif ( $new === 'next' ) {
1243
			// Show diff between revision $old and the next one. Get next one from DB.
1244
			$oldid = intval( $old );
1245
			$newid = $this->getTitle()->getNextRevisionID( $oldid );
1246
		} else {
1247
			$oldid = intval( $old );
1248
			$newid = intval( $new );
1249
		}
1250
1251
		return [ $oldid, $newid ];
1252
	}
1253
1254
	/**
1255
	 * Load revision IDs
1256
	 */
1257
	private function loadRevisionIds() {
1258
		if ( $this->mRevisionsIdsLoaded ) {
1259
			return;
1260
		}
1261
1262
		$this->mRevisionsIdsLoaded = true;
1263
1264
		$old = $this->mOldid;
1265
		$new = $this->mNewid;
1266
1267
		list( $this->mOldid, $this->mNewid ) = self::mapDiffPrevNext( $old, $new );
1268
		if ( $new === 'next' && $this->mNewid === false ) {
1269
			# if no result, NewId points to the newest old revision. The only newer
1270
			# revision is cur, which is "0".
1271
			$this->mNewid = 0;
1272
		}
1273
1274
		Hooks::run(
1275
			'NewDifferenceEngine',
1276
			[ $this->getTitle(), &$this->mOldid, &$this->mNewid, $old, $new ]
1277
		);
1278
	}
1279
1280
	/**
1281
	 * Load revision metadata for the specified articles. If newid is 0, then compare
1282
	 * the old article in oldid to the current article; if oldid is 0, then
1283
	 * compare the current article to the immediately previous one (ignoring the
1284
	 * value of newid).
1285
	 *
1286
	 * If oldid is false, leave the corresponding revision object set
1287
	 * to false. This is impossible via ordinary user input, and is provided for
1288
	 * API convenience.
1289
	 *
1290
	 * @return bool
1291
	 */
1292
	public function loadRevisionData() {
1293
		if ( $this->mRevisionsLoaded ) {
1294
			return true;
1295
		}
1296
1297
		// Whether it succeeds or fails, we don't want to try again
1298
		$this->mRevisionsLoaded = true;
1299
1300
		$this->loadRevisionIds();
1301
1302
		// Load the new revision object
1303
		if ( $this->mNewid ) {
1304
			$this->mNewRev = Revision::newFromId( $this->mNewid );
1305
		} else {
1306
			$this->mNewRev = Revision::newFromTitle(
1307
				$this->getTitle(),
1308
				false,
1309
				Revision::READ_NORMAL
1310
			);
1311
		}
1312
1313
		if ( !$this->mNewRev instanceof Revision ) {
1314
			return false;
1315
		}
1316
1317
		// Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
1318
		$this->mNewid = $this->mNewRev->getId();
1319
		$this->mNewPage = $this->mNewRev->getTitle();
1320
1321
		// Load the old revision object
1322
		$this->mOldRev = false;
1323
		if ( $this->mOldid ) {
1324
			$this->mOldRev = Revision::newFromId( $this->mOldid );
1325
		} elseif ( $this->mOldid === 0 ) {
1326
			$rev = $this->mNewRev->getPrevious();
1327
			if ( $rev ) {
1328
				$this->mOldid = $rev->getId();
1329
				$this->mOldRev = $rev;
1330
			} else {
1331
				// No previous revision; mark to show as first-version only.
1332
				$this->mOldid = false;
1333
				$this->mOldRev = false;
1334
			}
1335
		} /* elseif ( $this->mOldid === false ) leave mOldRev false; */
1336
1337
		if ( is_null( $this->mOldRev ) ) {
1338
			return false;
1339
		}
1340
1341
		if ( $this->mOldRev ) {
1342
			$this->mOldPage = $this->mOldRev->getTitle();
1343
		}
1344
1345
		// Load tags information for both revisions
1346
		$dbr = wfGetDB( DB_REPLICA );
1347
		if ( $this->mOldid !== false ) {
1348
			$this->mOldTags = $dbr->selectField(
1349
				'tag_summary',
1350
				'ts_tags',
1351
				[ 'ts_rev_id' => $this->mOldid ],
1352
				__METHOD__
1353
			);
1354
		} else {
1355
			$this->mOldTags = false;
1356
		}
1357
		$this->mNewTags = $dbr->selectField(
1358
			'tag_summary',
1359
			'ts_tags',
1360
			[ 'ts_rev_id' => $this->mNewid ],
1361
			__METHOD__
1362
		);
1363
1364
		return true;
1365
	}
1366
1367
	/**
1368
	 * Load the text of the revisions, as well as revision data.
1369
	 *
1370
	 * @return bool
1371
	 */
1372
	public function loadText() {
1373
		if ( $this->mTextLoaded == 2 ) {
1374
			return true;
1375
		}
1376
1377
		// Whether it succeeds or fails, we don't want to try again
1378
		$this->mTextLoaded = 2;
1379
1380
		if ( !$this->loadRevisionData() ) {
1381
			return false;
1382
		}
1383
1384 View Code Duplication
		if ( $this->mOldRev ) {
1385
			$this->mOldContent = $this->mOldRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
1386
			if ( $this->mOldContent === null ) {
1387
				return false;
1388
			}
1389
		}
1390
1391 View Code Duplication
		if ( $this->mNewRev ) {
1392
			$this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
1393
			if ( $this->mNewContent === null ) {
1394
				return false;
1395
			}
1396
		}
1397
1398
		return true;
1399
	}
1400
1401
	/**
1402
	 * Load the text of the new revision, not the old one
1403
	 *
1404
	 * @return bool
1405
	 */
1406
	public function loadNewText() {
1407
		if ( $this->mTextLoaded >= 1 ) {
1408
			return true;
1409
		}
1410
1411
		$this->mTextLoaded = 1;
1412
1413
		if ( !$this->loadRevisionData() ) {
1414
			return false;
1415
		}
1416
1417
		$this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
1418
1419
		return true;
1420
	}
1421
1422
}
1423