MediaWiki master
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 $revIds = [];
203 $title = $this->getTitle();
204 foreach ( $this->mResult as $row ) {
205 if ( $row->rev_parent_id ) {
206 $revIds[] = (int)$row->rev_parent_id;
207 }
208 if ( $row->user_name !== null ) {
209 $batch->addUser( new UserIdentityValue( (int)$row->user_id, $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,
216 IDBAccessObject::READ_NORMAL,
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( string $name, string $msg, string $class ): string {
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 $s .= ' ' . Html::rawElement( 'bdi', [ 'dir' => $lang->getDir() ], $link );
450 $s .= " <span class='history-user'>" .
451 Linker::revUserTools( $revRecord, true, false ) . "</span>";
452
453 if ( $revRecord->isMinor() ) {
454 $s .= ' ' . ChangesList::flag( 'minor', $this->getContext() );
455 }
456
457 # Sometimes rev_len isn't populated
458 if ( $revRecord->getSize() !== null ) {
459 # Size is always public data
460 $prevSize = $this->parentLens[$row->rev_parent_id] ?? 0;
461 $sDiff = ChangesList::showCharacterDifference( $prevSize, $revRecord->getSize() );
462 $fSize = Linker::formatRevisionSize( $revRecord->getSize() );
463 $s .= ' <span class="mw-changeslist-separator"></span> ' . "$fSize $sDiff";
464 }
465
466 # Include separator between character difference and following text
467 $s .= ' <span class="mw-changeslist-separator"></span> ';
468
469 # Text following the character difference is added just before running hooks
470 $comment = $this->formattedComments[$resultOffset];
471
472 if ( $comment === '' ) {
473 $defaultComment = $this->historyPage->message['changeslist-nocomment'];
474 $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
475 }
476 $s .= $comment;
477
478 if ( $this->notificationTimestamp && $row->rev_timestamp >= $this->notificationTimestamp ) {
479 $s .= ' <span class="updatedmarker">' . $this->historyPage->message['updatedmarker'] . '</span>';
480 $classes[] = 'mw-history-line-updated';
481 }
482
483 $pagerTools = new PagerTools(
484 $revRecord,
485 $previousRevRecord,
486 $latest && $previousRevRecord,
487 $this->hookRunner,
488 $this->getTitle(),
489 $this->getContext(),
490 $this->getLinkRenderer()
491 );
492 if ( $pagerTools->shouldPreventClickjacking() ) {
493 $this->preventClickjacking = true;
494 }
495 $s .= $pagerTools->toHTML();
496
497 # Tags
498 [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
499 $this->tagsCache->makeKey(
500 $row->ts_tags ?? '',
501 $this->getUser()->getName(),
502 $lang->getCode()
503 ),
504 fn () => ChangeTags::formatSummaryRow(
505 $row->ts_tags,
506 'history',
507 $this->getContext()
508 )
509 );
510 $classes = array_merge( $classes, $newClasses );
511 if ( $tagSummary !== '' ) {
512 $s .= " $tagSummary";
513 }
514
515 $attribs = [ 'data-mw-revid' => $revRecord->getId() ];
516
517 $this->hookRunner->onPageHistoryLineEnding( $this, $row, $s, $classes, $attribs );
518 $attribs = array_filter( $attribs,
519 [ Sanitizer::class, 'isReservedDataAttribute' ],
520 ARRAY_FILTER_USE_KEY
521 );
522 $attribs['class'] = $classes;
523
524 return Html::rawElement( 'li', $attribs, $s ) . "\n";
525 }
526
533 private function revLink( RevisionRecord $rev ) {
534 return ChangesList::revDateLink( $rev, $this->getAuthority(), $this->getLanguage(),
535 $this->getTitle() );
536 }
537
544 private function curLink( RevisionRecord $rev ) {
545 $cur = $this->historyPage->message['cur'];
546 $latest = $this->getWikiPage()->getLatest();
547 if ( $latest === $rev->getId()
548 || !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
549 ) {
550 return Html::element( 'span', [
551 'class' => 'mw-history-histlinks-current',
552 ], $cur );
553 } else {
554 return $this->getLinkRenderer()->makeKnownLink(
555 $this->getTitle(),
556 new HtmlArmor( $cur ),
557 [
558 'class' => 'mw-history-histlinks-current',
559 'title' => $this->historyPage->message['tooltip-cur']
560 ],
561 [
562 'diff' => $latest,
563 'oldid' => $rev->getId()
564 ]
565 );
566 }
567 }
568
577 private function lastLink( RevisionRecord $prevRev, ?RevisionRecord $nextRev ) {
578 $last = $this->historyPage->message['last'];
579
580 if ( !$prevRev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ||
581 ( $nextRev && !$nextRev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) )
582 ) {
583 return Html::element( 'span', [
584 'class' => 'mw-history-histlinks-previous',
585 ], $last );
586 }
587
588 return $this->getLinkRenderer()->makeKnownLink(
589 $this->getTitle(),
590 new HtmlArmor( $last ),
591 [
592 'class' => 'mw-history-histlinks-previous',
593 'title' => $this->historyPage->message['tooltip-last']
594 ],
595 [
596 'diff' => 'prev', // T243569
597 'oldid' => $prevRev->getId()
598 ]
599 );
600 }
601
610 private function diffButtons( RevisionRecord $rev, $firstInList ) {
611 if ( $this->getNumRows() > 1 ) {
612 $id = $rev->getId();
613 $radio = [ 'type' => 'radio', 'value' => $id ];
615 if ( $firstInList ) {
616 $first = Html::element( 'input',
617 array_merge( $radio, [
618 // Disable the hidden radio because it can still
619 // be selected with arrow keys on Firefox
620 'disabled' => '',
621 'name' => 'oldid',
622 'id' => 'mw-oldid-null' ] )
623 );
624 $checkmark = [ 'checked' => 'checked' ];
625 } else {
626 # Check visibility of old revisions
627 if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
628 $radio['disabled'] = 'disabled';
629 $checkmark = []; // We will check the next possible one
630 } elseif ( !$this->oldIdChecked ) {
631 $checkmark = [ 'checked' => 'checked' ];
632 $this->oldIdChecked = $id;
633 } else {
634 $checkmark = [];
635 }
636 $first = Html::element( 'input',
637 array_merge( $radio, $checkmark, [
638 'name' => 'oldid',
639 'id' => "mw-oldid-$id" ] ) );
640 $checkmark = [];
641 }
642 $second = Html::element( 'input',
643 array_merge( $radio, $checkmark, [
644 'name' => 'diff',
645 'id' => "mw-diff-$id" ] ) );
646
647 return $first . $second;
648 } else {
649 return '';
650 }
651 }
652
658 protected function isNavigationBarShown() {
659 if ( $this->getNumRows() == 0 ) {
660 return false;
661 }
662 return parent::isNavigationBarShown();
663 }
664
669 public function getPreventClickjacking() {
670 return $this->preventClickjacking;
671 }
672
673}
674
676class_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)