Completed
Branch master (939199)
by
unknown
39:35
created

includes/actions/HistoryAction.php (1 issue)

Upgrade to new PHP Analysis Engine

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

1
<?php
2
/**
3
 * Page history
4
 *
5
 * Split off from Article.php and Skin.php, 2003-12-22
6
 *
7
 * This program is free software; you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation; either version 2 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License along
18
 * with this program; if not, write to the Free Software Foundation, Inc.,
19
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20
 * http://www.gnu.org/copyleft/gpl.html
21
 *
22
 * @file
23
 * @ingroup Actions
24
 */
25
26
/**
27
 * This class handles printing the history page for an article. In order to
28
 * be efficient, it uses timestamps rather than offsets for paging, to avoid
29
 * costly LIMIT,offset queries.
30
 *
31
 * Construct it by passing in an Article, and call $h->history() to print the
32
 * history.
33
 *
34
 * @ingroup Actions
35
 */
36
class HistoryAction extends FormlessAction {
37
	const DIR_PREV = 0;
38
	const DIR_NEXT = 1;
39
40
	/** @var array Array of message keys and strings */
41
	public $message;
42
43
	public function getName() {
44
		return 'history';
45
	}
46
47
	public function requiresWrite() {
48
		return false;
49
	}
50
51
	public function requiresUnblock() {
52
		return false;
53
	}
54
55
	protected function getPageTitle() {
56
		return $this->msg( 'history-title', $this->getTitle()->getPrefixedText() )->text();
57
	}
58
59
	protected function getDescription() {
60
		// Creation of a subtitle link pointing to [[Special:Log]]
61
		return Linker::linkKnown(
62
			SpecialPage::getTitleFor( 'Log' ),
63
			$this->msg( 'viewpagelogs' )->escaped(),
64
			[],
65
			[ 'page' => $this->getTitle()->getPrefixedText() ]
66
		);
67
	}
68
69
	/**
70
	 * @return WikiPage|Article|ImagePage|CategoryPage|Page The Article object we are working on.
71
	 */
72
	public function getArticle() {
73
		return $this->page;
74
	}
75
76
	/**
77
	 * As we use the same small set of messages in various methods and that
78
	 * they are called often, we call them once and save them in $this->message
79
	 */
80
	private function preCacheMessages() {
81
		// Precache various messages
82
		if ( !isset( $this->message ) ) {
83
			$msgs = [ 'cur', 'last', 'pipe-separator' ];
84
			foreach ( $msgs as $msg ) {
85
				$this->message[$msg] = $this->msg( $msg )->escaped();
86
			}
87
		}
88
	}
89
90
	/**
91
	 * Print the history page for an article.
92
	 */
93
	function onView() {
94
		$out = $this->getOutput();
95
		$request = $this->getRequest();
96
97
		/**
98
		 * Allow client caching.
99
		 */
100
		if ( $out->checkLastModified( $this->page->getTouched() ) ) {
101
			return; // Client cache fresh and headers sent, nothing more to do.
102
		}
103
104
		$this->preCacheMessages();
105
		$config = $this->context->getConfig();
106
107
		# Fill in the file cache if not set already
108
		if ( HTMLFileCache::useFileCache( $this->getContext() ) ) {
109
			$cache = new HTMLFileCache( $this->getTitle(), 'history' );
110
			if ( !$cache->isCacheGood( /* Assume up to date */ ) ) {
111
				ob_start( [ &$cache, 'saveToFileCache' ] );
112
			}
113
		}
114
115
		// Setup page variables.
116
		$out->setFeedAppendQuery( 'action=history' );
117
		$out->addModules( 'mediawiki.action.history' );
118
		$out->addModuleStyles( [
119
			'mediawiki.action.history.styles',
120
			'mediawiki.special.changeslist',
121
		] );
122
		if ( $config->get( 'UseMediaWikiUIEverywhere' ) ) {
123
			$out = $this->getOutput();
124
			$out->addModuleStyles( [
125
				'mediawiki.ui.input',
126
				'mediawiki.ui.checkbox',
127
			] );
128
		}
129
130
		// Handle atom/RSS feeds.
131
		$feedType = $request->getVal( 'feed' );
132
		if ( $feedType ) {
133
			$this->feed( $feedType );
134
135
			return;
136
		}
137
138
		$this->addHelpLink( '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Page_history', true );
139
140
		// Fail nicely if article doesn't exist.
141
		if ( !$this->page->exists() ) {
142
			global $wgSend404Code;
143
			if ( $wgSend404Code ) {
144
				$out->setStatusCode( 404 );
145
			}
146
			$out->addWikiMsg( 'nohistory' );
147
			# show deletion/move log if there is an entry
148
			LogEventsList::showLogExtract(
149
				$out,
150
				[ 'delete', 'move' ],
151
				$this->getTitle(),
152
				'',
153
				[ 'lim' => 10,
154
					'conds' => [ "log_action != 'revision'" ],
155
					'showIfEmpty' => false,
156
					'msgKey' => [ 'moveddeleted-notice' ]
157
				]
158
			);
159
160
			return;
161
		}
162
163
		/**
164
		 * Add date selector to quickly get to a certain time
165
		 */
166
		$year = $request->getInt( 'year' );
167
		$month = $request->getInt( 'month' );
168
		$tagFilter = $request->getVal( 'tagfilter' );
169
		$tagSelector = ChangeTags::buildTagFilterSelector( $tagFilter );
170
171
		/**
172
		 * Option to show only revisions that have been (partially) hidden via RevisionDelete
173
		 */
174
		if ( $request->getBool( 'deleted' ) ) {
175
			$conds = [ 'rev_deleted != 0' ];
176
		} else {
177
			$conds = [];
178
		}
179
		if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
180
			$checkDeleted = Xml::checkLabel( $this->msg( 'history-show-deleted' )->text(),
181
				'deleted', 'mw-show-deleted-only', $request->getBool( 'deleted' ) ) . "\n";
182
		} else {
183
			$checkDeleted = '';
184
		}
185
186
		// Add the general form
187
		$action = htmlspecialchars( wfScript() );
188
		$out->addHTML(
189
			"<form action=\"$action\" method=\"get\" id=\"mw-history-searchform\">" .
190
			Xml::fieldset(
191
				$this->msg( 'history-fieldset-title' )->text(),
192
				false,
193
				[ 'id' => 'mw-history-search' ]
194
			) .
195
			Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n" .
196
			Html::hidden( 'action', 'history' ) . "\n" .
197
			Xml::dateMenu(
198
				( $year == null ? MWTimestamp::getLocalInstance()->format( 'Y' ) : $year ),
199
				$month
200
			) . '&#160;' .
201
			( $tagSelector ? ( implode( '&#160;', $tagSelector ) . '&#160;' ) : '' ) .
202
			$checkDeleted .
203
			Html::submitButton(
204
				$this->msg( 'historyaction-submit' )->text(),
205
				[],
206
				[ 'mw-ui-progressive' ]
207
			) . "\n" .
208
			'</fieldset></form>'
209
		);
210
211
		Hooks::run( 'PageHistoryBeforeList', [ &$this->page, $this->getContext() ] );
212
213
		// Create and output the list.
214
		$pager = new HistoryPager( $this, $year, $month, $tagFilter, $conds );
215
		$out->addHTML(
216
			$pager->getNavigationBar() .
217
			$pager->getBody() .
218
			$pager->getNavigationBar()
219
		);
220
		$out->preventClickjacking( $pager->getPreventClickjacking() );
221
222
	}
