Completed
Branch master (13ece3)
by
unknown
22:07
created

DifferenceEngine::generateTextDiffBody()   B

Complexity

Conditions 5
Paths 2

Size

Total Lines 32
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 18
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 32
rs 8.439
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
/**
25
 * Constant to indicate diff cache compatibility.
26
 * Bump this when changing the diff formatting in a way that
27
 * fixes important bugs or such to force cached diff views to
28
 * clear.
29
 */
30
define( 'MW_DIFF_VERSION', '1.11a' );
31
32
/**
33
 * @todo document
34
 * @ingroup DifferenceEngine
35
 */
36
class DifferenceEngine extends ContextSource {
37
38
	/** @var int */
39
	public $mOldid;
40
41
	/** @var int */
42
	public $mNewid;
43
44
	private $mOldTags;
45
	private $mNewTags;
46
47
	/** @var Content */
48
	public $mOldContent;
49
50
	/** @var Content */
51
	public $mNewContent;
52
53
	/** @var Language */
54
	protected $mDiffLang;
55
56
	/** @var Title */
57
	public $mOldPage;
58
59
	/** @var Title */
60
	public $mNewPage;
61
62
	/** @var Revision */
63
	public $mOldRev;
64
65
	/** @var Revision */
66
	public $mNewRev;
67
68
	/** @var bool Have the revisions IDs been loaded */
69
	private $mRevisionsIdsLoaded = false;
70
71
	/** @var bool Have the revisions been loaded */
72
	public $mRevisionsLoaded = false;
73
74
	/** @var int How many text blobs have been loaded, 0, 1 or 2? */
75
	public $mTextLoaded = 0;
76
77
	/** @var bool Was the diff fetched from cache? */
78
	public $mCacheHit = false;
79
80
	/**
81
	 * Set this to true to add debug info to the HTML output.
82
	 * Warning: this may cause RSS readers to spuriously mark articles as "new"
83
	 * (bug 20601)
84
	 */
85
	public $enableDebugComment = false;
86
87
	/** @var bool If true, line X is not displayed when X is 1, for example
88
	 *    to increase readability and conserve space with many small diffs.
89
	 */
90
	protected $mReducedLineNumbers = false;
91
92
	/** @var string Link to action=markpatrolled */
93
	protected $mMarkPatrolledLink = null;
94
95
	/** @var bool Show rev_deleted content if allowed */
96
	protected $unhide = false;
97
98
	/** @var bool Refresh the diff cache */
99
	protected $mRefreshCache = false;
100
101
	/**#@-*/
102
103
	/**
104
	 * Constructor
105
	 * @param IContextSource $context Context to use, anything else will be ignored
106
	 * @param int $old Old ID we want to show and diff with.
107
	 * @param string|int $new Either revision ID or 'prev' or 'next'. Default: 0.
108
	 * @param int $rcid Deprecated, no longer used!
109
	 * @param bool $refreshCache If set, refreshes the diff cache
110
	 * @param bool $unhide If set, allow viewing deleted revs
111
	 */
112
	public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
113
		$refreshCache = false, $unhide = false
114
	) {
115
		if ( $context instanceof IContextSource ) {
116
			$this->setContext( $context );
117
		}
118
119
		wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'\n" );
120
121
		$this->mOldid = $old;
122
		$this->mNewid = $new;
123
		$this->mRefreshCache = $refreshCache;
124
		$this->unhide = $unhide;
125
	}
126
127
	/**
128
	 * @param bool $value
129
	 */
130
	public function setReducedLineNumbers( $value = true ) {
131
		$this->mReducedLineNumbers = $value;
132
	}
133
134
	/**
135
	 * @return Language
136
	 */
137
	public function getDiffLang() {
138
		if ( $this->mDiffLang === null ) {
139
			# Default language in which the diff text is written.
140
			$this->mDiffLang = $this->getTitle()->getPageLanguage();
141
		}
142
143
		return $this->mDiffLang;
144
	}
145
146
	/**
147
	 * @return bool
148
	 */
149
	public function wasCacheHit() {
150
		return $this->mCacheHit;
151
	}
152
153
	/**
154
	 * @return int
155
	 */
156
	public function getOldid() {
157
		$this->loadRevisionIds();
158
159
		return $this->mOldid;
160
	}
161
162
	/**
163
	 * @return bool|int
164
	 */
165
	public function getNewid() {
166
		$this->loadRevisionIds();
167
168
		return $this->mNewid;
169
	}
170
171
	/**
172
	 * Look up a special:Undelete link to the given deleted revision id,
173
	 * as a workaround for being unable to load deleted diffs in currently.
174
	 *
175
	 * @param int $id Revision ID
176
	 *
177
	 * @return mixed URL or false
178
	 */
179
	public function deletedLink( $id ) {
180
		if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
181
			$dbr = wfGetDB( DB_SLAVE );
182
			$row = $dbr->selectRow( 'archive', '*',
183
				[ 'ar_rev_id' => $id ],
184
				__METHOD__ );
185
			if ( $row ) {
186
				$rev = Revision::newFromArchiveRow( $row );
0 ignored issues
show
Bug introduced by
It seems like $row defined by $dbr->selectRow('archive...d' => $id), __METHOD__) on line 182 can also be of type boolean; however, Revision::newFromArchiveRow() does only seem to accept object, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
187
				$title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
188
189
				return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
190
					'target' => $title->getPrefixedText(),
191
					'timestamp' => $rev->getTimestamp()
192
				] );
193
			}
194
		}
195
196
		return false;
197
	}
198
199
	/**
200
	 * Build a wikitext link toward a deleted revision, if viewable.
201
	 *
202
	 * @param int $id Revision ID
203
	 *
204
	 * @return string Wikitext fragment
205
	 */
206
	public function deletedIdMarker( $id ) {
207
		$link = $this->deletedLink( $id );
208
		if ( $link ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $link of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
209
			return "[$link $id]";
210
		} else {
211
			return $id;
212
		}
213
	}
214
215
	private function showMissingRevision() {
216
		$out = $this->getOutput();
217
218
		$missing = [];
219
		if ( $this->mOldRev === null ||
220
			( $this->mOldRev && $this->mOldContent === null )
221
		) {
222
			$missing[] = $this->deletedIdMarker( $this->mOldid );
223
		}
224
		if ( $this->mNewRev === null ||
225
			( $this->mNewRev && $this->mNewContent === null )
226
		) {
227
			$missing[] = $this->deletedIdMarker( $this->mNewid );
228
		}
229
230
		$out->setPageTitle( $this->msg( 'errorpagetitle' ) );
231
		$msg = $this->msg( 'difference-missing-revision' )
232
			->params( $this->getLanguage()->listToText( $missing ) )
233
			->numParams( count( $missing ) )
234
			->parseAsBlock();
235
		$out->addHTML( $msg );
236
	}
