MediaWiki master
HistoryPager.php
Go to the documentation of this file.
1<?php
25
26use ChangesList;
27use ChangeTags;
29use HtmlArmor;
30use MapCacheLRU;
46use stdClass;
47use Xml;
48
54
55 public $mGroupByDate = true;
56
58
59 protected $oldIdChecked;
60
61 protected $preventClickjacking = false;
65 protected $parentLens;
66
68 protected $showTagEditUI;
69
71
73 private $tagFilter;
74
76 private $tagInvert;
77
79 private $notificationTimestamp;
80
81 private RevisionStore $revisionStore;
82 private WatchlistManager $watchlistManager;
83 private LinkBatchFactory $linkBatchFactory;
84 private CommentFormatter $commentFormatter;
85 private HookRunner $hookRunner;
86 private ChangeTagsStore $changeTagsStore;
87
91 private $revisions = [];
92
96 private $formattedComments = [];
97
112 public function __construct(
114 $year = 0,
115 $month = 0,
116 $day = 0,
117 $tagFilter = '',
118 $tagInvert = false,
119 array $conds = [],
120 LinkBatchFactory $linkBatchFactory = null,
121 WatchlistManager $watchlistManager = null,
122 CommentFormatter $commentFormatter = null,
123 HookContainer $hookContainer = null,
124 ChangeTagsStore $changeTagsStore = null
125 ) {
126 parent::__construct( $historyPage->getContext() );
127 $this->historyPage = $historyPage;
128 $this->tagFilter = $tagFilter;
129 $this->tagInvert = $tagInvert;
130 $this->getDateCond( $year, $month, $day );
131 $this->conds = $conds;
132 $this->showTagEditUI = ChangeTags::showTagEditingUI( $this->getAuthority() );
133 $this->tagsCache = new MapCacheLRU( 50 );
134 $services = MediaWikiServices::getInstance();
135 $this->revisionStore = $services->getRevisionStore();
136 $this->linkBatchFactory = $linkBatchFactory ?? $services->getLinkBatchFactory();
137 $this->watchlistManager = $watchlistManager
138 ?? $services->getWatchlistManager();
139 $this->commentFormatter = $commentFormatter ?? $services->getCommentFormatter();
140 $this->hookRunner = new HookRunner( $hookContainer ?? $services->getHookContainer() );
141 $this->notificationTimestamp = $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker )
142 ? $this->watchlistManager->getTitleNotificationTimestamp( $this->getUser(), $this->getTitle() )
143 : false;
144 $this->changeTagsStore = $changeTagsStore ?? $services->getChangeTagsStore();
145 }
146
147 // For hook compatibility...
148 public function getArticle() {
149 return $this->historyPage->getArticle();
150 }
151
152 protected function getSqlComment() {
153 if ( $this->conds ) {
154 return 'history page filtered'; // potentially slow, see CR r58153
155 } else {
156 return 'history page unfiltered';
157 }
158 }
159
160 public function getQueryInfo() {
161 $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $this->mDb )
162 ->joinComment()
163 ->joinUser()
164 ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
165 ->where( [ 'rev_page' => $this->getWikiPage()->getId() ] )
166 ->andWhere( $this->conds );
167
168 $queryInfo = $queryBuilder->getQueryInfo( 'join_conds' );
169 $this->changeTagsStore->modifyDisplayQuery(
170 $queryInfo['tables'],
171 $queryInfo['fields'],
172 $queryInfo['conds'],
173 $queryInfo['join_conds'],
174 $queryInfo['options'],
175 $this->tagFilter,
176 $this->tagInvert
177 );
178
179 $this->hookRunner->onPageHistoryPager__getQueryInfo( $this, $queryInfo );
180
181 return $queryInfo;
182 }
183
184 public function getIndexField() {
185 return [ [ 'rev_timestamp', 'rev_id' ] ];
186 }
187
188 protected function doBatchLookups() {
189 if ( !$this->hookRunner->onPageHistoryPager__doBatchLookups( $this, $this->mResult ) ) {
190 return;
191 }
192
193 # Do a link batch query
194 $batch = $this->linkBatchFactory->newLinkBatch();
195 $revIds = [];
196 $title = $this->getTitle();
197 foreach ( $this->mResult as $row ) {
198 if ( $row->rev_parent_id ) {
199 $revIds[] = (int)$row->rev_parent_id;
200 }
201 if ( $row->user_name !== null ) {
202 $batch->add( NS_USER, $row->user_name );
203 $batch->add( NS_USER_TALK, $row->user_name );
204 } else { # for anons or usernames of imported revisions
205 $batch->add( NS_USER, $row->rev_user_text );
206 $batch->add( NS_USER_TALK, $row->rev_user_text );
207 }
208 $this->revisions[] = $this->revisionStore->newRevisionFromRow(
209 $row,
210 RevisionStore::READ_NORMAL,
211 $title
212 );
213 }
214 $this->parentLens = $this->revisionStore->getRevisionSizes( $revIds );
215 $batch->execute();
216
217 # The keys of $this->formattedComments will be the same as the keys of $this->revisions
218 $this->formattedComments = $this->commentFormatter->createRevisionBatch()
219 ->revisions( $this->revisions )
220 ->authority( $this->getAuthority() )
221 ->samePage( false )
222 ->hideIfDeleted( true )
223 ->useParentheses( false )
224 ->execute();
225
226 $this->mResult->seek( 0 );
227 }
228
233 protected function getEmptyBody() {
234 return $this->msg( 'history-empty' )->escaped();
235 }
236
242 protected function getStartBody() {
243 $this->oldIdChecked = 0;
244 $s = '';
245 // Button container stored in $this->buttons for re-use in getEndBody()
246 $this->buttons = '';
247 if ( $this->getNumRows() > 0 ) {
248 $this->getOutput()->wrapWikiMsg( "<div class='mw-history-legend'>\n$1\n</div>", 'histlegend' );
249 // Main form for comparing revisions
250 $s = Html::openElement( 'form', [
251 'action' => wfScript(),
252 'id' => 'mw-history-compare'
253 ] ) . "\n";
254 $s .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n";
255
256 $this->buttons .= Html::openElement(
257 'div', [ 'class' => 'mw-history-compareselectedversions' ] );
258 $className = 'historysubmit mw-history-compareselectedversions-button cdx-button';
259 $attrs = [ 'class' => $className ]
260 + Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' );
261 $this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(),
262 $attrs
263 ) . "\n";
264
265 $actionButtons = '';
266 if ( $this->getAuthority()->isAllowed( 'deleterevision' ) ) {
267 $actionButtons .= $this->getRevisionButton(
268 'Revisiondelete', 'showhideselectedversions', 'mw-history-revisiondelete-button' );
269 }
270 if ( $this->showTagEditUI ) {
271 $actionButtons .= $this->getRevisionButton(
272 'EditTags', 'history-edit-tags', 'mw-history-editchangetags-button' );
273 }
274 if ( $actionButtons ) {
275 // Prepend a mini-form for changing visibility and editing tags.
276 // Checkboxes and buttons are associated with it using the <input form="…"> attribute.
277 //
278 // This makes the submitted parameters cleaner (on supporting browsers - all except IE 11):
279 // the 'mw-history-compare' form submission will omit the `ids[…]` parameters, and the
280 // 'mw-history-revisionactions' form submission will omit the `diff` and `oldid` parameters.
281 $s = Html::rawElement( 'form', [
282 'action' => wfScript(),
283 'id' => 'mw-history-revisionactions',
284 ] ) . "\n" . $s;
285 $s .= Html::hidden( 'type', 'revision', [ 'form' => 'mw-history-revisionactions' ] ) . "\n";
286
287 $this->buttons .= Xml::tags( 'div', [ 'class' =>
288 'mw-history-revisionactions' ], $actionButtons );
289 }
290
291 if ( $this->getAuthority()->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) {
292 $this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
293 }
294
295 $this->buttons .= '</div>';
296
297 $s .= $this->buttons;
298 }
299
300 $s .= '<section id="pagehistory" class="mw-pager-body">';
301
302 return $s;
303 }
304
305 private function getRevisionButton( $name, $msg, $class ) {
306 $this->preventClickjacking = true;
307 $element = Html::element(
308 'button',
309 [
310 'type' => 'submit',
311 'name' => 'title',
312 'value' => SpecialPage::getTitleFor( $name )->getPrefixedDBkey(),
313 'class' => [ 'cdx-button', $class, 'historysubmit' ],
314 'form' => 'mw-history-revisionactions',
315 ],
316 $this->msg( $msg )->text()
317 ) . "\n";
318 return $element;
319 }
320
321 protected function getEndBody() {
322 if ( $this->getNumRows() == 0 ) {
323 return '';
324 }
325 $s = '';
326 if ( $this->getNumRows() > 2 ) {
327 $s .= $this->buttons;
328 }
329 $s .= '</section>'; // closes section#pagehistory
330 $s .= '</form>';
331 return $s;
332 }
333
341 private function submitButton( $message, $attributes = [] ) {
342 # Disable submit button if history has 1 revision only
343 if ( $this->getNumRows() > 1 ) {
344 return Html::submitButton( $message, $attributes );
345 } else {
346 return '';
347 }
348 }
349
356 public function formatRow( $row ) {
357 $resultOffset = $this->getResultOffset();
358 $numRows = min( $this->mResult->numRows(), $this->mLimit );
359
360 $firstInList = $resultOffset === ( $this->mIsBackwards ? $numRows - 1 : 0 );
361 // Next in the list, previous in chronological order.
362 $nextResultOffset = $resultOffset + ( $this->mIsBackwards ? -1 : 1 );
363
364 $revRecord = $this->revisions[$resultOffset];
365 // This may only be null if the current line is the last one in the list.
366 $previousRevRecord = $this->revisions[$nextResultOffset] ?? null;
367
368 $latest = $revRecord->getId() === $this->getWikiPage()->getLatest();
369 $curlink = $this->curLink( $revRecord );
370 if ( $previousRevRecord ) {
371 // Display a link to compare to the previous revision
372 $lastlink = $this->lastLink( $revRecord, $previousRevRecord );
373 } elseif ( $this->mIsBackwards && $this->mOffset !== '' ) {
374 // When paging "backwards", we don't have the extra result for the next revision that would
375 // appear in the list, and we don't know whether this is the oldest revision or not.
376 // However, if an offset has been specified, then the user probably reached this page by
377 // navigating from the "next" page, therefore the next revision probably exists.
378 // Display a link using &oldid=prev (this skips some checks but that's fine).
379 $lastlink = $this->lastLink( $revRecord, null );
380 } else {
381 // Do not display a link, because this is the oldest revision of the page
382 $lastlink = Html::element( 'span', [
383 'class' => 'mw-history-histlinks-previous',
384 ], $this->historyPage->message['last'] );
385 }
386 $curLastlinks = Html::rawElement( 'span', [], $curlink ) .
387 Html::rawElement( 'span', [], $lastlink );
388 $histLinks = Html::rawElement(
389 'span',
390 [ 'class' => 'mw-history-histlinks mw-changeslist-links' ],
391 $curLastlinks
392 );
393
394 $diffButtons = $this->diffButtons( $revRecord, $firstInList );
395 $s = $histLinks . $diffButtons;
396
397 $link = $this->revLink( $revRecord );
398 $classes = [];
399
400 $del = '';
401 $canRevDelete = $this->getAuthority()->isAllowed( 'deleterevision' );
402 // Show checkboxes for each revision, to allow for revision deletion and
403 // change tags
404 if ( $canRevDelete || $this->showTagEditUI ) {
405 $this->preventClickjacking = true;
406 // If revision was hidden from sysops and we don't need the checkbox
407 // for anything else, disable it
408 if ( !$this->showTagEditUI
409 && !$revRecord->userCan( RevisionRecord::DELETED_RESTRICTED, $this->getAuthority() )
410 ) {
411 $del = Xml::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] );
412 // Otherwise, enable the checkbox...
413 } else {
414 $del = Xml::check( 'showhiderevisions', false,
415 [ 'name' => 'ids[' . $revRecord->getId() . ']', 'form' => 'mw-history-revisionactions' ] );
416 }
417 // User can only view deleted revisions...
418 } elseif ( $revRecord->getVisibility() && $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
419 // If revision was hidden from sysops, disable the link
420 if ( !$revRecord->userCan( RevisionRecord::DELETED_RESTRICTED, $this->getAuthority() ) ) {
421 $del = Linker::revDeleteLinkDisabled( false );
422 // Otherwise, show the link...
423 } else {
424 $query = [
425 'type' => 'revision',
426 'target' => $this->getTitle()->getPrefixedDBkey(),
427 'ids' => $revRecord->getId()
428 ];
429 $del .= Linker::revDeleteLink(
430 $query,
431 $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ),
432 false
433 );
434 }
435 }
436 if ( $del ) {
437 $s .= " $del ";
438 }
439
440 $lang = $this->getLanguage();
441 $dirmark = $lang->getDirMark();
442
443 $s .= " $link";
444 $s .= $dirmark;
445 $s .= " <span class='history-user'>" .
446 Linker::revUserTools( $revRecord, true, false ) . "</span>";
447 $s .= $dirmark;
448
449 if ( $revRecord->isMinor() ) {
450 $s .= ' ' . ChangesList::flag( 'minor', $this->getContext() );
451 }
452
453 # Sometimes rev_len isn't populated
454 if ( $revRecord->getSize() !== null ) {
455 # Size is always public data
456 $prevSize = $this->parentLens[$row->rev_parent_id] ?? 0;
457 $sDiff = ChangesList::showCharacterDifference( $prevSize, $revRecord->getSize() );
458 $fSize = Linker::formatRevisionSize( $revRecord->getSize() );
459 $s .= ' <span class="mw-changeslist-separator"></span> ' . "$fSize $sDiff";
460 }
461
462 # Include separator between character difference and following text
463 $s .= ' <span class="mw-changeslist-separator"></span> ';
464
465 # Text following the character difference is added just before running hooks
466 $comment = $this->formattedComments[$resultOffset];
467
468 if ( $comment === '' ) {
469 $defaultComment = $this->historyPage->message['changeslist-nocomment'];
470 $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
471 }
472 $s .= $comment;
473
474 if ( $this->notificationTimestamp && $row->rev_timestamp >= $this->notificationTimestamp ) {
475 $s .= ' <span class="updatedmarker">' . $this->historyPage->message['updatedmarker'] . '</span>';
476 $classes[] = 'mw-history-line-updated';
477 }
478
479 $pagerTools = new PagerTools(
480 $revRecord,
481 $previousRevRecord,
482 $latest && $previousRevRecord,
483 $this->hookRunner,
484 $this->getTitle(),
485 $this->getContext(),
486 $this->getLinkRenderer()
487 );
488 if ( $pagerTools->shouldPreventClickjacking() ) {
489 $this->preventClickjacking = true;
490 }
491 $s .= $pagerTools->toHTML();
492
493 # Tags
494 [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
495 $this->tagsCache->makeKey(
496 $row->ts_tags ?? '',
497 $this->getUser()->getName(),
498 $lang->getCode()
499 ),
501 $row->ts_tags,
502 'history',
503 $this->getContext()
504 )
505 );
506 $classes = array_merge( $classes, $newClasses );
507 if ( $tagSummary !== '' ) {
508 $s .= " $tagSummary";
509 }
510
511 $attribs = [ 'data-mw-revid' => $revRecord->getId() ];
512
513 $this->hookRunner->onPageHistoryLineEnding( $this, $row, $s, $classes, $attribs );
514 $attribs = array_filter( $attribs,
515 [ Sanitizer::class, 'isReservedDataAttribute' ],
516 ARRAY_FILTER_USE_KEY
517 );
518
519 if ( $classes ) {
520 $attribs['class'] = implode( ' ', $classes );
521 }
522
523 return Xml::tags( 'li', $attribs, $s ) . "\n";
524 }
525
532 private function revLink( RevisionRecord $rev ) {
533 return ChangesList::revDateLink( $rev, $this->getAuthority(), $this->getLanguage(),
534 $this->getTitle() );
535 }
536
543 private function curLink( RevisionRecord $rev ) {
544 $cur = $this->historyPage->message['cur'];
545 $latest = $this->getWikiPage()->getLatest();
546 if ( $latest === $rev->getId()
547 || !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
548 ) {
549 return Html::element( 'span', [
550 'class' => 'mw-history-histlinks-current',
551 ], $cur );
552 } else {
553 return $this->getLinkRenderer()->makeKnownLink(
554 $this->getTitle(),
555 new HtmlArmor( $cur ),
556 [
557 'class' => 'mw-history-histlinks-current',
558 'title' => $this->historyPage->message['tooltip-cur']
559 ],
560 [
561 'diff' => $latest,
562 'oldid' => $rev->getId()
563 ]
564 );
565 }
566 }
567
576 private function lastLink( RevisionRecord $prevRev, ?RevisionRecord $nextRev ) {
577 $last = $this->historyPage->message['last'];
578
579 if ( !$prevRev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ||
580 ( $nextRev && !$nextRev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) )
581 ) {
582 return Html::element( 'span', [
583 'class' => 'mw-history-histlinks-previous',
584 ], $last );
585 }
586
587 return $this->getLinkRenderer()->makeKnownLink(
588 $this->getTitle(),
589 new HtmlArmor( $last ),
590 [
591 'class' => 'mw-history-histlinks-previous',
592 'title' => $this->historyPage->message['tooltip-last']
593 ],
594 [
595 'diff' => 'prev', // T243569
596 'oldid' => $prevRev->getId()
597 ]
598 );
599 }
600
609 private function diffButtons( RevisionRecord $rev, $firstInList ) {
610 if ( $this->getNumRows() > 1 ) {
611 $id = $rev->getId();
612 $radio = [ 'type' => 'radio', 'value' => $id ];
614 if ( $firstInList ) {
615 $first = Xml::element( 'input',
616 array_merge( $radio, [
617 // Disable the hidden radio because it can still
618 // be selected with arrow keys on Firefox
619 'disabled' => '',
620 'name' => 'oldid',
621 'id' => 'mw-oldid-null' ] )
622 );
623 $checkmark = [ 'checked' => 'checked' ];
624 } else {
625 # Check visibility of old revisions
626 if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
627 $radio['disabled'] = 'disabled';
628 $checkmark = []; // We will check the next possible one
629 } elseif ( !$this->oldIdChecked ) {
630 $checkmark = [ 'checked' => 'checked' ];
631 $this->oldIdChecked = $id;
632 } else {
633 $checkmark = [];
634 }
635 $first = Xml::element( 'input',
636 array_merge( $radio, $checkmark, [
637 'name' => 'oldid',
638 'id' => "mw-oldid-$id" ] ) );
639 $checkmark = [];
640 }
641 $second = Xml::element( 'input',
642 array_merge( $radio, $checkmark, [
643 'name' => 'diff',
644 'id' => "mw-diff-$id" ] ) );
645
646 return $first . $second;
647 } else {
648 return '';
649 }
650 }
651
657 protected function isNavigationBarShown() {
658 if ( $this->getNumRows() == 0 ) {
659 return false;
660 }
661 return parent::isNavigationBarShown();
662 }
663
668 public function getPreventClickjacking() {
670 }
671
672}
673
678class_alias( HistoryPager::class, 'HistoryPager' );
const NS_USER
Definition Defines.php:66
const NS_USER_TALK
Definition Defines.php:67
wfScript( $script='index')
Get the URL path to a MediaWiki entry point.
static showTagEditingUI(Authority $performer)
Indicate whether change tag editing UI is relevant.
static formatSummaryRow( $tags, $unused, MessageLocalizer $localizer=null)
Creates HTML for the given tags.
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
getWikiPage()
Get the WikiPage object.
getContext()
Get the base IContextSource object.
This class handles printing the history page for an article.
Marks HTML that shouldn't be escaped.
Definition HtmlArmor.php:30
Store key-value entries in a size-limited in-memory LRU cache.
Gateway class for change_tags table.
This is the main service interface for converting single-line comments from various DB comment fields...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
This class is a collection of static functions that serve two purposes:
Definition Html.php:57
Class for generating clickable toggle links for a list of checkboxes.
Some internal bits split of from Skin.php.
Definition Linker.php:65
A class containing constants representing the names of configuration variables.
const ShowUpdatedMarker
Name constant for the ShowUpdatedMarker setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
getIndexField()
Returns the name of the index field.
getEmptyBody()
Returns message when query returns no revisions.
bool $showTagEditUI
Whether to show the tag editing UI.
getQueryInfo()
Provides all parameters needed for the main paged query.
getStartBody()
Creates begin of history list with a submit button.
formatRow( $row)
Returns a row from the history printout.
getPreventClickjacking()
Get the "prevent clickjacking" flag.
isNavigationBarShown()
Returns whether to show the "navigation bar".
getEndBody()
Hook into getBody() for the end of the list.
__construct(HistoryAction $historyPage, $year=0, $month=0, $day=0, $tagFilter='', $tagInvert=false, array $conds=[], LinkBatchFactory $linkBatchFactory=null, WatchlistManager $watchlistManager=null, CommentFormatter $commentFormatter=null, HookContainer $hookContainer=null, ChangeTagsStore $changeTagsStore=null)
doBatchLookups()
Called from getBody(), before getStartBody() is called and after doQuery() was called.
getSqlComment()
Get some text to go in brackets in the "function name" part of the SQL comment.
getNumRows()
Get the number of rows in the result set.
Generate a set of tools for a revision.
IndexPager with a formatted navigation bar.
getDateCond( $year, $month, $day=-1)
Set and return the offset timestamp such that we can get all revisions with a timestamp up to the spe...
HTML sanitizer for MediaWiki.
Definition Sanitizer.php:46
Page revision base class.
userCan( $field, Authority $performer)
Determine if the give authority is allowed to view a particular field of this revision,...
getId( $wikiId=self::LOCAL)
Get revision ID.
Service for looking up page revisions.
Parent class for all special pages.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
Module of static functions for generating XML.
Definition Xml.php:33
static element( $element, $attribs=null, $contents='', $allowShortTag=true)
Format an XML element with given attributes and, optionally, text content.
Definition Xml.php:50