223
224
	/**
225
	 * Fetch an array of revisions, specified by a given limit, offset and
226
	 * direction. This is now only used by the feeds. It was previously
227
	 * used by the main UI but that's now handled by the pager.
228
	 *
229
	 * @param int $limit The limit number of revisions to get
230
	 * @param int $offset
231
	 * @param int $direction Either self::DIR_PREV or self::DIR_NEXT
232
	 * @return ResultWrapper
233
	 */
234
	function fetchRevisions( $limit, $offset, $direction ) {
235
		// Fail if article doesn't exist.
236
		if ( !$this->getTitle()->exists() ) {
237
			return new FakeResultWrapper( [] );
238
		}
239
240
		$dbr = wfGetDB( DB_REPLICA );
241
242
		if ( $direction === self::DIR_PREV ) {
243
			list( $dirs, $oper ) = [ "ASC", ">=" ];
244
		} else { /* $direction === self::DIR_NEXT */
245
			list( $dirs, $oper ) = [ "DESC", "<=" ];
246
		}
247
248
		if ( $offset ) {
249
			$offsets = [ "rev_timestamp $oper " . $dbr->addQuotes( $dbr->timestamp( $offset ) ) ];
250
		} else {
251
			$offsets = [];
252
		}
253
254
		$page_id = $this->page->getId();
255
256
		return $dbr->select( 'revision',
257
			Revision::selectFields(),
258
			array_merge( [ 'rev_page' => $page_id ], $offsets ),
259
			__METHOD__,
260
			[ 'ORDER BY' => "rev_timestamp $dirs",
261
				'USE INDEX' => 'page_timestamp', 'LIMIT' => $limit ]
262
		);
263
	}
