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