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