264
265
	/**
266
	 * Output a subscription feed listing recent edits to this page.
267
	 *
268
	 * @param string $type Feed type
269
	 */
270
	function feed( $type ) {
271
		if ( !FeedUtils::checkFeedOutput( $type ) ) {
272
			return;
273
		}
274
		$request = $this->getRequest();
275
276
		$feedClasses = $this->context->getConfig()->get( 'FeedClasses' );
277
		/** @var RSSFeed|AtomFeed $feed */
278
		$feed = new $feedClasses[$type](
279
			$this->getTitle()->getPrefixedText() . ' - ' .
280
			$this->msg( 'history-feed-title' )->inContentLanguage()->text(),
281
			$this->msg( 'history-feed-description' )->inContentLanguage()->text(),
282
			$this->getTitle()->getFullURL( 'action=history' )
283
		);
284
285
		// Get a limit on number of feed entries. Provide a sane default
286
		// of 10 if none is defined (but limit to $wgFeedLimit max)
287
		$limit = $request->getInt( 'limit', 10 );
288
		$limit = min(
289
			max( $limit, 1 ),
290
			$this->context->getConfig()->get( 'FeedLimit' )
291
		);
292
293
		$items = $this->fetchRevisions( $limit, 0, self::DIR_NEXT );
294
295
		// Generate feed elements enclosed between header and footer.
296
		$feed->outHeader();
297
		if ( $items->numRows() ) {
298
			foreach ( $items as $row ) {
299
				$feed->outItem( $this->feedItem( $row ) );
300
			}
301
		} else {
302
			$feed->outItem( $this->feedEmpty() );
303
		}
304
		$feed->outFooter();
305
	}
306
307
	function feedEmpty() {
308
		return new FeedItem(
309
			$this->msg( 'nohistory' )->inContentLanguage()->text(),
310
			$this->msg( 'history-feed-empty' )->inContentLanguage()->parseAsBlock(),
311
			$this->getTitle()->getFullURL(),
312
			wfTimestamp( TS_MW ),
313
			'',
314
			$this->getTitle()->getTalkPage()->getFullURL()
315
		);
316
	}
317
318
	/**
319
	 * Generate a FeedItem object from a given revision table row
320
	 * Borrows Recent Changes' feed generation functions for formatting;
321
	 * includes a diff to the previous revision (if any).
322
	 *
323
	 * @param stdClass|array $row Database row
324
	 * @return FeedItem
325
	 */
326
	function feedItem( $row ) {
327
		$rev = new Revision( $row );
328
		$rev->setTitle( $this->getTitle() );
329
		$text = FeedUtils::formatDiffRow(
330
			$this->getTitle(),
331
			$this->getTitle()->getPreviousRevisionID( $rev->getId() ),
332
			$rev->getId(),
333
			$rev->getTimestamp(),
334
			$rev->getComment()
335
		);
336
		if ( $rev->getComment() == '' ) {
337
			global $wgContLang;
338
			$title = $this->msg( 'history-feed-item-nocomment',
339
				$rev->getUserText(),
340
				$wgContLang->timeanddate( $rev->getTimestamp() ),
341
				$wgContLang->date( $rev->getTimestamp() ),
342
				$wgContLang->time( $rev->getTimestamp() ) )->inContentLanguage()->text();
343
		} else {
344
			$title = $rev->getUserText() .
345
				$this->msg( 'colon-separator' )->inContentLanguage()->text() .
346
				FeedItem::stripComment( $rev->getComment() );
347
		}
348
349
		return new FeedItem(
350
			$title,
351
			$text,
352
			$this->getTitle()->getFullURL( 'diff=' . $rev->getId() . '&oldid=prev' ),
353
			$rev->getTimestamp(),
354
			$rev->getUserText(),
355
			$this->getTitle()->getTalkPage()->getFullURL()
356
		);
357
	}
