MediaWiki master
ContributionsPager.php
Go to the documentation of this file.
1<?php
22namespace MediaWiki\Pager;
23
24use ChangesList;
25use ChangeTags;
26use HtmlArmor;
27use InvalidArgumentException;
28use MapCacheLRU;
49use stdClass;
52
58
60 public $mGroupByDate = true;
61
65 protected $messages;
66
70 protected $isArchive;
71
75 protected $runHooks;
76
80 protected $target;
81
85 private $namespace;
86
90 private $tagFilter;
91
95 private $tagInvert;
96
100 private $nsInvert;
101
106 private $associated;
107
111 private $deletedOnly;
112
116 private $topOnly;
117
121 private $newOnly;
122
126 private $hideMinor;
127
132 private $revisionsOnly;
133
135 private $preventClickjacking = false;
136
137 protected ?Title $currentPage;
139
143 private $mParentLens;
144
146 protected $targetUser;
147
152
153 private CommentFormatter $commentFormatter;
154 private HookRunner $hookRunner;
155 private LinkBatchFactory $linkBatchFactory;
158
160 private $formattedComments = [];
161
163 private $revisions = [];
164
166 private $tagsCache;
167
172 protected string $revisionIdField = 'rev_id';
173 protected string $revisionParentIdField = 'rev_parent_id';
174 protected string $revisionTimestampField = 'rev_timestamp';
175 protected string $revisionLengthField = 'rev_len';
176 protected string $revisionDeletedField = 'rev_deleted';
177 protected string $revisionMinorField = 'rev_minor_edit';
178 protected string $userNameField = 'rev_user_text';
179 protected string $pageNamespaceField = 'page_namespace';
180 protected string $pageTitleField = 'page_title';
181
194 public function __construct(
195 LinkRenderer $linkRenderer,
196 LinkBatchFactory $linkBatchFactory,
197 HookContainer $hookContainer,
200 CommentFormatter $commentFormatter,
201 UserFactory $userFactory,
202 IContextSource $context,
203 array $options,
205 ) {
206 $this->isArchive = $options['isArchive'] ?? false;
207 $this->runHooks = $options['runHooks'] ?? true;
208
209 // Set ->target before calling parent::__construct() so
210 // parent can call $this->getIndexField() and get the right result. Set
211 // the rest too just to keep things simple.
212 if ( $targetUser ) {
213 $this->target = $options['target'] ?? $targetUser->getName();
214 $this->targetUser = $targetUser;
215 } else {
216 // Use target option
217 // It's possible for the target to be empty. This is used by
218 // ContribsPagerTest and does not cause newFromName() to return
219 // false. It's probably not used by any production code.
220 $this->target = $options['target'] ?? '';
221 // @phan-suppress-next-line PhanPossiblyNullTypeMismatchProperty RIGOR_NONE never returns null
222 $this->targetUser = $userFactory->newFromName(
223 $this->target, UserRigorOptions::RIGOR_NONE
224 );
225 if ( !$this->targetUser ) {
226 // This can happen if the target contained "#". Callers
227 // typically pass user input through title normalization to
228 // avoid it.
229 throw new InvalidArgumentException( __METHOD__ . ': the user name is too ' .
230 'broken to use even with validation disabled.' );
231 }
232 }
233
234 $this->namespace = $options['namespace'] ?? '';
235 $this->tagFilter = $options['tagfilter'] ?? false;
236 $this->tagInvert = $options['tagInvert'] ?? false;
237 $this->nsInvert = $options['nsInvert'] ?? false;
238 $this->associated = $options['associated'] ?? false;
239
240 $this->deletedOnly = !empty( $options['deletedOnly'] );
241 $this->topOnly = !empty( $options['topOnly'] );
242 $this->newOnly = !empty( $options['newOnly'] );
243 $this->hideMinor = !empty( $options['hideMinor'] );
244 $this->revisionsOnly = !empty( $options['revisionsOnly'] );
245
246 parent::__construct( $context, $linkRenderer );
247
248 $msgs = [
249 'diff',
250 'hist',
251 'pipe-separator',
252 'uctop',
253 'changeslist-nocomment',
254 'undeleteviewlink',
255 'undeleteviewlink',
256 'deletionlog',
257 ];
258
259 foreach ( $msgs as $msg ) {
260 $this->messages[$msg] = $this->msg( $msg )->escaped();
261 }
262
263 // Date filtering: use timestamp if available
264 $startTimestamp = '';
265 $endTimestamp = '';
266 if ( isset( $options['start'] ) && $options['start'] ) {
267 $startTimestamp = $options['start'] . ' 00:00:00';
268 }
269 if ( isset( $options['end'] ) && $options['end'] ) {
270 $endTimestamp = $options['end'] . ' 23:59:59';
271 }
272 $this->getDateRangeCond( $startTimestamp, $endTimestamp );
273
274 $this->templateParser = new TemplateParser();
275 $this->linkBatchFactory = $linkBatchFactory;
276 $this->hookRunner = new HookRunner( $hookContainer );
277 $this->revisionStore = $revisionStore;
278 $this->namespaceInfo = $namespaceInfo;
279 $this->commentFormatter = $commentFormatter;
280 $this->tagsCache = new MapCacheLRU( 50 );
281 }
282
283 public function getDefaultQuery() {
284 $query = parent::getDefaultQuery();
285 $query['target'] = $this->target;
286
287 return $query;
288 }
289
299 public function reallyDoQuery( $offset, $limit, $order ) {
300 [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->buildQueryInfo(
301 $offset,
302 $limit,
303 $order
304 );
305
306 $options['MAX_EXECUTION_TIME'] =
308 /*
309 * This hook will allow extensions to add in additional queries, so they can get their data
310 * in My Contributions as well. Extensions should append their results to the $data array.
311 *
312 * Extension queries have to implement the navbar requirement as well. They should
313 * - have a column aliased as $pager->getIndexField()
314 * - have LIMIT set
315 * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset
316 * - have the ORDER BY specified based upon the details provided by the navbar
317 *
318 * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY
319 *
320 * &$data: an array of results of all contribs queries
321 * $pager: the ContribsPager object hooked into
322 * $offset: see phpdoc above
323 * $limit: see phpdoc above
324 * $descending: see phpdoc above
325 */
326 $dbr = $this->getDatabase();
327 $data = [ $dbr->newSelectQueryBuilder()
328 ->tables( is_array( $tables ) ? $tables : [ $tables ] )
329 ->fields( $fields )
330 ->conds( $conds )
331 ->caller( $fname )
332 ->options( $options )
333 ->joinConds( $join_conds )
334 ->setMaxExecutionTime( $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ) )
335 ->fetchResultSet() ];
336 if ( !$this->revisionsOnly && $this->runHooks ) {
337 // These hooks were moved from ContribsPager and DeletedContribsPager. For backwards
338 // compatability, they keep the same names. But they should be run for any contributions
339 // pager, otherwise the entries from extensions would be missing.
340 $reallyDoQueryHook = $this->isArchive ?
341 'onDeletedContribsPager__reallyDoQuery' :
342 'onContribsPager__reallyDoQuery';
343 // TODO: Range offsets are fairly important and all handlers should take care of it.
344 // If this hook will be replaced (e.g. unified with the DeletedContribsPager one),
345 // please consider passing [ $this->endOffset, $this->startOffset ] to it (T167577).
346 $this->hookRunner->$reallyDoQueryHook( $data, $this, $offset, $limit, $order );
347 }
348
349 $result = [];
350
351 // loop all results and collect them in an array
352 foreach ( $data as $query ) {
353 foreach ( $query as $i => $row ) {
354 // If the query results are in descending order, the indexes must also be in descending order
355 $index = $order === self::QUERY_ASCENDING ? $i : $limit - 1 - $i;
356 // Left-pad with zeroes, because these values will be sorted as strings
357 $index = str_pad( (string)$index, strlen( (string)$limit ), '0', STR_PAD_LEFT );
358 // use index column as key, allowing us to easily sort in PHP
359 $indexFieldValues = array_map(
360 static fn ( $fieldName ) => $row->$fieldName,
361 (array)$this->mIndexField
362 );
363 $result[implode( '-', $indexFieldValues ) . "-$index"] = $row;
364 }
365 }
366
367 // sort results
368 if ( $order === self::QUERY_ASCENDING ) {
369 ksort( $result );
370 } else {
371 krsort( $result );
372 }
373
374 // enforce limit
375 $result = array_slice( $result, 0, $limit );
376
377 // get rid of array keys
378 $result = array_values( $result );
379
380 return new FakeResultWrapper( $result );
381 }
382
389 abstract protected function getRevisionQuery();
390
391 public function getQueryInfo() {
392 $queryInfo = $this->getRevisionQuery();
393
394 if ( $this->deletedOnly ) {
395 $queryInfo['conds'][] = $this->revisionDeletedField . ' != 0';
396 }
397
398 if ( !$this->isArchive && $this->topOnly ) {
399 $queryInfo['conds'][] = $this->revisionIdField . ' = page_latest';
400 }
401
402 if ( $this->newOnly ) {
403 $queryInfo['conds'][] = $this->revisionParentIdField . ' = 0';
404 }
405
406 if ( $this->hideMinor ) {
407 $queryInfo['conds'][] = $this->revisionMinorField . ' = 0';
408 }
409
410 $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() );
411
412 // Paranoia: avoid brute force searches (T19342)
413 $dbr = $this->getDatabase();
414 if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
415 $queryInfo['conds'][] = $dbr->bitAnd(
416 $this->revisionDeletedField, RevisionRecord::DELETED_USER
417 ) . ' = 0';
418 } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
419 $queryInfo['conds'][] = $dbr->bitAnd(
420 $this->revisionDeletedField, RevisionRecord::SUPPRESSED_USER
421 ) . ' != ' . RevisionRecord::SUPPRESSED_USER;
422 }
423
424 // Index fields must be present in the result rows, as reallyDoQuery() tries to access them.
425 $indexFields = array_diff(
426 (array)$this->mIndexField,
427 $queryInfo['fields']
428 );
429
430 foreach ( $indexFields as $indexField ) {
431 // Skip if already added as an alias
432 if ( !array_key_exists( $indexField, $queryInfo['fields'] ) ) {
433 $queryInfo['fields'][] = $indexField;
434 }
435 }
436
437 MediaWikiServices::getInstance()->getChangeTagsStore()->modifyDisplayQuery(
438 $queryInfo['tables'],
439 $queryInfo['fields'],
440 $queryInfo['conds'],
441 $queryInfo['join_conds'],
442 $queryInfo['options'],
443 $this->tagFilter,
444 $this->tagInvert,
445 );
446
447 if ( !$this->isArchive && $this->runHooks ) {
448 $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo );
449 }
450
451 return $queryInfo;
452 }
453
454 protected function getNamespaceCond() {
455 if ( $this->namespace !== '' ) {
456 $dbr = $this->getDatabase();
457 $namespaces = [ $this->namespace ];
458 $eq_op = $this->nsInvert ? '!=' : '=';
459 if ( $this->associated ) {
460 $namespaces[] = $this->namespaceInfo->getAssociated( $this->namespace );
461 }
462 return [ $dbr->expr( $this->pageNamespaceField, $eq_op, $namespaces ) ];
463 }
464
465 return [];
466 }
467
471 public function getTagFilter() {
472 return $this->tagFilter;
473 }
474
478 public function getTagInvert() {
479 return $this->tagInvert;
480 }
481
485 public function getTarget() {
486 return $this->target;
487 }
488
492 public function isNewOnly() {
493 return $this->newOnly;
494 }
495
499 public function getNamespace() {
500 return $this->namespace;
501 }
502
503 protected function doBatchLookups() {
504 # Do a link batch query
505 $this->mResult->seek( 0 );
506 $parentRevIds = [];
507 $this->mParentLens = [];
508 $revisions = [];
509 $linkBatch = $this->linkBatchFactory->newLinkBatch();
510 # Give some pointers to make (last) links
511 foreach ( $this->mResult as $row ) {
512 $revisionRecord = $this->tryCreatingRevisionRecord( $row );
513 if ( !$revisionRecord ) {
514 continue;
515 }
516 if ( isset( $row->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) {
517 $parentRevIds[] = (int)$row->{$this->revisionParentIdField};
518 }
519 $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
520 if ( $this->target !== $row->{$this->userNameField} ) {
521 // If the target does not match the author, batch the author's talk page
522 $linkBatch->add( NS_USER_TALK, $row->{$this->userNameField} );
523 }
524 $linkBatch->add( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
525 $revisions[$row->{$this->revisionIdField}] = $this->createRevisionRecord( $row );
526 }
527 // Fetch rev_len/ar_len for revisions not already scanned above
528 // TODO: is it possible to make this fully abstract?
529 if ( $this->isArchive ) {
530 $parentRevIds = array_diff( $parentRevIds, array_keys( $this->mParentLens ) );
531 if ( $parentRevIds ) {
532 $result = $this->revisionStore
533 ->newArchiveSelectQueryBuilder( $this->getDatabase() )
534 ->clearFields()
535 ->fields( [ $this->revisionIdField, $this->revisionLengthField ] )
536 ->where( [ $this->revisionIdField => $parentRevIds ] )
537 ->caller( __METHOD__ )
538 ->fetchResultSet();
539 foreach ( $result as $row ) {
540 $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
541 }
542 }
543 }
544 $this->mParentLens += $this->revisionStore->getRevisionSizes(
545 array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
546 );
547 $linkBatch->execute();
548
549 $revisionBatch = $this->commentFormatter->createRevisionBatch()
550 ->authority( $this->getAuthority() )
551 ->revisions( $revisions );
552
553 if ( !$this->isArchive ) {
554 // Only show public comments, because this page might be public
555 $revisionBatch = $revisionBatch->hideIfDeleted();
556 }
557
558 $this->formattedComments = $revisionBatch->execute();
559
560 # For performance, save the revision objects for later.
561 # The array is indexed by rev_id. doBatchLookups() may be called
562 # multiple times with different results, so merge the revisions array,
563 # ignoring any duplicates.
564 $this->revisions += $revisions;
565 }
566
570 protected function getStartBody() {
571 return "<section class='mw-pager-body'>\n";
572 }
573
577 protected function getEndBody() {
578 return "</section>\n";
579 }
580
584 protected function getEmptyBody() {
585 return $this->msg( 'nocontribs' )->parse();
586 }
587
598 public function tryCreatingRevisionRecord( $row, $title = null ) {
599 if ( $row instanceof stdClass && isset( $row->{$this->revisionIdField} )
600 && isset( $this->revisions[$row->{$this->revisionIdField}] )
601 ) {
602 return $this->revisions[$row->{$this->revisionIdField}];
603 }
604
605 if (
606 $this->isArchive &&
607 $this->revisionStore->isRevisionRow( $row, 'archive' )
608 ) {
609 return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
610 }
611
612 if (
613 !$this->isArchive &&
614 $this->revisionStore->isRevisionRow( $row )
615 ) {
616 return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
617 }
618
619 return null;
620 }
621
629 public function createRevisionRecord( $row, $title = null ) {
630 if ( $this->isArchive ) {
631 return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
632 }
633
634 return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
635 }
636
643 protected function populateAttributes( $row, &$attributes ) {
644 $attributes['data-mw-revid'] = $this->currentRevRecord->getId();
645 }
646
653 protected function formatArticleLink( $row ) {
654 if ( !$this->currentPage ) {
655 return '';
656 }
657 $dir = $this->getLanguage()->getDir();
658 return Html::rawElement( 'bdi', [ 'dir' => $dir ], $this->getLinkRenderer()->makeLink(
659 $this->currentPage,
660 $this->currentPage->getPrefixedText(),
661 [ 'class' => 'mw-contributions-title' ],
662 $this->currentPage->isRedirect() ? [ 'redirect' => 'no' ] : []
663 ) );
664 }
665
672 protected function formatDiffHistLinks( $row ) {
673 if ( !$this->currentPage || !$this->currentRevRecord ) {
674 return '';
675 }
676 if ( $this->isArchive ) {
677 // Add the same links as DeletedContribsPager::formatRevisionRow
678 $undelete = SpecialPage::getTitleFor( 'Undelete' );
679 if ( $this->getAuthority()->isAllowed( 'deletedtext' ) ) {
680 $last = $this->getLinkRenderer()->makeKnownLink(
681 $undelete,
682 new HtmlArmor( $this->messages['diff'] ),
683 [],
684 [
685 'target' => $this->currentPage->getPrefixedText(),
686 'timestamp' => $this->currentRevRecord->getTimestamp(),
687 'diff' => 'prev'
688 ]
689 );
690 } else {
691 $last = $this->messages['diff'];
692 }
693
694 $logs = SpecialPage::getTitleFor( 'Log' );
695 $dellog = $this->getLinkRenderer()->makeKnownLink(
696 $logs,
697 new HtmlArmor( $this->messages['deletionlog'] ),
698 [],
699 [
700 'type' => 'delete',
701 'page' => $this->currentPage->getPrefixedText()
702 ]
703 );
704
705 $reviewlink = $this->getLinkRenderer()->makeKnownLink(
706 SpecialPage::getTitleFor( 'Undelete', $this->currentPage->getPrefixedDBkey() ),
707 new HtmlArmor( $this->messages['undeleteviewlink'] )
708 );
709
710 return Html::rawElement(
711 'span',
712 [ 'class' => 'mw-deletedcontribs-tools' ],
713 $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
714 [ $last, $dellog, $reviewlink ] ) )->escaped()
715 );
716 } else {
717 # Is there a visible previous revision?
718 if ( $this->currentRevRecord->getParentId() !== 0 &&
719 $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
720 ) {
721 $difftext = $this->getLinkRenderer()->makeKnownLink(
722 $this->currentPage,
723 new HtmlArmor( $this->messages['diff'] ),
724 [ 'class' => 'mw-changeslist-diff' ],
725 [
726 'diff' => 'prev',
727 'oldid' => $row->{$this->revisionIdField},
728 ]
729 );
730 } else {
731 $difftext = $this->messages['diff'];
732 }
733 $histlink = $this->getLinkRenderer()->makeKnownLink(
734 $this->currentPage,
735 new HtmlArmor( $this->messages['hist'] ),
736 [ 'class' => 'mw-changeslist-history' ],
737 [ 'action' => 'history' ]
738 );
739
740 // While it might be tempting to use a list here
741 // this would result in clutter and slows down navigating the content
742 // in assistive technology.
743 // See https://phabricator.wikimedia.org/T205581#4734812
744 return Html::rawElement( 'span',
745 [ 'class' => 'mw-changeslist-links' ],
746 // The spans are needed to ensure the dividing '|' elements are not
747 // themselves styled as links.
748 Html::rawElement( 'span', [], $difftext ) .
749 ' ' . // Space needed for separating two words.
750 Html::rawElement( 'span', [], $histlink )
751 );
752 }
753 }
754
761 protected function formatDateLink( $row ) {
762 if ( !$this->currentPage || !$this->currentRevRecord ) {
763 return '';
764 }
765 if ( $this->isArchive ) {
766 $date = $this->getLanguage()->userTimeAndDate(
767 $this->currentRevRecord->getTimestamp(),
768 $this->getUser()
769 );
770
771 if ( $this->getAuthority()->isAllowed( 'undelete' ) &&
772 $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
773 ) {
774 $dateLink = $this->getLinkRenderer()->makeKnownLink(
775 SpecialPage::getTitleFor( 'Undelete' ),
776 $date,
777 [ 'class' => 'mw-changeslist-date' ],
778 [
779 'target' => $this->currentPage->getPrefixedText(),
780 'timestamp' => $this->currentRevRecord->getTimestamp()
781 ]
782 );
783 } else {
784 $dateLink = htmlspecialchars( $date );
785 }
786 if ( $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
787 $class = Linker::getRevisionDeletedClass( $this->currentRevRecord );
788 $dateLink = Html::rawElement(
789 'span',
790 [ 'class' => $class ],
791 $dateLink
792 );
793 }
794 } else {
795 $dateLink = ChangesList::revDateLink(
796 $this->currentRevRecord,
797 $this->getAuthority(),
798 $this->getLanguage(),
799 $this->currentPage
800 );
801 }
802 return $dateLink;
803 }
804
812 protected function formatTopMarkText( $row, &$classes ) {
813 if ( !$this->currentPage || !$this->currentRevRecord ) {
814 return '';
815 }
816 $topmarktext = '';
817 if ( !$this->isArchive ) {
818 $pagerTools = new PagerTools(
819 $this->currentRevRecord,
820 null,
821 $row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new,
822 $this->hookRunner,
823 $this->currentPage,
824 $this->getContext(),
825 $this->getLinkRenderer()
826 );
827 if ( $row->{$this->revisionIdField} === $row->page_latest ) {
828 $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
829 $classes[] = 'mw-contributions-current';
830 }
831 if ( $pagerTools->shouldPreventClickjacking() ) {
832 $this->setPreventClickjacking( true );
833 }
834 $topmarktext .= $pagerTools->toHTML();
835 }
836 return $topmarktext;
837 }
838
845 protected function formatCharDiff( $row ) {
846 if ( $row->{$this->revisionParentIdField} === null ) {
847 // For some reason rev_parent_id isn't populated for this row.
848 // Its rumoured this is true on wikipedia for some revisions (T36922).
849 // Next best thing is to have the total number of bytes.
850 $chardiff = ' <span class="mw-changeslist-separator"></span> ';
851 $chardiff .= Linker::formatRevisionSize( $row->{$this->revisionLengthField} );
852 $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
853 } else {
854 $parentLen = 0;
855 if ( isset( $this->mParentLens[$row->{$this->revisionParentIdField}] ) ) {
856 $parentLen = $this->mParentLens[$row->{$this->revisionParentIdField}];
857 }
858
859 $chardiff = ' <span class="mw-changeslist-separator"></span> ';
861 $parentLen,
862 $row->{$this->revisionLengthField},
863 $this->getContext()
864 );
865 $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
866 }
867 return $chardiff;
868 }
869
876 protected function formatComment( $row ) {
877 $comment = $this->formattedComments[$row->{$this->revisionIdField}];
878
879 if ( $comment === '' ) {
880 $defaultComment = $this->messages['changeslist-nocomment'];
881 $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
882 }
883
884 // Don't wrap result of this with <bdi> or any other element, see T377555
885 return $comment;
886 }
887
894 protected function formatUserLink( $row ) {
895 if ( !$this->currentRevRecord ) {
896 return '';
897 }
898 $dir = $this->getLanguage()->getDir();
899
900 // When the author is different from the target, always show user and user talk links
901 $userlink = '';
902 $revUser = $this->currentRevRecord->getUser();
903 $revUserId = $revUser ? $revUser->getId() : 0;
904 $revUserText = $revUser ? $revUser->getName() : '';
905 if ( $this->target !== $revUserText ) {
906 $userlink = ' <span class="mw-changeslist-separator"></span> '
907 . Html::rawElement( 'bdi', [ 'dir' => $dir ],
908 Linker::userLink( $revUserId, $revUserText ) );
909 $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
910 Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' ';
911 }
912 return $userlink;
913 }
914
919 protected function formatFlags( $row ) {
920 if ( !$this->currentRevRecord ) {
921 return [];
922 }
923 $flags = [];
924 if ( $this->currentRevRecord->getParentId() === 0 ) {
925 $flags[] = ChangesList::flag( 'newpage' );
926 }
927
928 if ( $this->currentRevRecord->isMinor() ) {
929 $flags[] = ChangesList::flag( 'minor' );
930 }
931 return $flags;
932 }
933
940 protected function formatVisibilityLink( $row ) {
941 if ( !$this->currentPage || !$this->currentRevRecord ) {
942 return '';
943 }
944 $del = Linker::getRevDeleteLink(
945 $this->getAuthority(),
946 $this->currentRevRecord,
947 $this->currentPage
948 );
949 if ( $del !== '' ) {
950 $del .= ' ';
951 }
952 return $del;
953 }
954
960 protected function formatTags( $row, &$classes ) {
961 # Tags, if any. Save some time using a cache.
962 [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
963 $this->tagsCache->makeKey(
964 $row->ts_tags ?? '',
965 $this->getUser()->getName(),
966 $this->getLanguage()->getCode()
967 ),
969 $row->ts_tags,
970 null,
971 $this->getContext()
972 )
973 );
974 $classes = array_merge( $classes, $newClasses );
975 return $tagSummary;
976 }
977
984 public function revisionUserIsDeleted( $row ) {
985 return $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_USER );
986 }
987
1000 public function formatRow( $row ) {
1001 $ret = '';
1002 $classes = [];
1003 $attribs = [];
1004
1005 $this->currentPage = null;
1006 $this->currentRevRecord = null;
1007
1008 // Create a title for the revision if possible
1009 // Rows from the hook may not include title information
1010 if ( isset( $row->{$this->pageNamespaceField} ) && isset( $row->{$this->pageTitleField} ) ) {
1011 $this->currentPage = Title::makeTitle( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
1012 }
1013
1014 // Flow overrides the ContribsPager::reallyDoQuery hook, causing this
1015 // function to be called with a special object for $row. It expects us
1016 // skip formatting so that the row can be formatted by the
1017 // ContributionsLineEnding hook below.
1018 // FIXME: have some better way for extensions to provide formatted rows.
1019 $this->currentRevRecord = $this->tryCreatingRevisionRecord( $row, $this->currentPage );
1020 if ( $this->revisionsOnly || ( $this->currentRevRecord && $this->currentPage ) ) {
1021 $this->populateAttributes( $row, $attribs );
1022
1023 $templateParams = $this->getTemplateParams( $row, $classes );
1024 $ret = $this->getProcessedTemplate( $templateParams );
1025 }
1026
1027 if ( $this->runHooks ) {
1028 // Let extensions add data
1029 $lineEndingsHook = $this->isArchive ?
1030 'onDeletedContributionsLineEnding' :
1031 'onContributionsLineEnding';
1032 $this->hookRunner->$lineEndingsHook( $this, $ret, $row, $classes, $attribs );
1033 }
1034
1035 $attribs = array_filter( $attribs,
1036 [ Sanitizer::class, 'isReservedDataAttribute' ],
1037 ARRAY_FILTER_USE_KEY
1038 );
1039
1040 // TODO: Handle exceptions in the catch block above. Do any extensions rely on
1041 // receiving empty rows?
1042
1043 if ( $classes === [] && $attribs === [] && $ret === '' ) {
1044 wfDebug( "Dropping ContributionsSpecialPage row that could not be formatted" );
1045 return "<!-- Could not format ContributionsSpecialPage row. -->\n";
1046 }
1047 $attribs['class'] = $classes;
1048
1049 // FIXME: The signature of the ContributionsLineEnding hook makes it
1050 // very awkward to move this LI wrapper into the template.
1051 return Html::rawElement( 'li', $attribs, $ret ) . "\n";
1052 }
1053
1064 public function getTemplateParams( $row, &$classes ) {
1065 $link = $this->formatArticleLink( $row );
1066 $topmarktext = $this->formatTopMarkText( $row, $classes );
1067 $diffHistLinks = $this->formatDiffHistLinks( $row );
1068 $dateLink = $this->formatDateLink( $row );
1069 $chardiff = $this->formatCharDiff( $row );
1070 $comment = $this->formatComment( $row );
1071 $userlink = $this->formatUserLink( $row );
1072 $flags = $this->formatFlags( $row );
1073 $del = $this->formatVisibilityLink( $row );
1074 $tagSummary = $this->formatTags( $row, $classes );
1075
1076 if ( !$this->isArchive && $this->runHooks ) {
1077 $this->hookRunner->onSpecialContributions__formatRow__flags(
1078 $this->getContext(), $row, $flags );
1079 }
1080
1081 $templateParams = [
1082 'del' => $del,
1083 'timestamp' => $dateLink,
1084 'diffHistLinks' => $diffHistLinks,
1085 'charDifference' => $chardiff,
1086 'flags' => $flags,
1087 'articleLink' => $link,
1088 'userlink' => $userlink,
1089 'logText' => $comment,
1090 'topmarktext' => $topmarktext,
1091 'tagSummary' => $tagSummary,
1092 ];
1093
1094 # Denote if username is redacted for this edit
1095 if ( $this->revisionUserIsDeleted( $row ) ) {
1096 $templateParams['rev-deleted-user-contribs'] =
1097 $this->msg( 'rev-deleted-user-contribs' )->escaped();
1098 }
1099
1100 return $templateParams;
1101 }
1102
1112 public function getProcessedTemplate( $templateParams ) {
1113 return $this->templateParser->processTemplate(
1114 'SpecialContributionsLine',
1115 $templateParams
1116 );
1117 }
1118
1123 protected function getSqlComment() {
1124 if ( $this->namespace || $this->deletedOnly ) {
1125 // potentially slow, see CR r58153
1126 return 'contributions page filtered for namespace or RevisionDeleted edits';
1127 } else {
1128 return 'contributions page unfiltered';
1129 }
1130 }
1131
1135 protected function preventClickjacking() {
1136 $this->setPreventClickjacking( true );
1137 }
1138
1143 protected function setPreventClickjacking( bool $enable ) {
1144 $this->preventClickjacking = $enable;
1145 }
1146
1150 public function getPreventClickjacking() {
1151 return $this->preventClickjacking;
1152 }
1153
1154}
const NS_USER_TALK
Definition Defines.php:68
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
Recent changes tagging.
static formatSummaryRow( $tags, $unused, ?MessageLocalizer $localizer=null)
Creates HTML for the given tags.
Base class for lists of recent changes shown on special pages.
static showCharacterDifference( $old, $new, ?IContextSource $context=null)
Show formatted char difference.
Marks HTML that shouldn't be escaped.
Definition HtmlArmor.php:30
Store key-value entries in a size-limited in-memory LRU cache.
This is the main service interface for converting single-line comments from various DB comment fields...
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:56
Class that generates HTML for internal links.
Some internal bits split of from Skin.php.
Definition Linker.php:62
A class containing constants representing the names of configuration variables.
const MaxExecutionTimeForExpensiveQueries
Name constant for the MaxExecutionTimeForExpensiveQueries 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.
Pager for Special:Contributions.
getRevisionQuery()
Get queryInfo for the main query selecting revisions, not including filtering on namespace,...
getQueryInfo()
Provides all parameters needed for the main paged query.
getTemplateParams( $row, &$classes)
Generate array of template parameters to pass to the template for rendering.
string $revisionIdField
Field names for various attributes.
reallyDoQuery( $offset, $limit, $order)
This method basically executes the exact same code as the parent class, though with a hook added,...
revisionUserIsDeleted( $row)
Check whether the revision author is deleted.
formatTopMarkText( $row, &$classes)
Format annotation and add extra class if a row represents a latest revision.
getEndBody()
Hook into getBody() for the end of the list.to overridestring
getProcessedTemplate( $templateParams)
Return the processed template.
createRevisionRecord( $row, $title=null)
Create a revision record from a $row that models a revision.
formatCharDiff( $row)
Format annotation to show the size of a diff.
string $target
User name, or a string describing an IP address range.
formatDateLink( $row)
Format a date link.
getSqlComment()
Overwrite Pager function and return a helpful comment.
string[] $messages
Local cache for escaped messages.
getDefaultQuery()
Get an array of query parameters that should be put into self-links.
bool $runHooks
Run hooks to allow extensions to modify the page.
formatArticleLink( $row)
Format a link to an article.
formatVisibilityLink( $row)
Format link for changing visibility.
tryCreatingRevisionRecord( $row, $title=null)
If the object looks like a revision row, or corresponds to a previously cached revision,...
bool $isArchive
Get revisions from the archive table (if true) or the revision table (if false)
__construct(LinkRenderer $linkRenderer, LinkBatchFactory $linkBatchFactory, HookContainer $hookContainer, RevisionStore $revisionStore, NamespaceInfo $namespaceInfo, CommentFormatter $commentFormatter, UserFactory $userFactory, IContextSource $context, array $options, ?UserIdentity $targetUser)
formatDiffHistLinks( $row)
Format diff and history links.
TemplateParser $templateParser
Set to protected to allow subclasses access for overrides.
formatUserLink( $row)
Format a user link.
populateAttributes( $row, &$attributes)
Populate the HTML attributes.
formatRow( $row)
Generates each row in the contributions list.
formatComment( $row)
Format a comment for a revision.
doBatchLookups()
Called from getBody(), before getStartBody() is called and after doQuery() was called.
getStartBody()
Hook into getBody(), allows text to be inserted at the start.This will be called even if there are no...
getEmptyBody()
Hook into getBody(), for the bit between the start and the end when there are no rows....
getDatabase()
Get the Database object in use.
Generate a set of tools for a revision.
Pager for filtering by a range of dates.
getDateRangeCond( $startTime, $endTime)
Set and return a date range condition using timestamps provided by the user.
buildQueryInfo( $offset, $limit, $order)
Build variables to use by the database wrapper.For b/c, query direction is true for ascending and fal...
HTML sanitizer for MediaWiki.
Definition Sanitizer.php:46
Page revision base class.
Service for looking up page revisions.
Parent class for all special pages.
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Represents a title within MediaWiki.
Definition Title.php:78
Create User objects.
newFromName(string $name, string $validate=self::RIGOR_VALID)
Factory method for creating users by name, replacing static User::newFromName.
Overloads the relevant methods of the real ResultWrapper so it doesn't go anywhere near an actual dat...
Interface for objects which can provide a MediaWiki context on request.
Interface for objects representing user identity.
Shared interface for rigor levels when dealing with User methods.
Result wrapper for grabbing data queried from an IDatabase object.