Completed
Branch master (d58858)
by
unknown
28:23
created

DifferenceEngine::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 14
Code Lines 9

Duplication

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