358
}
359
360
/**
361
 * @ingroup Pager
362
 * @ingroup Actions
363
 */
364
class HistoryPager extends ReverseChronologicalPager {
365
	/**
366
	 * @var bool|stdClass
367
	 */
368
	public $lastRow = false;
369
370
	public $counter, $historyPage, $buttons, $conds;
371
372
	protected $oldIdChecked;
373
374
	protected $preventClickjacking = false;
375
	/**
376
	 * @var array
377
	 */
378
	protected $parentLens;
379
380
	/** @var bool Whether to show the tag editing UI */
381
	protected $showTagEditUI;
382
383
	/**
384
	 * @param HistoryAction $historyPage
385
	 * @param string $year
386
	 * @param string $month
387
	 * @param string $tagFilter
388
	 * @param array $conds
389
	 */
390
	function __construct( $historyPage, $year = '', $month = '', $tagFilter = '', $conds = [] ) {
391
		parent::__construct( $historyPage->getContext() );
392
		$this->historyPage = $historyPage;
393
		$this->tagFilter = $tagFilter;
394
		$this->getDateCond( $year, $month );
395
		$this->conds = $conds;
396
		$this->showTagEditUI = ChangeTags::showTagEditingUI( $this->getUser() );
397
	}
398
399
	// For hook compatibility...
400
	function getArticle() {
401
		return $this->historyPage->getArticle();
402
	}
403
404
	function getSqlComment() {
405
		if ( $this->conds ) {
406
			return 'history page filtered'; // potentially slow, see CR r58153
407
		} else {
408
			return 'history page unfiltered';
409
		}
410
	}
411
412
	function getQueryInfo() {
413
		$queryInfo = [
414
			'tables' => [ 'revision', 'user' ],
415
			'fields' => array_merge( Revision::selectFields(), Revision::selectUserFields() ),
416
			'conds' => array_merge(
417
				[ 'rev_page' => $this->getWikiPage()->getId() ],
418
				$this->conds ),
419
			'options' => [ 'USE INDEX' => [ 'revision' => 'page_timestamp' ] ],
420
			'join_conds' => [ 'user' => Revision::userJoinCond() ],
421
		];
422
		ChangeTags::modifyDisplayQuery(
423
			$queryInfo['tables'],
424
			$queryInfo['fields'],
425
			$queryInfo['conds'],
426
			$queryInfo['join_conds'],
427
			$queryInfo['options'],
428
			$this->tagFilter
429
		);
430
		Hooks::run( 'PageHistoryPager::getQueryInfo', [ &$this, &$queryInfo ] );
431
432
		return $queryInfo;
433
	}
434
435
	function getIndexField() {
436
		return 'rev_timestamp';
437
	}
438
439
	/**
440
	 * @param stdClass $row
441
	 * @return string
442
	 */
443
	function formatRow( $row ) {
444
		if ( $this->lastRow ) {
445
			$latest = ( $this->counter == 1 && $this->mIsFirst );
446
			$firstInList = $this->counter == 1;
447
			$this->counter++;
448
449
			$notifTimestamp = $this->getConfig()->get( 'ShowUpdatedMarker' )
450
				? $this->getTitle()->getNotificationTimestamp( $this->getUser() )
451
				: false;
452
453
			$s = $this->historyLine(
454
				$this->lastRow, $row, $notifTimestamp, $latest, $firstInList );
455
		} else {
456
			$s = '';
457
		}
458
		$this->lastRow = $row;
459
460
		return $s;
461
	}
462
463
	function doBatchLookups() {
464
		if ( !Hooks::run( 'PageHistoryPager::doBatchLookups', [ $this, $this->mResult ] ) ) {
465
			return;
466
		}
467
468
		# Do a link batch query
469
		$this->mResult->seek( 0 );
470
		$batch = new LinkBatch();
471
		$revIds = [];
472
		foreach ( $this->mResult as $row ) {
473
			if ( $row->rev_parent_id ) {
474
				$revIds[] = $row->rev_parent_id;
475
			}
476
			if ( !is_null( $row->user_name ) ) {
477
				$batch->add( NS_USER, $row->user_name );
478
				$batch->add( NS_USER_TALK, $row->user_name );
479
			} else { # for anons or usernames of imported revisions
480
				$batch->add( NS_USER, $row->rev_user_text );
481
				$batch->add( NS_USER_TALK, $row->rev_user_text );
482
			}
483
		}
484
		$this->parentLens = Revision::getParentLengths( $this->mDb, $revIds );
0 ignored issues
show
It seems like $this->mDb can be null; however, getParentLengths() 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...
485
		$batch->execute();
486
		$this->mResult->seek( 0 );
487
	}
488
489
	/**
490
	 * Creates begin of history list with a submit button
491
	 *
492
	 * @return string HTML output
493
	 */
494
	function getStartBody() {
495
		$this->lastRow = false;
496
		$this->counter = 1;
497
		$this->oldIdChecked = 0;
498
499
		$this->getOutput()->wrapWikiMsg( "<div class='mw-history-legend'>\n$1\n</div>", 'histlegend' );
500
		$s = Html::openElement( 'form', [ 'action' => wfScript(),
501
			'id' => 'mw-history-compare' ] ) . "\n";
502
		$s .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n";
503
		$s .= Html::hidden( 'action', 'historysubmit' ) . "\n";
504
		$s .= Html::hidden( 'type', 'revision' ) . "\n";
505
506
		// Button container stored in $this->buttons for re-use in getEndBody()
507
		$this->buttons = '<div>';
508
		$className = 'historysubmit mw-history-compareselectedversions-button';
509
		$attrs = [ 'class' => $className ]
510
			+ Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' );
511
		$this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(),
512
			$attrs
513
		) . "\n";
514
515
		$user = $this->getUser();
516
		$actionButtons = '';
517
		if ( $user->isAllowed( 'deleterevision' ) ) {
518
			$actionButtons .= $this->getRevisionButton( 'revisiondelete', 'showhideselectedversions' );
519
		}
520
		if ( $this->showTagEditUI ) {
521
			$actionButtons .= $this->getRevisionButton( 'editchangetags', 'history-edit-tags' );
522
		}
523
		if ( $actionButtons ) {
524
			$this->buttons .= Xml::tags( 'div', [ 'class' =>
525
				'mw-history-revisionactions' ], $actionButtons );
526
		}
527
528
		if ( $user->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) {
529
			$this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
530
		}
531
532
		$this->buttons .= '</div>';
533
534
		$s .= $this->buttons;
535
		$s .= '<ul id="pagehistory">' . "\n";
536
537
		return $s;
538
	}
