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