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 public string $buttons;
60 public array $conds;
61
62 protected $oldIdChecked;
63
64 protected $preventClickjacking = false;
68 protected $parentLens;
69
71 protected $showTagEditUI;
72
74
76 private $tagFilter;
77
79 private $tagInvert;
80
82 private $notificationTimestamp;
83
84 private RevisionStore $revisionStore;
85 private WatchlistManager $watchlistManager;
86 private LinkBatchFactory $linkBatchFactory;
87 private CommentFormatter $commentFormatter;
88 private HookRunner $hookRunner;
89 private ChangeTagsStore $changeTagsStore;
90
94 private $revisions = [];
95
99 private $formattedComments = [];
100
115 public function __construct(
117 $year = 0,
118 $month = 0,
119 $day = 0,
120 $tagFilter = '',
121 $tagInvert = false,
122 array $conds = [],
123 LinkBatchFactory $linkBatchFactory = null,
124 WatchlistManager $watchlistManager = null,
125 CommentFormatter $commentFormatter = null,
126 HookContainer $hookContainer = null,
127 ChangeTagsStore $changeTagsStore = null
128 ) {
129 parent::__construct( $historyPage->getContext() );
130 $this->historyPage = $historyPage;
131 $this->tagFilter = $tagFilter;
132 $this->tagInvert = $tagInvert;
133 $this->getDateCond( $year, $month, $day );
134 $this->conds = $conds;
135 $this->showTagEditUI = ChangeTags::showTagEditingUI( $this->getAuthority() );
136 $this->tagsCache = new MapCacheLRU( 50 );
137 $services = MediaWikiServices::getInstance();
138 $this->revisionStore = $services->getRevisionStore();
139 $this->linkBatchFactory = $linkBatchFactory ?? $services->getLinkBatchFactory();
140 $this->watchlistManager = $watchlistManager
141 ?? $services->getWatchlistManager();
142 $this->commentFormatter = $commentFormatter ?? $services->getCommentFormatter();
143 $this->hookRunner = new HookRunner( $hookContainer ?? $services->getHookContainer() );
144 $this->notificationTimestamp = $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker )
145 ? $this->watchlistManager->getTitleNotificationTimestamp( $this->getUser(), $this->getTitle() )
146 : false;
147 $this->changeTagsStore = $changeTagsStore ?? $services->getChangeTagsStore();
148 }
149
150 // For hook compatibility…
151 public function getArticle() {
152 return $this->historyPage->getArticle();
153 }
154
155 protected function getSqlComment() {
156 if ( $this->conds ) {
157 return 'history page filtered'; // potentially slow, see CR r58153
158 } else {
159 return 'history page unfiltered';
160 }
161 }
162
163 public function getQueryInfo() {
164 $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $this->mDb )
165 ->joinComment()
166 ->joinUser()
167 ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
168 ->where( [ 'rev_page' => $this->getWikiPage()->getId() ] )
169 ->andWhere( $this->conds );
170
171 $queryInfo = $queryBuilder->getQueryInfo( 'join_conds' );
172 $this->changeTagsStore->modifyDisplayQuery(
173 $queryInfo['tables'],
174 $queryInfo['fields'],
175 $queryInfo['conds'],
176 $queryInfo['join_conds'],
177 $queryInfo['options'],
178 $this->tagFilter,
179 $this->tagInvert
180 );
181
182 $this->hookRunner->onPageHistoryPager__getQueryInfo( $this, $queryInfo );
183
184 return $queryInfo;
185 }
186
187 public function getIndexField() {
188 return [ [ 'rev_timestamp', 'rev_id' ] ];
189 }
190
191 protected function doBatchLookups() {
192 if ( !$this->hookRunner->onPageHistoryPager__doBatchLookups( $this, $this->mResult ) ) {
193 return;
194 }
195
196 # Do a link batch query
197 $batch = $this->linkBatchFactory->newLinkBatch();
198 $revIds = [];
199 $title = $this->getTitle();
200 foreach ( $this->mResult as $row ) {
201 if ( $row->rev_parent_id ) {
202 $revIds[] = (int)$row->rev_parent_id;
203 }
204 if ( $row->user_name !== null ) {
205 $batch->add( NS_USER, $row->user_name );
206 $batch->add( NS_USER_TALK, $row->user_name );
207 } else { # for anons or usernames of imported revisions
208 $batch->add( NS_USER, $row->rev_user_text );
209 $batch->add( NS_USER_TALK, $row->rev_user_text );
210 }
211 $this->revisions[] = $this->revisionStore->newRevisionFromRow(
212 $row,
213 IDBAccessObject::READ_NORMAL,
214 $title
215 );
216 }
217 $this->parentLens = $this->revisionStore->getRevisionSizes( $revIds );
218 $batch->execute();
219
220 # The keys of $this->formattedComments will be the same as the keys of $this->revisions
221 $this->formattedComments = $this->commentFormatter->createRevisionBatch()
222 ->revisions( $this->revisions )
223 ->authority( $this->getAuthority() )
224 ->samePage( false )
225 ->hideIfDeleted( true )
226 ->useParentheses( false )
227 ->execute();
228
229 $this->mResult->seek( 0 );
230 }
231
236 protected function getEmptyBody() {
237 return $this->msg( 'history-empty' )->escaped();
238 }
239
245 protected function getStartBody() {
246 $this->oldIdChecked = 0;
247 $s = '';
248 // Button container stored in $this->buttons for re-use in getEndBody()
249 $this->buttons = '';
250 if ( $this->getNumRows() > 0 ) {
251 $this->getOutput()->wrapWikiMsg( "<div class='mw-history-legend'>\n$1\n</div>", 'histlegend' );
252 // Main form for comparing revisions
253 $s = Html::openElement( 'form', [
254 'action' => wfScript(),
255 'id' => 'mw-history-compare'
256 ] ) . "\n";
257 $s .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n";
258
259 $this->buttons .= Html::openElement(
260 'div', [ 'class' => 'mw-history-compareselectedversions' ] );
261 $className = 'historysubmit mw-history-compareselectedversions-button cdx-button';
262 $attrs = [ 'class' => $className ]
263 + Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' );
264 $this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(),
265 $attrs
266 ) . "\n";
267
268 $actionButtons = '';
269 if ( $this->getAuthority()->isAllowed( 'deleterevision' ) ) {
270 $actionButtons .= $this->getRevisionButton(
271 'Revisiondelete', 'showhideselectedversions', 'mw-history-revisiondelete-button' );
272 }
273 if ( $this->showTagEditUI ) {
274 $actionButtons .= $this->getRevisionButton(
275 'EditTags', 'history-edit-tags', 'mw-history-editchangetags-button' );
276 }
277 if ( $actionButtons ) {
278 // Prepend a mini-form for changing visibility and editing tags.
279 // Checkboxes and buttons are associated with it using the <input form="…"> attribute.
280 //
281 // This makes the submitted parameters cleaner (on supporting browsers - all except IE 11):
282 // the 'mw-history-compare' form submission will omit the `ids[…]` parameters, and the
283 // 'mw-history-revisionactions' form submission will omit the `diff` and `oldid` parameters.
284 $s = Html::rawElement( 'form', [
285 'action' => wfScript(),
286 'id' => 'mw-history-revisionactions',
287 ] ) . "\n" . $s;
288 $s .= Html::hidden( 'type', 'revision', [ 'form' => 'mw-history-revisionactions' ] ) . "\n";
289
290 $this->buttons .= Html::rawElement( 'div', [ 'class' =>
291 'mw-history-revisionactions' ], $actionButtons );
292 }
293
294 if ( $this->getAuthority()->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) {
295 $this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
296 }
297
298 $this->buttons .= '</div>';
299
300 $s .= $this->buttons;
301 }
302
303 $s .= '<section id="pagehistory" class="mw-pager-body">';
304
305 return $s;
306 }
307
308 private function getRevisionButton( $name, $msg, $class ) {
309 $this->preventClickjacking = true;
310 $element = Html::element(
311 'button',
312 [
313 'type' => 'submit',
314 'name' => 'title',
315 'value' => SpecialPage::getTitleFor( $name )->getPrefixedDBkey(),
316 'class' => [ 'cdx-button', $class, 'historysubmit' ],
317 'form' => 'mw-history-revisionactions',
318 ],
319 $this->msg( $msg )->text()
320 ) . "\n";
321 return $element;
322 }
323
324 protected function getEndBody() {
325 if ( $this->getNumRows() == 0 ) {
326 return '';
327 }
328 $s = '';
329 if ( $this->getNumRows() > 2 ) {
330 $s .= $this->buttons;
331 }
332 $s .= '</section>'; // closes section#pagehistory
333 $s .= '</form>';
334 return $s;
335 }
336
344 private function submitButton( $message, $attributes = [] ) {
345 # Disable submit button if history has 1 revision only
346 if ( $this->getNumRows() > 1 ) {
347 return Html::submitButton( $message, $attributes );
348 } else {
349 return '';
350 }
351 }
352
359 public function formatRow( $row ) {
360 $resultOffset = $this->getResultOffset();
361 $numRows = min( $this->mResult->numRows(), $this->mLimit );
362
363 $firstInList = $resultOffset === ( $this->mIsBackwards ? $numRows - 1 : 0 );
364 // Next in the list, previous in chronological order.
365 $nextResultOffset = $resultOffset + ( $this->mIsBackwards ? -1 : 1 );
366
367 $revRecord = $this->revisions[$resultOffset];
368 // This may only be null if the current line is the last one in the list.
369 $previousRevRecord = $this->revisions[$nextResultOffset] ?? null;
370
371 $latest = $revRecord->getId() === $this->getWikiPage()->getLatest();
372 $curlink = $this->curLink( $revRecord );
373 if ( $previousRevRecord ) {
374 // Display a link to compare to the previous revision
375 $lastlink = $this->lastLink( $revRecord, $previousRevRecord );
376 } elseif ( $this->mIsBackwards && $this->mOffset !== '' ) {
377 // When paging "backwards", we don't have the extra result for the next revision that would
378 // appear in the list, and we don't know whether this is the oldest revision or not.
379 // However, if an offset has been specified, then the user probably reached this page by
380 // navigating from the "next" page, therefore the next revision probably exists.
381 // Display a link using &oldid=prev (this skips some checks but that's fine).
382 $lastlink = $this->lastLink( $revRecord, null );
383 } else {
384 // Do not display a link, because this is the oldest revision of the page
385 $lastlink = Html::element( 'span', [
386 'class' => 'mw-history-histlinks-previous',
387 ], $this->historyPage->message['last'] );
388 }
389 $curLastlinks = Html::rawElement( 'span', [], $curlink ) .
390 Html::rawElement( 'span', [], $lastlink );
391 $histLinks = Html::rawElement(
392 'span',
393 [ 'class' => 'mw-history-histlinks mw-changeslist-links' ],
394 $curLastlinks
395 );
396
397 $diffButtons = $this->diffButtons( $revRecord, $firstInList );
398 $s = $histLinks . $diffButtons;
399
400 $link = $this->revLink( $revRecord );
401 $classes = [];
402
403 $del = '';
404 $canRevDelete = $this->getAuthority()->isAllowed( 'deleterevision' );
405 // Show checkboxes for each revision, to allow for revision deletion and
406 // change tags
407 if ( $canRevDelete || $this->showTagEditUI ) {
408 $this->preventClickjacking = true;
409 // If revision was hidden from sysops and we don't need the checkbox
410 // for anything else, disable it
411 if ( !$this->showTagEditUI
412 && !$revRecord->userCan( RevisionRecord::DELETED_RESTRICTED, $this->getAuthority() )
413 ) {
414 $del = Html::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] );
415 // Otherwise, enable the checkbox…
416 } else {
417 $del = Html::check(
418 'ids[' . $revRecord->getId() . ']', false,
419 [ 'form' => 'mw-history-revisionactions' ]
420 );
421 }
422 // User can only view deleted revisions…
423 } elseif ( $revRecord->getVisibility() && $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
424 // If revision was hidden from sysops, disable the link
425 if ( !$revRecord->userCan( RevisionRecord::DELETED_RESTRICTED, $this->getAuthority() ) ) {
426 $del = Linker::revDeleteLinkDisabled( false );
427 // Otherwise, show the link…
428 } else {
429 $query = [
430 'type' => 'revision',
431 'target' => $this->getTitle()->getPrefixedDBkey(),
432 'ids' => $revRecord->getId()
433 ];
434 $del .= Linker::revDeleteLink(
435 $query,
436 $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ),
437 false
438 );
439 }
440 }
441 if ( $del ) {
442 $s .= " $del ";
443 }
444
445 $lang = $this->getLanguage();
446 $dirmark = $lang->getDirMark();
447
448 $s .= " $link";
449 $s .= $dirmark;
450 $s .= " <span class='history-user'>" .
451 Linker::revUserTools( $revRecord, true, false ) . "</span>";
452 $s .= $dirmark;
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 ),
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
524 if ( $classes ) {
525 $attribs['class'] = implode( ' ', $classes );
526 }
527
528 return Html::rawElement( 'li', $attribs, $s ) . "\n";
529 }
530
537 private function revLink( RevisionRecord $rev ) {
538 return ChangesList::revDateLink( $rev, $this->getAuthority(), $this->getLanguage(),
539 $this->getTitle() );
540 }
541
548 private function curLink( RevisionRecord $rev ) {
549 $cur = $this->historyPage->message['cur'];
550 $latest = $this->getWikiPage()->getLatest();
551 if ( $latest === $rev->getId()
552 || !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
553 ) {
554 return Html::element( 'span', [
555 'class' => 'mw-history-histlinks-current',
556 ], $cur );
557 } else {
558 return $this->getLinkRenderer()->makeKnownLink(
559 $this->getTitle(),
560 new HtmlArmor( $cur ),
561 [
562 'class' => 'mw-history-histlinks-current',
563 'title' => $this->historyPage->message['tooltip-cur']
564 ],
565 [
566 'diff' => $latest,
567 'oldid' => $rev->getId()
568 ]
569 );
570 }
571 }
572
581 private function lastLink( RevisionRecord $prevRev, ?RevisionRecord $nextRev ) {
582 $last = $this->historyPage->message['last'];
583
584 if ( !$prevRev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ||
585 ( $nextRev && !$nextRev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) )
586 ) {
587 return Html::element( 'span', [
588 'class' => 'mw-history-histlinks-previous',
589 ], $last );
590 }
591
592 return $this->getLinkRenderer()->makeKnownLink(
593 $this->getTitle(),
594 new HtmlArmor( $last ),
595 [
596 'class' => 'mw-history-histlinks-previous',
597 'title' => $this->historyPage->message['tooltip-last']
598 ],
599 [
600 'diff' => 'prev', // T243569
601 'oldid' => $prevRev->getId()
602 ]
603 );
604 }
605
614 private function diffButtons( RevisionRecord $rev, $firstInList ) {
615 if ( $this->getNumRows() > 1 ) {
616 $id = $rev->getId();
617 $radio = [ 'type' => 'radio', 'value' => $id ];
619 if ( $firstInList ) {
620 $first = Html::element( 'input',
621 array_merge( $radio, [
622 // Disable the hidden radio because it can still
623 // be selected with arrow keys on Firefox
624 'disabled' => '',
625 'name' => 'oldid',
626 'id' => 'mw-oldid-null' ] )
627 );
628 $checkmark = [ 'checked' => 'checked' ];
629 } else {
630 # Check visibility of old revisions
631 if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
632 $radio['disabled'] = 'disabled';
633 $checkmark = []; // We will check the next possible one
634 } elseif ( !$this->oldIdChecked ) {
635 $checkmark = [ 'checked' => 'checked' ];
636 $this->oldIdChecked = $id;
637 } else {
638 $checkmark = [];
639 }
640 $first = Html::element( 'input',
641 array_merge( $radio, $checkmark, [
642 'name' => 'oldid',
643 'id' => "mw-oldid-$id" ] ) );
644 $checkmark = [];
645 }
646 $second = Html::element( 'input',
647 array_merge( $radio, $checkmark, [
648 'name' => 'diff',
649 'id' => "mw-diff-$id" ] ) );
650
651 return $first . $second;
652 } else {
653 return '';
654 }
655 }
656
662 protected function isNavigationBarShown() {
663 if ( $this->getNumRows() == 0 ) {
664 return false;
665 }
666 return parent::isNavigationBarShown();
667 }
668
673 public function getPreventClickjacking() {
675 }
676
677}
678
680class_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.
getContext()
Get the IContextSource in use here.
Definition Action.php:117
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)