539
540
	private function getRevisionButton( $name, $msg ) {
541
		$this->preventClickjacking();
542
		# Note bug #20966, <button> is non-standard in IE<8
543
		$element = Html::element(
544
			'button',
545
			[
546
				'type' => 'submit',
547
				'name' => $name,
548
				'value' => '1',
549
				'class' => "historysubmit mw-history-$name-button",
550
			],
551
			$this->msg( $msg )->text()
552
		) . "\n";
553
		return $element;
554
	}
555
556
	function getEndBody() {
557
		if ( $this->lastRow ) {
558
			$latest = $this->counter == 1 && $this->mIsFirst;
559
			$firstInList = $this->counter == 1;
560
			if ( $this->mIsBackwards ) {
561
				# Next row is unknown, but for UI reasons, probably exists if an offset has been specified
562
				if ( $this->mOffset == '' ) {
563
					$next = null;
564
				} else {
565
					$next = 'unknown';
566
				}
567
			} else {
568
				# The next row is the past-the-end row
569
				$next = $this->mPastTheEndRow;
570
			}
571
			$this->counter++;
572
573
			$notifTimestamp = $this->getConfig()->get( 'ShowUpdatedMarker' )
574
				? $this->getTitle()->getNotificationTimestamp( $this->getUser() )
575
				: false;
576
577
			$s = $this->historyLine(
578
				$this->lastRow, $next, $notifTimestamp, $latest, $firstInList );
579
		} else {
580
			$s = '';
581
		}
582
		$s .= "</ul>\n";
583
		# Add second buttons only if there is more than one rev
584
		if ( $this->getNumRows() > 2 ) {
585
			$s .= $this->buttons;
586
		}
587
		$s .= '</form>';
588
589
		return $s;
590
	}