237
238
	public function showDiffPage( $diffOnly = false ) {
239
240
		# Allow frames except in certain special cases
241
		$out = $this->getOutput();
242
		$out->allowClickjacking();
243
		$out->setRobotPolicy( 'noindex,nofollow' );
244
245
		if ( !$this->loadRevisionData() ) {
246
			$this->showMissingRevision();
247
248
			return;
249
		}
250
251
		$user = $this->getUser();
252
		$permErrors = $this->mNewPage->getUserPermissionsErrors( 'read', $user );
253
		if ( $this->mOldPage ) { # mOldPage might not be set, see below.
254
			$permErrors = wfMergeErrorArrays( $permErrors,
255
				$this->mOldPage->getUserPermissionsErrors( 'read', $user ) );
256
		}
257
		if ( count( $permErrors ) ) {
258
			throw new PermissionsError( 'read', $permErrors );
259
		}
260
261
		$rollback = '';
262
263
		$query = [];
264
		# Carry over 'diffonly' param via navigation links
265
		if ( $diffOnly != $user->getBoolOption( 'diffonly' ) ) {
266
			$query['diffonly'] = $diffOnly;
267
		}
268
		# Cascade unhide param in links for easy deletion browsing
269
		if ( $this->unhide ) {
270
			$query['unhide'] = 1;
271
		}
272
273
		# Check if one of the revisions is deleted/suppressed
274
		$deleted = $suppressed = false;
275
		$allowed = $this->mNewRev->userCan( Revision::DELETED_TEXT, $user );
276
277
		$revisionTools = [];
278
279
		# mOldRev is false if the difference engine is called with a "vague" query for
280
		# a diff between a version V and its previous version V' AND the version V
281
		# is the first version of that article. In that case, V' does not exist.
282
		if ( $this->mOldRev === false ) {
283
			$out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
284
			$samePage = true;
285
			$oldHeader = '';
286
		} else {
287
			Hooks::run( 'DiffViewHeader', [ $this, $this->mOldRev, $this->mNewRev ] );
288
289
			if ( $this->mNewPage->equals( $this->mOldPage ) ) {
290
				$out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
291
				$samePage = true;
292
			} else {
293
				$out->setPageTitle( $this->msg( 'difference-title-multipage',
294
					$this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
295
				$out->addSubtitle( $this->msg( 'difference-multipage' ) );
296
				$samePage = false;
297
			}
298
299
			if ( $samePage && $this->mNewPage->quickUserCan( 'edit', $user ) ) {
300
				if ( $this->mNewRev->isCurrent() && $this->mNewPage->userCan( 'rollback', $user ) ) {
301
					$rollbackLink = Linker::generateRollback( $this->mNewRev, $this->getContext() );
302
					if ( $rollbackLink ) {
303
						$out->preventClickjacking();
304
						$rollback = '&#160;&#160;&#160;' . $rollbackLink;
305
					}
306
				}
307
308
				if ( !$this->mOldRev->isDeleted( Revision::DELETED_TEXT ) &&
309
					!$this->mNewRev->isDeleted( Revision::DELETED_TEXT )
310
				) {
311
					$undoLink = Html::element( 'a', [
312
							'href' => $this->mNewPage->getLocalURL( [
313
								'action' => 'edit',
314
								'undoafter' => $this->mOldid,
315
								'undo' => $this->mNewid
316
							] ),
317
							'title' => Linker::titleAttrib( 'undo' ),
318
						],
319
						$this->msg( 'editundo' )->text()
320
					);
321
					$revisionTools['mw-diff-undo'] = $undoLink;
322
				}
323
			}
324
325
			# Make "previous revision link"
326 View Code Duplication
			if ( $samePage && $this->mOldRev->getPrevious() ) {
327
				$prevlink = Linker::linkKnown(
328
					$this->mOldPage,
329
					$this->msg( 'previousdiff' )->escaped(),
330
					[ 'id' => 'differences-prevlink' ],
331
					[ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query
332
				);
333
			} else {
334
				$prevlink = '&#160;';
335
			}
336
337
			if ( $this->mOldRev->isMinor() ) {
338
				$oldminor = ChangesList::flag( 'minor' );
339
			} else {
340
				$oldminor = '';
341
			}
342
343
			$ldel = $this->revisionDeleteLink( $this->mOldRev );
344
			$oldRevisionHeader = $this->getRevisionHeader( $this->mOldRev, 'complete' );
345
			$oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
346
347
			$oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
348
				'<div id="mw-diff-otitle2">' .
349
				Linker::revUserTools( $this->mOldRev, !$this->unhide ) . '</div>' .
350
				'<div id="mw-diff-otitle3">' . $oldminor .
351
				Linker::revComment( $this->mOldRev, !$diffOnly, !$this->unhide ) . $ldel . '</div>' .
352
				'<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
353
				'<div id="mw-diff-otitle4">' . $prevlink . '</div>';
354
355 View Code Duplication
			if ( $this->mOldRev->isDeleted( Revision::DELETED_TEXT ) ) {
356
				$deleted = true; // old revisions text is hidden
357
				if ( $this->mOldRev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
358
					$suppressed = true; // also suppressed
359
				}
360
			}
361
362
			# Check if this user can see the revisions
363
			if ( !$this->mOldRev->userCan( Revision::DELETED_TEXT, $user ) ) {
364
				$allowed = false;
365
			}
366
		}
367
368
		# Make "next revision link"
369
		# Skip next link on the top revision
370 View Code Duplication
		if ( $samePage && !$this->mNewRev->isCurrent() ) {
371
			$nextlink = Linker::linkKnown(
372
				$this->mNewPage,
373
				$this->msg( 'nextdiff' )->escaped(),
374
				[ 'id' => 'differences-nextlink' ],
375
				[ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query
376
			);
377
		} else {
378
			$nextlink = '&#160;';
379
		}
380
381
		if ( $this->mNewRev->isMinor() ) {
382
			$newminor = ChangesList::flag( 'minor' );
383
		} else {
384
			$newminor = '';
385
		}
386
387
		# Handle RevisionDelete links...
388
		$rdel = $this->revisionDeleteLink( $this->mNewRev );
389
390
		# Allow extensions to define their own revision tools
391
		Hooks::run( 'DiffRevisionTools',
392
			[ $this->mNewRev, &$revisionTools, $this->mOldRev, $user ] );
393
		$formattedRevisionTools = [];
394
		// Put each one in parentheses (poor man's button)
395
		foreach ( $revisionTools as $key => $tool ) {
396
			$toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
397
			$element = Html::rawElement(
398
				'span',
399
				[ 'class' => $toolClass ],
400
				$this->msg( 'parentheses' )->rawParams( $tool )->escaped()
401
			);
402
			$formattedRevisionTools[] = $element;
403
		}
404
		$newRevisionHeader = $this->getRevisionHeader( $this->mNewRev, 'complete' ) .
405
			' ' . implode( ' ', $formattedRevisionTools );
406
		$newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
407
408
		$newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
409
			'<div id="mw-diff-ntitle2">' . Linker::revUserTools( $this->mNewRev, !$this->unhide ) .
410
			" $rollback</div>" .
411
			'<div id="mw-diff-ntitle3">' . $newminor .
412
			Linker::revComment( $this->mNewRev, !$diffOnly, !$this->unhide ) . $rdel . '</div>' .
413
			'<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
414
			'<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
415
416 View Code Duplication
		if ( $this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
417
			$deleted = true; // new revisions text is hidden
418
			if ( $this->mNewRev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
419
				$suppressed = true; // also suppressed
420
			}
421
		}
422
423
		# If the diff cannot be shown due to a deleted revision, then output
424
		# the diff header and links to unhide (if available)...
425
		if ( $deleted && ( !$this->unhide || !$allowed ) ) {
426
			$this->showDiffStyle();
427
			$multi = $this->getMultiNotice();
428
			$out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
429
			if ( !$allowed ) {
430
				$msg = $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff';
431
				# Give explanation for why revision is not visible
432
				$out->wrapWikiMsg( "<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
433
					[ $msg ] );
434
			} else {
435
				# Give explanation and add a link to view the diff...
436
				$query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
437
				$link = $this->getTitle()->getFullURL( $query );
438
				$msg = $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff';
439
				$out->wrapWikiMsg(
440
					"<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
441
					[ $msg, $link ]
442
				);
443
			}
444
		# Otherwise, output a regular diff...
445
		} else {
446
			# Add deletion notice if the user is viewing deleted content
447
			$notice = '';
448
			if ( $deleted ) {
449
				$msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
450
				$notice = "<div id='mw-$msg' class='mw-warning plainlinks'>\n" .
451
					$this->msg( $msg )->parse() .
452
					"</div>\n";
453
			}
454
			$this->showDiff( $oldHeader, $newHeader, $notice );
455
			if ( !$diffOnly ) {
456
				$this->renderNewRevision();
457
			}
458
		}
459
	}
460
461
	/**
462
	 * Build a link to mark a change as patrolled.
463
	 *
464
	 * Returns empty string if there's either no revision to patrol or the user is not allowed to.
465
	 * Side effect: When the patrol link is build, this method will call
466
	 * OutputPage::preventClickjacking() and load mediawiki.page.patrol.ajax.
467
	 *
468
	 * @return string HTML or empty string
469
	 */
470
	protected function markPatrolledLink() {
471
		if ( $this->mMarkPatrolledLink === null ) {
472
			$linkInfo = $this->getMarkPatrolledLinkInfo();
473
			// If false, there is no patrol link needed/allowed
474
			if ( !$linkInfo ) {
475
				$this->mMarkPatrolledLink = '';
476
			} else {
477
				$this->mMarkPatrolledLink = ' <span class="patrollink" data-mw="interface">[' . Linker::linkKnown(
478
					$this->mNewPage,
479
					$this->msg( 'markaspatrolleddiff' )->escaped(),
480
					[],
481
					[
482
						'action' => 'markpatrolled',
483
						'rcid' => $linkInfo['rcid'],
484
						'token' => $linkInfo['token'],
485
					]
486
				) . ']</span>';
487
			}
488
		}
489
		return $this->mMarkPatrolledLink;
490
	}
491
492
	/**
493
	 * Returns an array of meta data needed to build a "mark as patrolled" link and
494
	 * adds the mediawiki.page.patrol.ajax to the output.
495
	 *
496
	 * @return array|false An array of meta data for a patrol link (rcid & token)
497
	 *  or false if no link is needed
498
	 */
499
	protected function getMarkPatrolledLinkInfo() {
500
		global $wgUseRCPatrol, $wgEnableAPI, $wgEnableWriteAPI;
501
502
		$user = $this->getUser();
503
504
		// Prepare a change patrol link, if applicable
505
		if (
506
			// Is patrolling enabled and the user allowed to?
507
			$wgUseRCPatrol && $this->mNewPage->quickUserCan( 'patrol', $user ) &&
508
			// Only do this if the revision isn't more than 6 hours older
509
			// than the Max RC age (6h because the RC might not be cleaned out regularly)
510
			RecentChange::isInRCLifespan( $this->mNewRev->getTimestamp(), 21600 )
511
		) {
512
			// Look for an unpatrolled change corresponding to this diff
513
			$db = wfGetDB( DB_SLAVE );
514
			$change = RecentChange::newFromConds(
515
				[
516
					'rc_timestamp' => $db->timestamp( $this->mNewRev->getTimestamp() ),
517
					'rc_this_oldid' => $this->mNewid,
518
					'rc_patrolled' => 0
519
				],
520
				__METHOD__
521
			);
522
523
			if ( $change && !$change->getPerformer()->equals( $user ) ) {
524
				$rcid = $change->getAttribute( 'rc_id' );
525
			} else {
526
				// None found or the page has been created by the current user.
527
				// If the user could patrol this it already would be patrolled
528
				$rcid = 0;
529
			}
530
			// Build the link
531
			if ( $rcid ) {
532
				$this->getOutput()->preventClickjacking();
533
				if ( $wgEnableAPI && $wgEnableWriteAPI
534
					&& $user->isAllowed( 'writeapi' )
535
				) {
536
					$this->getOutput()->addModules( 'mediawiki.page.patrol.ajax' );
537
				}
538
539
				$token = $user->getEditToken( $rcid );
540
				return [
541
					'rcid' => $rcid,
542
					'token' => $token,
543
				];
544
			}
545
		}
546
547
		// No mark as patrolled link applicable
548
		return false;
549
	}
550
551
	/**
552
	 * @param Revision $rev
553
	 *
554
	 * @return string
555
	 */
556
	protected function revisionDeleteLink( $rev ) {
557
		$link = Linker::getRevDeleteLink( $this->getUser(), $rev, $rev->getTitle() );
0 ignored issues
show
Bug introduced by
It seems like $rev->getTitle() can be null; however, getRevDeleteLink() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
558
		if ( $link !== '' ) {
559
			$link = '&#160;&#160;&#160;' . $link . ' ';
560
		}
561
562
		return $link;
563
	}
564
565
	/**
566
	 * Show the new revision of the page.
567
	 */
568
	public function renderNewRevision() {
569
		$out = $this->getOutput();
570
		$revHeader = $this->getRevisionHeader( $this->mNewRev );
571
		# Add "current version as of X" title
572
		$out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
573
		<h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
574
		# Page content may be handled by a hooked call instead...
575
		# @codingStandardsIgnoreStart Ignoring long lines.
576
		if ( Hooks::run( 'ArticleContentOnDiff', [ $this, $out ] ) ) {
577
			$this->loadNewText();
578
			$out->setRevisionId( $this->mNewid );
579
			$out->setRevisionTimestamp( $this->mNewRev->getTimestamp() );
0 ignored issues
show
Security Bug introduced by
It seems like $this->mNewRev->getTimestamp() targeting Revision::getTimestamp() can also be of type false; however, OutputPage::setRevisionTimestamp() does only seem to accept string|null, did you maybe forget to handle an error condition?
Loading history...
580
			$out->setArticleFlag( true );
581
582
			// NOTE: only needed for B/C: custom rendering of JS/CSS via hook
583
			if ( $this->mNewPage->isCssJsSubpage() || $this->mNewPage->isCssOrJsPage() ) {
584
				// This needs to be synchronised with Article::showCssOrJsPage(), which sucks
585
				// Give hooks a chance to customise the output
586
				// @todo standardize this crap into one function
587
				if ( ContentHandler::runLegacyHooks( 'ShowRawCssJs', [ $this->mNewContent, $this->mNewPage, $out ] ) ) {
588
					// NOTE: deprecated hook, B/C only
589
					// use the content object's own rendering
590
					$cnt = $this->mNewRev->getContent();
591
					$po = $cnt ? $cnt->getParserOutput( $this->mNewRev->getTitle(), $this->mNewRev->getId() ) : null;
0 ignored issues
show
Bug introduced by
It seems like $this->mNewRev->getTitle() can be null; however, getParserOutput() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
592
					if ( $po ) {
593
						$out->addParserOutputContent( $po );
594
					}
595
				}
596
			} elseif ( !Hooks::run( 'ArticleContentViewCustom', [ $this->mNewContent, $this->mNewPage, $out ] ) ) {
597
				// Handled by extension
598
			} elseif ( !ContentHandler::runLegacyHooks( 'ArticleViewCustom', [ $this->mNewContent, $this->mNewPage, $out ] ) ) {
599
				// NOTE: deprecated hook, B/C only
600
				// Handled by extension
601
			} else {
602
				// Normal page
603
				if ( $this->getTitle()->equals( $this->mNewPage ) ) {
604
					// If the Title stored in the context is the same as the one
605
					// of the new revision, we can use its associated WikiPage
606
					// object.
607
					$wikiPage = $this->getWikiPage();
608
				} else {
609
					// Otherwise we need to create our own WikiPage object
610
					$wikiPage = WikiPage::factory( $this->mNewPage );
611
				}
612
613
				$parserOutput = $this->getParserOutput( $wikiPage, $this->mNewRev );
614
615
				# WikiPage::getParserOutput() should not return false, but just in case
616
				if ( $parserOutput ) {
617
					$out->addParserOutput( $parserOutput );
618
				}
619
			}
620
		}
621
		# @codingStandardsIgnoreEnd
622
623
		# Add redundant patrol link on bottom...
624
		$out->addHTML( $this->markPatrolledLink() );
625
626
	}
627
628
	protected function getParserOutput( WikiPage $page, Revision $rev ) {
629
		$parserOptions = $page->makeParserOptions( $this->getContext() );
630
631
		if ( !$rev->isCurrent() || !$rev->getTitle()->quickUserCan( 'edit', $this->getUser() ) ) {
632
			$parserOptions->setEditSection( false );
633
		}
634
635
		$parserOutput = $page->getParserOutput( $parserOptions, $rev->getId() );
636
637
		return $parserOutput;
638
	}
639
640
	/**
641
	 * Get the diff text, send it to the OutputPage object
642
	 * Returns false if the diff could not be generated, otherwise returns true
643
	 *
644
	 * @param string|bool $otitle Header for old text or false
645
	 * @param string|bool $ntitle Header for new text or false
646
	 * @param string $notice HTML between diff header and body
647
	 *
648
	 * @return bool
649
	 */
650
	public function showDiff( $otitle, $ntitle, $notice = '' ) {
651
		$diff = $this->getDiff( $otitle, $ntitle, $notice );
652
		if ( $diff === false ) {
653
			$this->showMissingRevision();
654
655
			return false;
656
		} else {
657
			$this->showDiffStyle();
658
			$this->getOutput()->addHTML( $diff );
659
660
			return true;
661
		}
662
	}
663
664
	/**
665
	 * Add style sheets and supporting JS for diff display.
666
	 */
667
	public function showDiffStyle() {
668
		$this->getOutput()->addModuleStyles( 'mediawiki.action.history.diff' );
669
	}
670
671
	/**
672
	 * Get complete diff table, including header
673
	 *
674
	 * @param string|bool $otitle Header for old text or false
675
	 * @param string|bool $ntitle Header for new text or false
676
	 * @param string $notice HTML between diff header and body
677
	 *
678
	 * @return mixed
679
	 */
680
	public function getDiff( $otitle, $ntitle, $notice = '' ) {
681
		$body = $this->getDiffBody();
682
		if ( $body === false ) {
683
			return false;
684
		}
685
686
		$multi = $this->getMultiNotice();
687
		// Display a message when the diff is empty
688
		if ( $body === '' ) {
689
			$notice .= '<div class="mw-diff-empty">' .
690
				$this->msg( 'diff-empty' )->parse() .
691
				"</div>\n";
692
		}
693
694
		return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
0 ignored issues
show
Bug introduced by
It seems like $otitle defined by parameter $otitle on line 680 can also be of type boolean; however, DifferenceEngine::addHeader() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
Bug introduced by
It seems like $ntitle defined by parameter $ntitle on line 680 can also be of type boolean; however, DifferenceEngine::addHeader() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
695
	}
696
697
	/**
698
	 * Get the diff table body, without header
699
	 *
700
	 * @return mixed (string/false)
701
	 */
702
	public function getDiffBody() {
703
		$this->mCacheHit = true;
704
		// Check if the diff should be hidden from this user
705
		if ( !$this->loadRevisionData() ) {
706
			return false;
707
		} elseif ( $this->mOldRev &&
708
			!$this->mOldRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
709
		) {
710
			return false;
711
		} elseif ( $this->mNewRev &&
712
			!$this->mNewRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
713
		) {
714
			return false;
715
		}
716
		// Short-circuit
717
		if ( $this->mOldRev === false || ( $this->mOldRev && $this->mNewRev
718
			&& $this->mOldRev->getId() == $this->mNewRev->getId() )
719
		) {
720
			return '';
721
		}
722
		// Cacheable?
723
		$key = false;
724
		$cache = ObjectCache::getMainWANInstance();
725
		if ( $this->mOldid && $this->mNewid ) {
726
			$key = $this->getDiffBodyCacheKey();
727
728
			// Try cache
729
			if ( !$this->mRefreshCache ) {
730
				$difftext = $cache->get( $key );
731
				if ( $difftext ) {
732
					wfIncrStats( 'diff_cache.hit' );
733
					$difftext = $this->localiseLineNumbers( $difftext );
734
					$difftext .= "\n<!-- diff cache key $key -->\n";
735
736
					return $difftext;
737
				}
738
			} // don't try to load but save the result
739
		}
740
		$this->mCacheHit = false;
741
742
		// Loadtext is permission safe, this just clears out the diff
743
		if ( !$this->loadText() ) {
744
			return false;
745
		}
746
747
		$difftext = $this->generateContentDiffBody( $this->mOldContent, $this->mNewContent );
748
749
		// Save to cache for 7 days
750
		if ( !Hooks::run( 'AbortDiffCache', [ &$this ] ) ) {
751
			wfIncrStats( 'diff_cache.uncacheable' );
752
		} elseif ( $key !== false && $difftext !== false ) {
753
			wfIncrStats( 'diff_cache.miss' );
754
			$cache->set( $key, $difftext, 7 * 86400 );
755
		} else {
756
			wfIncrStats( 'diff_cache.uncacheable' );
757
		}
758
		// Replace line numbers with the text in the user's language
759
		if ( $difftext !== false ) {
760
			$difftext = $this->localiseLineNumbers( $difftext );
0 ignored issues
show
Bug introduced by
It seems like $difftext can also be of type boolean; however, DifferenceEngine::localiseLineNumbers() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
761
		}
762
763
		return $difftext;
764
	}
765
766
	/**
767
	 * Returns the cache key for diff body text or content.
768
	 *
769
	 * @since 1.23
770
	 *
771
	 * @throws MWException
772
	 * @return string
773
	 */
774
	protected function getDiffBodyCacheKey() {
775
		if ( !$this->mOldid || !$this->mNewid ) {
776
			throw new MWException( 'mOldid and mNewid must be set to get diff cache key.' );
777
		}
778
779
		return wfMemcKey( 'diff', 'version', MW_DIFF_VERSION,
780
			'oldid', $this->mOldid, 'newid', $this->mNewid );
781
	}
782
783
	/**
784
	 * Generate a diff, no caching.
785
	 *
786
	 * This implementation uses generateTextDiffBody() to generate a diff based on the default
787
	 * serialization of the given Content objects. This will fail if $old or $new are not
788
	 * instances of TextContent.
789
	 *
790
	 * Subclasses may override this to provide a different rendering for the diff,
791
	 * perhaps taking advantage of the content's native form. This is required for all content
792
	 * models that are not text based.
793
	 *
794
	 * @since 1.21
795
	 *
796
	 * @param Content $old Old content
797
	 * @param Content $new New content
798
	 *
799
	 * @throws MWException If old or new content is not an instance of TextContent.
800
	 * @return bool|string
801
	 */
802
	public function generateContentDiffBody( Content $old, Content $new ) {
803 View Code Duplication
		if ( !( $old instanceof TextContent ) ) {
804
			throw new MWException( "Diff not implemented for " . get_class( $old ) . "; " .
805
				"override generateContentDiffBody to fix this." );
806
		}
807
808 View Code Duplication
		if ( !( $new instanceof TextContent ) ) {
809
			throw new MWException( "Diff not implemented for " . get_class( $new ) . "; "
810
				. "override generateContentDiffBody to fix this." );
811
		}
812
813
		$otext = $old->serialize();
814
		$ntext = $new->serialize();
815
816
		return $this->generateTextDiffBody( $otext, $ntext );
817
	}
818
819
	/**
820
	 * Generate a diff, no caching
821
	 *
822
	 * @param string $otext Old text, must be already segmented
823
	 * @param string $ntext New text, must be already segmented
824
	 *
825
	 * @return bool|string
826
	 * @deprecated since 1.21, use generateContentDiffBody() instead!
827
	 */
828
	public function generateDiffBody( $otext, $ntext ) {
829
		ContentHandler::deprecated( __METHOD__, "1.21" );
830
831
		return $this->generateTextDiffBody( $otext, $ntext );
832
	}
833
834
	/**
835
	 * Generate a diff, no caching
836
	 *
837
	 * @todo move this to TextDifferenceEngine, make DifferenceEngine abstract. At some point.
838
	 *
839
	 * @param string $otext Old text, must be already segmented
840
	 * @param string $ntext New text, must be already segmented
841
	 *
842
	 * @return bool|string
843
	 */
844
	public function generateTextDiffBody( $otext, $ntext ) {
845
		$diff = function() use ( $otext, $ntext ) {
846
			$time = microtime( true );
847
848
			$result = $this->textDiff( $otext, $ntext );
849
850
			$time = intval( ( microtime( true ) - $time ) * 1000 );
851
			$this->getStats()->timing( 'diff_time', $time );
0 ignored issues
show
Deprecated Code introduced by
The method ContextSource::getStats() has been deprecated with message: since 1.27 use a StatsdDataFactory from MediaWikiServices (preferably injected)

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

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

Loading history...
852
			// Log requests slower than 99th percentile
853
			if ( $time > 100 && $this->mOldPage && $this->mNewPage ) {
854
				wfDebugLog( 'diff',
855
					"$time ms diff: {$this->mOldid} -> {$this->mNewid} {$this->mNewPage}" );
856
			}
857
858
			return $result;
859
		};
860
861
		$error = function( $status ) {
862
			throw new FatalError( $status->getWikiText() );
863
		};
864
865
		// Use PoolCounter if the diff looks like it can be expensive
866
		if ( strlen( $otext ) + strlen( $ntext ) > 20000 ) {
867
			$work = new PoolCounterWorkViaCallback( 'diff',
868
				md5( $otext ) . md5( $ntext ),
869
				[ 'doWork' => $diff, 'error' => $error ]
870
			);
871
			return $work->execute();
872
		}
873
874
		return $diff();
875
	}
876
877
	/**
878
	 * Generates diff, to be wrapped internally in a logging/instrumentation
879
	 *
880
	 * @param string $otext Old text, must be already segmented
881
	 * @param string $ntext New text, must be already segmented
882
	 * @return bool|string
883
	 */
884
	protected function textDiff( $otext, $ntext ) {
885
		global $wgExternalDiffEngine, $wgContLang;
886
887
		$otext = str_replace( "\r\n", "\n", $otext );
888
		$ntext = str_replace( "\r\n", "\n", $ntext );
889
890
		if ( $wgExternalDiffEngine == 'wikidiff' || $wgExternalDiffEngine == 'wikidiff3' ) {
891
			wfDeprecated( "\$wgExternalDiffEngine = '{$wgExternalDiffEngine}'", '1.27' );
892
			$wgExternalDiffEngine = false;
893
		}
894
895
		if ( $wgExternalDiffEngine == 'wikidiff2' ) {
896
			if ( function_exists( 'wikidiff2_do_diff' ) ) {
897
				# Better external diff engine, the 2 may some day be dropped
898
				# This one does the escaping and segmenting itself
899
				$text = wikidiff2_do_diff( $otext, $ntext, 2 );
900
				$text .= $this->debug( 'wikidiff2' );
901
902
				return $text;
903
			}
904
		} elseif ( $wgExternalDiffEngine !== false ) {
905
			# Diff via the shell
906
			$tmpDir = wfTempDir();
907
			$tempName1 = tempnam( $tmpDir, 'diff_' );
908
			$tempName2 = tempnam( $tmpDir, 'diff_' );
909
910
			$tempFile1 = fopen( $tempName1, "w" );
911
			if ( !$tempFile1 ) {
912
				return false;
913
			}
914
			$tempFile2 = fopen( $tempName2, "w" );
915
			if ( !$tempFile2 ) {
916
				return false;
917
			}
918
			fwrite( $tempFile1, $otext );
919
			fwrite( $tempFile2, $ntext );
920
			fclose( $tempFile1 );
921
			fclose( $tempFile2 );
922
			$cmd = wfEscapeShellArg( $wgExternalDiffEngine, $tempName1, $tempName2 );
923
			$difftext = wfShellExec( $cmd );
924
			$difftext .= $this->debug( "external $wgExternalDiffEngine" );
925
			unlink( $tempName1 );
926
			unlink( $tempName2 );
927
928
			return $difftext;
929
		}
930
931
		# Native PHP diff
932
		$ota = explode( "\n", $wgContLang->segmentForDiff( $otext ) );
933
		$nta = explode( "\n", $wgContLang->segmentForDiff( $ntext ) );
934
		$diffs = new Diff( $ota, $nta );
935
		$formatter = new TableDiffFormatter();
936
		$difftext = $wgContLang->unsegmentForDiff( $formatter->format( $diffs ) );
937
938
		return $difftext;
939
	}
940
941
	/**
942
	 * Generate a debug comment indicating diff generating time,
943
	 * server node, and generator backend.
944
	 *
945
	 * @param string $generator : What diff engine was used
946
	 *
947
	 * @return string
948
	 */
949
	protected function debug( $generator = "internal" ) {
950
		global $wgShowHostnames;
951
		if ( !$this->enableDebugComment ) {
952
			return '';
953
		}
954
		$data = [ $generator ];
955
		if ( $wgShowHostnames ) {
956
			$data[] = wfHostname();
957
		}
958
		$data[] = wfTimestamp( TS_DB );
959
960
		return "<!-- diff generator: " .
961
			implode( " ", array_map( "htmlspecialchars", $data ) ) .
962
			" -->\n";
963
	}
964
965
	/**
966
	 * Replace line numbers with the text in the user's language
967
	 *
968
	 * @param string $text
969
	 *
970
	 * @return mixed
971
	 */
972
	public function localiseLineNumbers( $text ) {
973
		return preg_replace_callback(
974
			'/<!--LINE (\d+)-->/',
975
			[ &$this, 'localiseLineNumbersCb' ],
976
			$text
977
		);
978
	}
979
980
	public function localiseLineNumbersCb( $matches ) {
981
		if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
982
			return '';
983
		}
984
985
		return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
986
	}
987
988
	/**
989
	 * If there are revisions between the ones being compared, return a note saying so.
990
	 *
991
	 * @return string
992
	 */
993
	public function getMultiNotice() {
994
		if ( !is_object( $this->mOldRev ) || !is_object( $this->mNewRev ) ) {
995
			return '';
996
		} elseif ( !$this->mOldPage->equals( $this->mNewPage ) ) {
997
			// Comparing two different pages? Count would be meaningless.
998
			return '';
999
		}
1000
1001
		if ( $this->mOldRev->getTimestamp() > $this->mNewRev->getTimestamp() ) {
1002
			$oldRev = $this->mNewRev; // flip
1003
			$newRev = $this->mOldRev; // flip
1004
		} else { // normal case
1005
			$oldRev = $this->mOldRev;
1006
			$newRev = $this->mNewRev;
1007
		}
1008
1009
		// Sanity: don't show the notice if too many rows must be scanned
1010
		// @todo show some special message for that case
1011
		$nEdits = $this->mNewPage->countRevisionsBetween( $oldRev, $newRev, 1000 );
1012
		if ( $nEdits > 0 && $nEdits <= 1000 ) {
1013
			$limit = 100; // use diff-multi-manyusers if too many users
1014
			$users = $this->mNewPage->getAuthorsBetween( $oldRev, $newRev, $limit );
1015
			$numUsers = count( $users );
1016
1017
			if ( $numUsers == 1 && $users[0] == $newRev->getUserText( Revision::RAW ) ) {
1018
				$numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1019
			}
1020
1021
			return self::intermediateEditsMsg( $nEdits, $numUsers, $limit );
1022
		}
1023
1024
		return ''; // nothing
1025
	}
1026
1027
	/**
1028
	 * Get a notice about how many intermediate edits and users there are
1029
	 *
1030
	 * @param int $numEdits
1031
	 * @param int $numUsers
1032
	 * @param int $limit
1033
	 *
1034
	 * @return string
1035
	 */
1036
	public static function intermediateEditsMsg( $numEdits, $numUsers, $limit ) {
1037
		if ( $numUsers === 0 ) {
1038
			$msg = 'diff-multi-sameuser';
1039
		} elseif ( $numUsers > $limit ) {
1040
			$msg = 'diff-multi-manyusers';
1041
			$numUsers = $limit;
1042
		} else {
1043
			$msg = 'diff-multi-otherusers';
1044
		}
1045
1046
		return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1047
	}
1048
1049
	/**
1050
	 * Get a header for a specified revision.
1051
	 *
1052
	 * @param Revision $rev
1053
	 * @param string $complete 'complete' to get the header wrapped depending
1054
	 *        the visibility of the revision and a link to edit the page.
1055
	 *
1056
	 * @return string HTML fragment
1057
	 */
1058
	protected function getRevisionHeader( Revision $rev, $complete = '' ) {
1059
		$lang = $this->getLanguage();
1060
		$user = $this->getUser();
1061
		$revtimestamp = $rev->getTimestamp();
1062
		$timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1063
		$dateofrev = $lang->userDate( $revtimestamp, $user );
1064
		$timeofrev = $lang->userTime( $revtimestamp, $user );
1065
1066
		$header = $this->msg(
1067
			$rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1068
			$timestamp,
1069
			$dateofrev,
1070
			$timeofrev
1071
		)->escaped();
1072
1073
		if ( $complete !== 'complete' ) {
1074
			return $header;
1075
		}
1076
1077
		$title = $rev->getTitle();
1078
1079
		$header = Linker::linkKnown( $title, $header, [],
1080
			[ 'oldid' => $rev->getId() ] );
1081
1082
		if ( $rev->userCan( Revision::DELETED_TEXT, $user ) ) {
1083
			$editQuery = [ 'action' => 'edit' ];
1084
			if ( !$rev->isCurrent() ) {
1085
				$editQuery['oldid'] = $rev->getId();
1086
			}
1087
1088
			$key = $title->quickUserCan( 'edit', $user ) ? 'editold' : 'viewsourceold';
1089
			$msg = $this->msg( $key )->escaped();
1090
			$editLink = $this->msg( 'parentheses' )->rawParams(
1091
				Linker::linkKnown( $title, $msg, [], $editQuery ) )->escaped();
1092
			$header .= ' ' . Html::rawElement(
1093
				'span',
1094
				[ 'class' => 'mw-diff-edit' ],
1095
				$editLink
1096
			);
1097
			if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
1098
				$header = Html::rawElement(
1099
					'span',
1100
					[ 'class' => 'history-deleted' ],
1101
					$header
1102
				);
1103
			}
1104
		} else {
1105
			$header = Html::rawElement( 'span', [ 'class' => 'history-deleted' ], $header );
1106
		}
1107
1108
		return $header;
1109
	}
1110
1111
	/**
1112
	 * Add the header to a diff body
1113
	 *
1114
	 * @param string $diff Diff body
1115
	 * @param string $otitle Old revision header
1116
	 * @param string $ntitle New revision header
1117
	 * @param string $multi Notice telling user that there are intermediate
1118
	 *   revisions between the ones being compared
1119
	 * @param string $notice Other notices, e.g. that user is viewing deleted content
1120
	 *
1121
	 * @return string
1122
	 */
1123
	public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
1124
		// shared.css sets diff in interface language/dir, but the actual content
1125
		// is often in a different language, mostly the page content language/dir
1126
		$header = Html::openElement( 'table', [
1127
			'class' => [ 'diff', 'diff-contentalign-' . $this->getDiffLang()->alignStart() ],
1128
			'data-mw' => 'interface',
1129
		] );
1130
		$userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
1131
1132
		if ( !$diff && !$otitle ) {
1133
			$header .= "
1134
			<tr style='vertical-align: top;' lang='{$userLang}'>
1135
			<td class='diff-ntitle'>{$ntitle}</td>
1136
			</tr>";
1137
			$multiColspan = 1;
1138
		} else {
1139
			if ( $diff ) { // Safari/Chrome show broken output if cols not used
1140
				$header .= "
1141
				<col class='diff-marker' />
1142
				<col class='diff-content' />
1143
				<col class='diff-marker' />
1144
				<col class='diff-content' />";
1145
				$colspan = 2;
1146
				$multiColspan = 4;
1147
			} else {
1148
				$colspan = 1;
1149
				$multiColspan = 2;
1150
			}
1151
			if ( $otitle || $ntitle ) {
1152
				$header .= "
1153
				<tr style='vertical-align: top;' lang='{$userLang}'>
1154
				<td colspan='$colspan' class='diff-otitle'>{$otitle}</td>
1155
				<td colspan='$colspan' class='diff-ntitle'>{$ntitle}</td>
1156
				</tr>";
1157
			}
1158
		}
1159
1160
		if ( $multi != '' ) {
1161
			$header .= "<tr><td colspan='{$multiColspan}' style='text-align: center;' " .
1162
				"class='diff-multi' lang='{$userLang}'>{$multi}</td></tr>";
1163
		}
1164
		if ( $notice != '' ) {
1165
			$header .= "<tr><td colspan='{$multiColspan}' style='text-align: center;' " .
1166
				"lang='{$userLang}'>{$notice}</td></tr>";
1167
		}
1168
1169
		return $header . $diff . "</table>";
1170
	}
1171
1172
	/**
1173
	 * Use specified text instead of loading from the database
1174
	 * @param Content $oldContent
1175
	 * @param Content $newContent
1176
	 * @since 1.21
1177
	 */
1178
	public function setContent( Content $oldContent, Content $newContent ) {
1179
		$this->mOldContent = $oldContent;
1180
		$this->mNewContent = $newContent;
1181
1182
		$this->mTextLoaded = 2;
1183
		$this->mRevisionsLoaded = true;
1184
	}
1185
1186
	/**
1187
	 * Set the language in which the diff text is written
1188
	 * (Defaults to page content language).
1189
	 * @param Language|string $lang
1190
	 * @since 1.19
1191
	 */
1192
	public function setTextLanguage( $lang ) {
1193
		$this->mDiffLang = wfGetLangObj( $lang );
1194
	}
1195
1196
	/**
1197
	 * Maps a revision pair definition as accepted by DifferenceEngine constructor
1198
	 * to a pair of actual integers representing revision ids.
1199
	 *
1200
	 * @param int $old Revision id, e.g. from URL parameter 'oldid'
1201
	 * @param int|string $new Revision id or strings 'next' or 'prev', e.g. from URL parameter 'diff'
1202
	 *
1203
	 * @return int[] List of two revision ids, older first, later second.
1204
	 *     Zero signifies invalid argument passed.
1205
	 *     false signifies that there is no previous/next revision ($old is the oldest/newest one).
1206
	 */
1207
	public function mapDiffPrevNext( $old, $new ) {
1208
		if ( $new === 'prev' ) {
1209
			// Show diff between revision $old and the previous one. Get previous one from DB.
1210
			$newid = intval( $old );
1211
			$oldid = $this->getTitle()->getPreviousRevisionID( $newid );
1212
		} elseif ( $new === 'next' ) {
1213
			// Show diff between revision $old and the next one. Get next one from DB.
1214
			$oldid = intval( $old );
1215
			$newid = $this->getTitle()->getNextRevisionID( $oldid );
1216
		} else {
1217
			$oldid = intval( $old );
1218
			$newid = intval( $new );
1219
		}
1220
1221
		return [ $oldid, $newid ];
1222
	}
1223
1224
	/**
1225
	 * Load revision IDs
1226
	 */
1227
	private function loadRevisionIds() {
1228
		if ( $this->mRevisionsIdsLoaded ) {
1229
			return;
1230
		}
1231
1232
		$this->mRevisionsIdsLoaded = true;
1233
1234
		$old = $this->mOldid;
1235
		$new = $this->mNewid;
1236
1237
		list( $this->mOldid, $this->mNewid ) = self::mapDiffPrevNext( $old, $new );
1238
		if ( $new === 'next' && $this->mNewid === false ) {
1239
			# if no result, NewId points to the newest old revision. The only newer
1240
			# revision is cur, which is "0".
1241
			$this->mNewid = 0;
1242
		}
1243
1244
		Hooks::run(
1245
			'NewDifferenceEngine',
1246
			[ $this->getTitle(), &$this->mOldid, &$this->mNewid, $old, $new ]
1247
		);
1248
	}
1249
1250
	/**
1251
	 * Load revision metadata for the specified articles. If newid is 0, then compare
1252
	 * the old article in oldid to the current article; if oldid is 0, then
1253
	 * compare the current article to the immediately previous one (ignoring the
1254
	 * value of newid).
1255
	 *
1256
	 * If oldid is false, leave the corresponding revision object set
1257
	 * to false. This is impossible via ordinary user input, and is provided for
1258
	 * API convenience.
1259
	 *
1260
	 * @return bool
1261
	 */
1262
	public function loadRevisionData() {
1263
		if ( $this->mRevisionsLoaded ) {
1264
			return true;
1265
		}
1266
1267
		// Whether it succeeds or fails, we don't want to try again
1268
		$this->mRevisionsLoaded = true;
1269
1270
		$this->loadRevisionIds();
1271
1272
		// Load the new revision object
1273
		if ( $this->mNewid ) {
1274
			$this->mNewRev = Revision::newFromId( $this->mNewid );
1275
		} else {
1276
			$this->mNewRev = Revision::newFromTitle(
1277
				$this->getTitle(),
0 ignored issues
show
Bug introduced by
It seems like $this->getTitle() can be null; however, newFromTitle() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1278
				false,
1279
				Revision::READ_NORMAL
1280
			);
1281
		}
1282
1283
		if ( !$this->mNewRev instanceof Revision ) {
1284
			return false;
1285
		}
1286
1287
		// Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
1288
		$this->mNewid = $this->mNewRev->getId();
1289
		$this->mNewPage = $this->mNewRev->getTitle();
1290
1291
		// Load the old revision object
1292
		$this->mOldRev = false;
0 ignored issues
show
Documentation Bug introduced by
It seems like false of type false is incompatible with the declared type object<Revision> of property $mOldRev.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
1293
		if ( $this->mOldid ) {
1294
			$this->mOldRev = Revision::newFromId( $this->mOldid );
1295
		} elseif ( $this->mOldid === 0 ) {
1296
			$rev = $this->mNewRev->getPrevious();
1297
			if ( $rev ) {
1298
				$this->mOldid = $rev->getId();
1299
				$this->mOldRev = $rev;
1300
			} else {
1301
				// No previous revision; mark to show as first-version only.
1302
				$this->mOldid = false;
0 ignored issues
show
Documentation Bug introduced by
The property $mOldid was declared of type integer, but false is of type false. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
1303
				$this->mOldRev = false;
1304
			}
1305
		} /* elseif ( $this->mOldid === false ) leave mOldRev false; */
1306
1307
		if ( is_null( $this->mOldRev ) ) {
1308
			return false;
1309
		}
1310
1311
		if ( $this->mOldRev ) {
1312
			$this->mOldPage = $this->mOldRev->getTitle();
1313
		}
1314
1315
		// Load tags information for both revisions
1316
		$dbr = wfGetDB( DB_SLAVE );
1317
		if ( $this->mOldid !== false ) {
1318
			$this->mOldTags = $dbr->selectField(
1319
				'tag_summary',
1320
				'ts_tags',
1321
				[ 'ts_rev_id' => $this->mOldid ],
1322
				__METHOD__
1323
			);
1324
		} else {
1325
			$this->mOldTags = false;
1326
		}
1327
		$this->mNewTags = $dbr->selectField(
1328
			'tag_summary',
1329
			'ts_tags',
1330
			[ 'ts_rev_id' => $this->mNewid ],
1331
			__METHOD__
1332
		);
1333
1334
		return true;
1335
	}
1336
1337
	/**
1338
	 * Load the text of the revisions, as well as revision data.
1339
	 *
1340
	 * @return bool
1341
	 */
1342
	public function loadText() {
1343
		if ( $this->mTextLoaded == 2 ) {
1344
			return true;
1345
		}
1346
1347
		// Whether it succeeds or fails, we don't want to try again
1348
		$this->mTextLoaded = 2;
1349
1350
		if ( !$this->loadRevisionData() ) {
1351
			return false;
1352
		}
1353
1354 View Code Duplication
		if ( $this->mOldRev ) {
1355
			$this->mOldContent = $this->mOldRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
1356
			if ( $this->mOldContent === null ) {
1357
				return false;
1358
			}
1359
		}
1360
1361 View Code Duplication
		if ( $this->mNewRev ) {
1362
			$this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
1363
			if ( $this->mNewContent === null ) {
1364
				return false;
1365
			}
1366
		}
1367
1368
		return true;
1369
	}
1370
1371
	/**
1372
	 * Load the text of the new revision, not the old one
1373
	 *
1374
	 * @return bool
1375
	 */
1376
	public function loadNewText() {
1377
		if ( $this->mTextLoaded >= 1 ) {
1378
			return true;
1379
		}
1380
1381
		$this->mTextLoaded = 1;
1382
1383
		if ( !$this->loadRevisionData() ) {
1384
			return false;
1385
		}
1386
1387
		$this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
1388
1389
		return true;
1390
	}
1391
1392
}
1393