591
592
	/**
593
	 * Creates a submit button
594
	 *
595
	 * @param string $message Text of the submit button, will be escaped
596
	 * @param array $attributes Attributes
597
	 * @return string HTML output for the submit button
598
	 */
599
	function submitButton( $message, $attributes = [] ) {
600
		# Disable submit button if history has 1 revision only
601
		if ( $this->getNumRows() > 1 ) {
602
			return Html::submitButton( $message, $attributes );
603
		} else {
604
			return '';
605
		}
606
	}
607
608
	/**
609
	 * Returns a row from the history printout.
610
	 *
611
	 * @todo document some more, and maybe clean up the code (some params redundant?)
612
	 *
613
	 * @param stdClass $row The database row corresponding to the previous line.
614
	 * @param mixed $next The database row corresponding to the next line
615
	 *   (chronologically previous)
616
	 * @param bool|string $notificationtimestamp
617
	 * @param bool $latest Whether this row corresponds to the page's latest revision.
618
	 * @param bool $firstInList Whether this row corresponds to the first
619
	 *   displayed on this history page.
620
	 * @return string HTML output for the row
621
	 */
622
	function historyLine( $row, $next, $notificationtimestamp = false,
623
		$latest = false, $firstInList = false ) {
624
		$rev = new Revision( $row );
625
		$rev->setTitle( $this->getTitle() );
626
627
		if ( is_object( $next ) ) {
628
			$prevRev = new Revision( $next );
629
			$prevRev->setTitle( $this->getTitle() );
630
		} else {
631
			$prevRev = null;
632
		}
633
634
		$curlink = $this->curLink( $rev, $latest );
635
		$lastlink = $this->lastLink( $rev, $next );
636
		$curLastlinks = $curlink . $this->historyPage->message['pipe-separator'] . $lastlink;
637
		$histLinks = Html::rawElement(
638
			'span',
639
			[ 'class' => 'mw-history-histlinks' ],
640
			$this->msg( 'parentheses' )->rawParams( $curLastlinks )->escaped()
641
		);
642
643
		$diffButtons = $this->diffButtons( $rev, $firstInList );
644
		$s = $histLinks . $diffButtons;
645
646
		$link = $this->revLink( $rev );
647
		$classes = [];
648
649
		$del = '';
650
		$user = $this->getUser();
651
		$canRevDelete = $user->isAllowed( 'deleterevision' );
652
		// Show checkboxes for each revision, to allow for revision deletion and
653
		// change tags
654
		if ( $canRevDelete || $this->showTagEditUI ) {
655
			$this->preventClickjacking();
656
			// If revision was hidden from sysops and we don't need the checkbox
657
			// for anything else, disable it
658 View Code Duplication
			if ( !$this->showTagEditUI && !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) {
659
				$del = Xml::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] );
660
			// Otherwise, enable the checkbox...
661
			} else {
662
				$del = Xml::check( 'showhiderevisions', false,
663
					[ 'name' => 'ids[' . $rev->getId() . ']' ] );
664
			}
665
		// User can only view deleted revisions...
666
		} elseif ( $rev->getVisibility() && $user->isAllowed( 'deletedhistory' ) ) {
667
			// If revision was hidden from sysops, disable the link
668
			if ( !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) {
669
				$del = Linker::revDeleteLinkDisabled( false );
670
			// Otherwise, show the link...
671
			} else {
672
				$query = [ 'type' => 'revision',
673
					'target' => $this->getTitle()->getPrefixedDBkey(), 'ids' => $rev->getId() ];
674
				$del .= Linker::revDeleteLink( $query,
675
					$rev->isDeleted( Revision::DELETED_RESTRICTED ), false );
676
			}
677
		}
678
		if ( $del ) {
679
			$s .= " $del ";
680
		}
681
682
		$lang = $this->getLanguage();
683
		$dirmark = $lang->getDirMark();
684
685
		$s .= " $link";
686
		$s .= $dirmark;
687
		$s .= " <span class='history-user'>" .
688
			Linker::revUserTools( $rev, true ) . "</span>";
689
		$s .= $dirmark;
690
691
		if ( $rev->isMinor() ) {
692
			$s .= ' ' . ChangesList::flag( 'minor', $this->getContext() );
693
		}
694
695
		# Sometimes rev_len isn't populated
696
		if ( $rev->getSize() !== null ) {
697
			# Size is always public data
698
			$prevSize = isset( $this->parentLens[$row->rev_parent_id] )
699
				? $this->parentLens[$row->rev_parent_id]
700
				: 0;
701
			$sDiff = ChangesList::showCharacterDifference( $prevSize, $rev->getSize() );
702
			$fSize = Linker::formatRevisionSize( $rev->getSize() );
703
			$s .= ' <span class="mw-changeslist-separator">. .</span> ' . "$fSize $sDiff";
704
		}
705
706
		# Text following the character difference is added just before running hooks
707
		$s2 = Linker::revComment( $rev, false, true );
708
709
		if ( $notificationtimestamp && ( $row->rev_timestamp >= $notificationtimestamp ) ) {
710
			$s2 .= ' <span class="updatedmarker">' . $this->msg( 'updatedmarker' )->escaped() . '</span>';
711
			$classes[] = 'mw-history-line-updated';
712
		}
713
714
		$tools = [];
715
716
		# Rollback and undo links
717
		if ( $prevRev && $this->getTitle()->quickUserCan( 'edit', $user ) ) {
718
			if ( $latest && $this->getTitle()->quickUserCan( 'rollback', $user ) ) {
719
				// Get a rollback link without the brackets
720
				$rollbackLink = Linker::generateRollback(
721
					$rev,
722
					$this->getContext(),
723
					[ 'verify', 'noBrackets' ]
724
				);
725
				if ( $rollbackLink ) {
726
					$this->preventClickjacking();
727
					$tools[] = $rollbackLink;
728
				}
729
			}
730
731
			if ( !$rev->isDeleted( Revision::DELETED_TEXT )
732
				&& !$prevRev->isDeleted( Revision::DELETED_TEXT )
733
			) {
734
				# Create undo tooltip for the first (=latest) line only
735
				$undoTooltip = $latest
736
					? [ 'title' => $this->msg( 'tooltip-undo' )->text() ]
737
					: [];
738
				$undolink = Linker::linkKnown(
739
					$this->getTitle(),
740
					$this->msg( 'editundo' )->escaped(),
741
					$undoTooltip,
742
					[
743
						'action' => 'edit',
744
						'undoafter' => $prevRev->getId(),
745
						'undo' => $rev->getId()
746
					]
747
				);
748
				$tools[] = "<span class=\"mw-history-undo\">{$undolink}</span>";
749
			}
750
		}
751
		// Allow extension to add their own links here
752
		Hooks::run( 'HistoryRevisionTools', [ $rev, &$tools, $prevRev, $user ] );
753
754
		if ( $tools ) {
755
			$s2 .= ' ' . $this->msg( 'parentheses' )->rawParams( $lang->pipeList( $tools ) )->escaped();
756
		}
757
758
		# Tags
759
		list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow(
760
			$row->ts_tags,
761
			'history',
762
			$this->getContext()
763
		);
764
		$classes = array_merge( $classes, $newClasses );
765
		if ( $tagSummary !== '' ) {
766
			$s2 .= " $tagSummary";
767
		}
768
769
		# Include separator between character difference and following text
770
		if ( $s2 !== '' ) {
771
			$s .= ' <span class="mw-changeslist-separator">. .</span> ' . $s2;
772
		}
773
774
		Hooks::run( 'PageHistoryLineEnding', [ $this, &$row, &$s, &$classes ] );
775
776
		$attribs = [];
777
		if ( $classes ) {
778
			$attribs['class'] = implode( ' ', $classes );
779
		}
780
781
		return Xml::tags( 'li', $attribs, $s ) . "\n";
782
	}
783
784
	/**
785
	 * Create a link to view this revision of the page
786
	 *
787
	 * @param Revision $rev
788
	 * @return string
789
	 */
790
	function revLink( $rev ) {
791
		$date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $this->getUser() );
792
		$date = htmlspecialchars( $date );
793
		if ( $rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
794
			$link = Linker::linkKnown(
795
				$this->getTitle(),
796
				$date,
797
				[ 'class' => 'mw-changeslist-date' ],
798
				[ 'oldid' => $rev->getId() ]
799
			);
800
		} else {
801
			$link = $date;
802
		}
803
		if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
804
			$link = "<span class=\"history-deleted\">$link</span>";
805
		}
806
807
		return $link;
808
	}
809
810
	/**
811
	 * Create a diff-to-current link for this revision for this page
812
	 *
813
	 * @param Revision $rev
814
	 * @param bool $latest This is the latest revision of the page?
815
	 * @return string
816
	 */
817
	function curLink( $rev, $latest ) {
818
		$cur = $this->historyPage->message['cur'];
819
		if ( $latest || !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
820
			return $cur;
821
		} else {
822
			return Linker::linkKnown(
823
				$this->getTitle(),
824
				$cur,
825
				[],
826
				[
827
					'diff' => $this->getWikiPage()->getLatest(),
828
					'oldid' => $rev->getId()
829
				]
830
			);
831
		}
832
	}
833
834
	/**
835
	 * Create a diff-to-previous link for this revision for this page.
836
	 *
837
	 * @param Revision $prevRev The revision being displayed
838
	 * @param stdClass|string|null $next The next revision in list (that is
839
	 *        the previous one in chronological order).
840
	 *        May either be a row, "unknown" or null.
841
	 * @return string
842
	 */
843
	function lastLink( $prevRev, $next ) {
844
		$last = $this->historyPage->message['last'];
845
846
		if ( $next === null ) {
847
			# Probably no next row
848
			return $last;
849
		}
850
851
		if ( $next === 'unknown' ) {
852
			# Next row probably exists but is unknown, use an oldid=prev link
853
			return Linker::linkKnown(
854
				$this->getTitle(),
855
				$last,
856
				[],
857
				[
858
					'diff' => $prevRev->getId(),
859
					'oldid' => 'prev'
860
				]
861
			);
862
		}
863
864
		$nextRev = new Revision( $next );
865
866
		if ( !$prevRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
867
			|| !$nextRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
868
		) {
869
			return $last;
870
		}
871
872
		return Linker::linkKnown(
873
			$this->getTitle(),
874
			$last,
875
			[],
876
			[
877
				'diff' => $prevRev->getId(),
878
				'oldid' => $next->rev_id
879
			]
880
		);
881
	}
882
883
	/**
884
	 * Create radio buttons for page history
885
	 *
886
	 * @param Revision $rev
887
	 * @param bool $firstInList Is this version the first one?
888
	 *
889
	 * @return string HTML output for the radio buttons
890
	 */
891
	function diffButtons( $rev, $firstInList ) {
892
		if ( $this->getNumRows() > 1 ) {
893
			$id = $rev->getId();
894
			$radio = [ 'type' => 'radio', 'value' => $id ];
895
			/** @todo Move title texts to javascript */
896
			if ( $firstInList ) {
897
				$first = Xml::element( 'input',
898
					array_merge( $radio, [
899
						'style' => 'visibility:hidden',
900
						'name' => 'oldid',
901
						'id' => 'mw-oldid-null' ] )
902
				);
903
				$checkmark = [ 'checked' => 'checked' ];
904
			} else {
905
				# Check visibility of old revisions
906
				if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
907
					$radio['disabled'] = 'disabled';
908
					$checkmark = []; // We will check the next possible one
909
				} elseif ( !$this->oldIdChecked ) {
910
					$checkmark = [ 'checked' => 'checked' ];
911
					$this->oldIdChecked = $id;
912
				} else {
913
					$checkmark = [];
914
				}
915
				$first = Xml::element( 'input',
916
					array_merge( $radio, $checkmark, [
917
						'name' => 'oldid',
918
						'id' => "mw-oldid-$id" ] ) );
919
				$checkmark = [];
920
			}
921
			$second = Xml::element( 'input',
922
				array_merge( $radio, $checkmark, [
923
					'name' => 'diff',
924
					'id' => "mw-diff-$id" ] ) );
925
926
			return $first . $second;
927
		} else {
928
			return '';
929
		}
930
	}
931
932
	/**
933
	 * This is called if a write operation is possible from the generated HTML
934
	 * @param bool $enable
935
	 */
936
	function preventClickjacking( $enable = true ) {
937
		$this->preventClickjacking = $enable;
938
	}
939
940
	/**
941
	 * Get the "prevent clickjacking" flag
942
	 * @return bool
943
	 */
944
	function getPreventClickjacking() {
945
		return $this->preventClickjacking;
946
	}
947
948
}
949