MediaWiki  master
ChangeTags.php
Go to the documentation of this file.
1 <?php
33 
34 class ChangeTags {
38  public const TAG_CONTENT_MODEL_CHANGE = 'mw-contentmodelchange';
43  public const TAG_NEW_REDIRECT = 'mw-new-redirect';
47  public const TAG_REMOVED_REDIRECT = 'mw-removed-redirect';
51  public const TAG_CHANGED_REDIRECT_TARGET = 'mw-changed-redirect-target';
55  public const TAG_BLANK = 'mw-blank';
59  public const TAG_REPLACE = 'mw-replace';
67  public const TAG_ROLLBACK = 'mw-rollback';
74  public const TAG_UNDO = 'mw-undo';
80  public const TAG_MANUAL_REVERT = 'mw-manual-revert';
88  public const TAG_REVERTED = 'mw-reverted';
92  public const TAG_SERVER_SIDE_UPLOAD = 'mw-server-side-upload';
93 
98 
102  public const BYPASS_MAX_USAGE_CHECK = 1;
103 
109  private const MAX_DELETE_USES = 5000;
110 
114  private const DEFINED_SOFTWARE_TAGS = [
115  'mw-contentmodelchange',
116  'mw-new-redirect',
117  'mw-removed-redirect',
118  'mw-changed-redirect-target',
119  'mw-blank',
120  'mw-replace',
121  'mw-rollback',
122  'mw-undo',
123  'mw-manual-revert',
124  'mw-reverted',
125  'mw-server-side-upload',
126  ];
127 
131  private const CHANGE_TAG = 'change_tag';
132 
136  private const CHANGE_TAG_DEF = 'change_tag_def';
137 
148  public static $avoidReopeningTablesForTesting = false;
149 
157  public static function getSoftwareTags( $all = false ) {
158  $coreTags = MediaWikiServices::getInstance()->getMainConfig()->get(
159  MainConfigNames::SoftwareTags );
160  $softwareTags = [];
161 
162  if ( !is_array( $coreTags ) ) {
163  wfWarn( 'wgSoftwareTags should be associative array of enabled tags.
164  Please refer to documentation for the list of tags you can enable' );
165  return $softwareTags;
166  }
167 
168  $availableSoftwareTags = !$all ?
169  array_keys( array_filter( $coreTags ) ) :
170  array_keys( $coreTags );
171 
172  $softwareTags = array_intersect(
173  $availableSoftwareTags,
174  self::DEFINED_SOFTWARE_TAGS
175  );
176 
177  return $softwareTags;
178  }
179 
193  public static function formatSummaryRow( $tags, $page, MessageLocalizer $localizer = null ) {
194  if ( $tags === '' || $tags === null ) {
195  return [ '', [] ];
196  }
197  if ( !$localizer ) {
198  $localizer = RequestContext::getMain();
199  }
200 
201  $classes = [];
202 
203  $tags = explode( ',', $tags );
204  $order = array_flip( self::listDefinedTags() );
205  usort( $tags, static function ( $a, $b ) use ( $order ) {
206  return ( $order[ $a ] ?? INF ) <=> ( $order[ $b ] ?? INF );
207  } );
208 
209  $displayTags = [];
210  foreach ( $tags as $tag ) {
211  if ( $tag === '' ) {
212  continue;
213  }
214  $classes[] = Sanitizer::escapeClass( "mw-tag-$tag" );
215  $description = self::tagDescription( $tag, $localizer );
216  if ( $description === false ) {
217  continue;
218  }
219  $displayTags[] = Xml::tags(
220  'span',
221  [ 'class' => 'mw-tag-marker ' .
222  Sanitizer::escapeClass( "mw-tag-marker-$tag" ) ],
223  $description
224  );
225  }
226 
227  if ( !$displayTags ) {
228  return [ '', $classes ];
229  }
230 
231  $markers = $localizer->msg( 'tag-list-wrapper' )
232  ->numParams( count( $displayTags ) )
233  ->rawParams( implode( ' ', $displayTags ) )
234  ->parse();
235  $markers = Xml::tags( 'span', [ 'class' => 'mw-tag-markers' ], $markers );
236 
237  return [ $markers, $classes ];
238  }
239 
253  public static function tagShortDescriptionMessage( $tag, MessageLocalizer $context ) {
254  $msg = $context->msg( "tag-$tag" );
255  if ( !$msg->exists() ) {
256  // No such message
257  // Pass through ->msg(), even though it seems redundant, to avoid requesting
258  // the user's language from session-less entry points (T227233)
259  return $context->msg( new RawMessage( '$1', [ Message::plaintextParam( $tag ) ] ) );
260  }
261  if ( $msg->isDisabled() ) {
262  // The message exists but is disabled, hide the tag.
263  return false;
264  }
265 
266  // Message exists and isn't disabled, use it.
267  return $msg;
268  }
269 
283  public static function tagDescription( $tag, MessageLocalizer $context ) {
284  $msg = self::tagShortDescriptionMessage( $tag, $context );
285  return $msg ? $msg->parse() : false;
286  }
287 
300  public static function tagLongDescriptionMessage( $tag, MessageLocalizer $context ) {
301  $msg = $context->msg( "tag-$tag-description" );
302  if ( !$msg->exists() ) {
303  return false;
304  }
305  if ( $msg->isDisabled() ) {
306  // The message exists but is disabled, hide the description.
307  return false;
308  }
309 
310  // Message exists and isn't disabled, use it.
311  return $msg;
312  }
313 
328  public static function addTags( $tags, $rc_id = null, $rev_id = null,
329  $log_id = null, $params = null, RecentChange $rc = null
330  ) {
331  $result = self::updateTags( $tags, null, $rc_id, $rev_id, $log_id, $params, $rc );
332  return (bool)$result[0];
333  }
334 
365  public static function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id = null,
366  &$rev_id = null, &$log_id = null, $params = null, RecentChange $rc = null,
367  UserIdentity $user = null
368  ) {
369  $tagsToAdd = array_filter(
370  (array)$tagsToAdd, // Make sure we're submitting all tags...
371  static function ( $value ) {
372  return ( $value ?? '' ) !== '';
373  }
374  );
375  $tagsToRemove = array_filter(
376  (array)$tagsToRemove,
377  static function ( $value ) {
378  return ( $value ?? '' ) !== '';
379  }
380  );
381 
382  if ( !$rc_id && !$rev_id && !$log_id ) {
383  throw new MWException( 'At least one of: RCID, revision ID, and log ID MUST be ' .
384  'specified when adding or removing a tag from a change!' );
385  }
386 
387  $dbw = wfGetDB( DB_PRIMARY );
388 
389  // Might as well look for rcids and so on.
390  if ( !$rc_id ) {
391  // Info might be out of date, somewhat fractionally, on replica DB.
392  // LogEntry/LogPage and WikiPage match rev/log/rc timestamps,
393  // so use that relation to avoid full table scans.
394  if ( $log_id ) {
395  $rc_id = $dbw->newSelectQueryBuilder()
396  ->select( 'rc_id' )
397  ->from( 'logging' )
398  ->join( 'recentchanges', null, [
399  'rc_timestamp = log_timestamp',
400  'rc_logid = log_id'
401  ] )
402  ->where( [ 'log_id' => $log_id ] )
403  ->caller( __METHOD__ )
404  ->fetchField();
405  } elseif ( $rev_id ) {
406  $rc_id = $dbw->newSelectQueryBuilder()
407  ->select( 'rc_id' )
408  ->from( 'revision' )
409  ->join( 'recentchanges', null, [
410  'rc_this_oldid = rev_id'
411  ] )
412  ->where( [ 'rev_id' => $rev_id ] )
413  ->caller( __METHOD__ )
414  ->fetchField();
415  }
416  } elseif ( !$log_id && !$rev_id ) {
417  // Info might be out of date, somewhat fractionally, on replica DB.
418  $log_id = $dbw->newSelectQueryBuilder()
419  ->select( 'rc_logid' )
420  ->from( 'recentchanges' )
421  ->where( [ 'rc_id' => $rc_id ] )
422  ->caller( __METHOD__ )
423  ->fetchField();
424  $rev_id = $dbw->newSelectQueryBuilder()
425  ->select( 'rc_this_oldid' )
426  ->from( 'recentchanges' )
427  ->where( [ 'rc_id' => $rc_id ] )
428  ->caller( __METHOD__ )
429  ->fetchField();
430  }
431 
432  if ( $log_id && !$rev_id ) {
433  $rev_id = $dbw->newSelectQueryBuilder()
434  ->select( 'ls_value' )
435  ->from( 'log_search' )
436  ->where( [ 'ls_field' => 'associated_rev_id', 'ls_log_id' => $log_id ] )
437  ->caller( __METHOD__ )
438  ->fetchField();
439  } elseif ( !$log_id && $rev_id ) {
440  $log_id = $dbw->newSelectQueryBuilder()
441  ->select( 'ls_log_id' )
442  ->from( 'log_search' )
443  ->where( [ 'ls_field' => 'associated_rev_id', 'ls_value' => (string)$rev_id ] )
444  ->caller( __METHOD__ )
445  ->fetchField();
446  }
447 
448  $prevTags = self::getTags( $dbw, $rc_id, $rev_id, $log_id );
449 
450  // add tags
451  $tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
452  $newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
453 
454  // remove tags
455  $tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
456  $newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
457 
458  sort( $prevTags );
459  sort( $newTags );
460  if ( $prevTags == $newTags ) {
461  return [ [], [], $prevTags ];
462  }
463 
464  // insert a row into change_tag for each new tag
465  $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
466  if ( count( $tagsToAdd ) ) {
467  $changeTagMapping = [];
468  foreach ( $tagsToAdd as $tag ) {
469  $changeTagMapping[$tag] = $changeTagDefStore->acquireId( $tag );
470  }
471  $fname = __METHOD__;
472  // T207881: update the counts at the end of the transaction
473  $dbw->onTransactionPreCommitOrIdle( static function () use ( $dbw, $tagsToAdd, $fname ) {
474  $dbw->update(
475  self::CHANGE_TAG_DEF,
476  [ 'ctd_count = ctd_count + 1' ],
477  [ 'ctd_name' => $tagsToAdd ],
478  $fname
479  );
480  }, $fname );
481 
482  $tagsRows = [];
483  foreach ( $tagsToAdd as $tag ) {
484  // Filter so we don't insert NULLs as zero accidentally.
485  // Keep in mind that $rc_id === null means "I don't care/know about the
486  // rc_id, just delete $tag on this revision/log entry". It doesn't
487  // mean "only delete tags on this revision/log WHERE rc_id IS NULL".
488  $tagsRows[] = array_filter(
489  [
490  'ct_rc_id' => $rc_id,
491  'ct_log_id' => $log_id,
492  'ct_rev_id' => $rev_id,
493  'ct_params' => $params,
494  'ct_tag_id' => $changeTagMapping[$tag] ?? null,
495  ]
496  );
497 
498  }
499 
500  $dbw->insert( self::CHANGE_TAG, $tagsRows, __METHOD__, [ 'IGNORE' ] );
501  }
502 
503  // delete from change_tag
504  if ( count( $tagsToRemove ) ) {
505  $fname = __METHOD__;
506  foreach ( $tagsToRemove as $tag ) {
507  $conds = array_filter(
508  [
509  'ct_rc_id' => $rc_id,
510  'ct_log_id' => $log_id,
511  'ct_rev_id' => $rev_id,
512  'ct_tag_id' => $changeTagDefStore->getId( $tag ),
513  ]
514  );
515  $dbw->delete( self::CHANGE_TAG, $conds, __METHOD__ );
516  if ( $dbw->affectedRows() ) {
517  // T207881: update the counts at the end of the transaction
518  $dbw->onTransactionPreCommitOrIdle( static function () use ( $dbw, $tag, $fname ) {
519  $dbw->update(
520  self::CHANGE_TAG_DEF,
521  [ 'ctd_count = ctd_count - 1' ],
522  [ 'ctd_name' => $tag ],
523  $fname
524  );
525 
526  $dbw->delete(
527  self::CHANGE_TAG_DEF,
528  [ 'ctd_name' => $tag, 'ctd_count' => 0, 'ctd_user_defined' => 0 ],
529  $fname
530  );
531  }, $fname );
532  }
533  }
534  }
535 
536  $userObj = $user ? MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $user ) : null;
537  Hooks::runner()->onChangeTagsAfterUpdateTags( $tagsToAdd, $tagsToRemove, $prevTags,
538  $rc_id, $rev_id, $log_id, $params, $rc, $userObj );
539 
540  return [ $tagsToAdd, $tagsToRemove, $prevTags ];
541  }
542 
555  public static function getTagsWithData(
556  IDatabase $db, $rc_id = null, $rev_id = null, $log_id = null
557  ) {
558  if ( !$rc_id && !$rev_id && !$log_id ) {
559  throw new MWException( 'At least one of: RCID, revision ID, and log ID MUST be ' .
560  'specified when loading tags from a change!' );
561  }
562 
563  $conds = array_filter(
564  [
565  'ct_rc_id' => $rc_id,
566  'ct_rev_id' => $rev_id,
567  'ct_log_id' => $log_id,
568  ]
569  );
570  $result = $db->newSelectQueryBuilder()
571  ->select( [ 'ct_tag_id', 'ct_params' ] )
572  ->from( self::CHANGE_TAG )
573  ->where( $conds )
574  ->caller( __METHOD__ )
575  ->fetchResultSet();
576 
577  $tags = [];
578  $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
579  foreach ( $result as $row ) {
580  $tagName = $changeTagDefStore->getName( (int)$row->ct_tag_id );
581  $tags[$tagName] = $row->ct_params;
582  }
583 
584  return $tags;
585  }
586 
597  public static function getTags( IDatabase $db, $rc_id = null, $rev_id = null, $log_id = null ) {
598  return array_keys( self::getTagsWithData( $db, $rc_id, $rev_id, $log_id ) );
599  }
600 
611  protected static function restrictedTagError( $msgOne, $msgMulti, $tags ) {
612  $lang = RequestContext::getMain()->getLanguage();
613  $tags = array_values( $tags );
614  $count = count( $tags );
615  $status = Status::newFatal( ( $count > 1 ) ? $msgMulti : $msgOne,
616  $lang->commaList( $tags ), $count );
617  $status->value = $tags;
618  return $status;
619  }
620 
635  public static function canAddTagsAccompanyingChange(
636  array $tags,
637  Authority $performer = null,
638  $checkBlock = true
639  ) {
640  $user = null;
641  if ( $performer !== null ) {
642  if ( !$performer->isAllowed( 'applychangetags' ) ) {
643  return Status::newFatal( 'tags-apply-no-permission' );
644  }
645 
646  if ( $checkBlock && $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
647  return Status::newFatal(
648  'tags-apply-blocked',
649  $performer->getUser()->getName()
650  );
651  }
652 
653  // ChangeTagsAllowedAdd hook still needs a full User object
654  $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
655  }
656 
657  // to be applied, a tag has to be explicitly defined
658  $allowedTags = self::listExplicitlyDefinedTags();
659  Hooks::runner()->onChangeTagsAllowedAdd( $allowedTags, $tags, $user );
660  $disallowedTags = array_diff( $tags, $allowedTags );
661  if ( $disallowedTags ) {
662  return self::restrictedTagError( 'tags-apply-not-allowed-one',
663  'tags-apply-not-allowed-multi', $disallowedTags );
664  }
665 
666  return Status::newGood();
667  }
668 
689  public static function addTagsAccompanyingChangeWithChecks(
690  array $tags, $rc_id, $rev_id, $log_id, $params, Authority $performer
691  ) {
692  // are we allowed to do this?
693  $result = self::canAddTagsAccompanyingChange( $tags, $performer );
694  if ( !$result->isOK() ) {
695  $result->value = null;
696  return $result;
697  }
698 
699  // do it!
700  self::addTags( $tags, $rc_id, $rev_id, $log_id, $params );
701 
702  return Status::newGood( true );
703  }
704 
719  public static function canUpdateTags(
720  array $tagsToAdd,
721  array $tagsToRemove,
722  Authority $performer = null
723  ) {
724  if ( $performer !== null ) {
725  if ( !$performer->isAllowed( 'changetags' ) ) {
726  return Status::newFatal( 'tags-update-no-permission' );
727  }
728 
729  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
730  return Status::newFatal(
731  'tags-update-blocked',
732  $performer->getUser()->getName()
733  );
734  }
735  }
736 
737  if ( $tagsToAdd ) {
738  // to be added, a tag has to be explicitly defined
739  // @todo Allow extensions to define tags that can be applied by users...
740  $explicitlyDefinedTags = self::listExplicitlyDefinedTags();
741  $diff = array_diff( $tagsToAdd, $explicitlyDefinedTags );
742  if ( $diff ) {
743  return self::restrictedTagError( 'tags-update-add-not-allowed-one',
744  'tags-update-add-not-allowed-multi', $diff );
745  }
746  }
747 
748  if ( $tagsToRemove ) {
749  // to be removed, a tag must not be defined by an extension, or equivalently it
750  // has to be either explicitly defined or not defined at all
751  // (assuming no edge case of a tag both explicitly-defined and extension-defined)
752  $softwareDefinedTags = self::listSoftwareDefinedTags();
753  $intersect = array_intersect( $tagsToRemove, $softwareDefinedTags );
754  if ( $intersect ) {
755  return self::restrictedTagError( 'tags-update-remove-not-allowed-one',
756  'tags-update-remove-not-allowed-multi', $intersect );
757  }
758  }
759 
760  return Status::newGood();
761  }
762 
793  public static function updateTagsWithChecks( $tagsToAdd, $tagsToRemove,
794  $rc_id, $rev_id, $log_id, $params, string $reason, Authority $performer
795  ) {
796  if ( $tagsToAdd === null ) {
797  $tagsToAdd = [];
798  }
799  if ( $tagsToRemove === null ) {
800  $tagsToRemove = [];
801  }
802  if ( !$tagsToAdd && !$tagsToRemove ) {
803  // no-op, don't bother
804  return Status::newGood( (object)[
805  'logId' => null,
806  'addedTags' => [],
807  'removedTags' => [],
808  ] );
809  }
810 
811  // are we allowed to do this?
812  $result = self::canUpdateTags( $tagsToAdd, $tagsToRemove, $performer );
813  if ( !$result->isOK() ) {
814  $result->value = null;
815  return $result;
816  }
817 
818  // basic rate limiting
819  $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
820  if ( $user->pingLimiter( 'changetag' ) ) {
821  return Status::newFatal( 'actionthrottledtext' );
822  }
823 
824  // do it!
825  list( $tagsAdded, $tagsRemoved, $initialTags ) = self::updateTags( $tagsToAdd,
826  $tagsToRemove, $rc_id, $rev_id, $log_id, $params, null, $user );
827  if ( !$tagsAdded && !$tagsRemoved ) {
828  // no-op, don't log it
829  return Status::newGood( (object)[
830  'logId' => null,
831  'addedTags' => [],
832  'removedTags' => [],
833  ] );
834  }
835 
836  // log it
837  $logEntry = new ManualLogEntry( 'tag', 'update' );
838  $logEntry->setPerformer( $performer->getUser() );
839  $logEntry->setComment( $reason );
840 
841  // find the appropriate target page
842  if ( $rev_id ) {
843  $revisionRecord = MediaWikiServices::getInstance()
844  ->getRevisionLookup()
845  ->getRevisionById( $rev_id );
846  if ( $revisionRecord ) {
847  $logEntry->setTarget( $revisionRecord->getPageAsLinkTarget() );
848  }
849  } elseif ( $log_id ) {
850  // This function is from revision deletion logic and has nothing to do with
851  // change tags, but it appears to be the only other place in core where we
852  // perform logged actions on log items.
853  $logEntry->setTarget( RevDelLogList::suggestTarget( null, [ $log_id ] ) );
854  }
855 
856  if ( !$logEntry->getTarget() ) {
857  // target is required, so we have to set something
858  $logEntry->setTarget( SpecialPage::getTitleFor( 'Tags' ) );
859  }
860 
861  $logParams = [
862  '4::revid' => $rev_id,
863  '5::logid' => $log_id,
864  '6:list:tagsAdded' => $tagsAdded,
865  '7:number:tagsAddedCount' => count( $tagsAdded ),
866  '8:list:tagsRemoved' => $tagsRemoved,
867  '9:number:tagsRemovedCount' => count( $tagsRemoved ),
868  'initialTags' => $initialTags,
869  ];
870  $logEntry->setParameters( $logParams );
871  $logEntry->setRelations( [ 'Tag' => array_merge( $tagsAdded, $tagsRemoved ) ] );
872 
873  $dbw = wfGetDB( DB_PRIMARY );
874  $logId = $logEntry->insert( $dbw );
875  // Only send this to UDP, not RC, similar to patrol events
876  $logEntry->publish( $logId, 'udp' );
877 
878  return Status::newGood( (object)[
879  'logId' => $logId,
880  'addedTags' => $tagsAdded,
881  'removedTags' => $tagsRemoved,
882  ] );
883  }
884 
906  public static function modifyDisplayQuery( &$tables, &$fields, &$conds,
907  &$join_conds, &$options, $filter_tag = '', bool $exclude = false
908  ) {
909  $useTagFilter = MediaWikiServices::getInstance()->getMainConfig()->get(
910  MainConfigNames::UseTagFilter );
911 
912  // Normalize to arrays
913  $tables = (array)$tables;
914  $fields = (array)$fields;
915  $conds = (array)$conds;
916  $options = (array)$options;
917 
918  $fields['ts_tags'] = self::makeTagSummarySubquery( $tables );
919 
920  // Figure out which ID field to use
921  if ( in_array( 'recentchanges', $tables ) ) {
922  $join_cond = 'ct_rc_id=rc_id';
923  } elseif ( in_array( 'logging', $tables ) ) {
924  $join_cond = 'ct_log_id=log_id';
925  } elseif ( in_array( 'revision', $tables ) ) {
926  $join_cond = 'ct_rev_id=rev_id';
927  } elseif ( in_array( 'archive', $tables ) ) {
928  $join_cond = 'ct_rev_id=ar_rev_id';
929  } else {
930  throw new MWException( 'Unable to determine appropriate JOIN condition for tagging.' );
931  }
932 
933  if ( !$useTagFilter ) {
934  return;
935  }
936 
937  if ( !is_array( $filter_tag ) ) {
938  // some callers provide false or null
939  $filter_tag = (string)$filter_tag;
940  }
941 
942  if ( $filter_tag !== [] && $filter_tag !== '' ) {
943  // Somebody wants to filter on a tag.
944  // Add an INNER JOIN on change_tag
945  $tagTable = self::getDisplayTableName();
946  $filterTagIds = [];
947  $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
948  foreach ( (array)$filter_tag as $filterTagName ) {
949  try {
950  $filterTagIds[] = $changeTagDefStore->getId( $filterTagName );
951  } catch ( NameTableAccessException $exception ) {
952  }
953  }
954 
955  if ( $exclude ) {
956  if ( $filterTagIds !== [] ) {
957  $tables[] = $tagTable;
958  $join_conds[$tagTable] = [
959  'LEFT JOIN',
960  [ $join_cond, 'ct_tag_id' => $filterTagIds ]
961  ];
962  $conds[] = "$tagTable.ct_tag_id IS NULL";
963  }
964  } else {
965  $tables[] = $tagTable;
966  $join_conds[$tagTable] = [ 'JOIN', $join_cond ];
967  if ( $filterTagIds !== [] ) {
968  $conds['ct_tag_id'] = $filterTagIds;
969  } else {
970  // all tags were invalid, return nothing
971  $conds[] = '0=1';
972  }
973 
974  if (
975  is_array( $filter_tag ) && count( $filter_tag ) > 1 &&
976  !in_array( 'DISTINCT', $options )
977  ) {
978  $options[] = 'DISTINCT';
979  }
980  }
981  }
982  }
983 
990  public static function getDisplayTableName() {
991  $tagTable = self::CHANGE_TAG;
992  if ( self::$avoidReopeningTablesForTesting && defined( 'MW_PHPUNIT_TEST' ) ) {
993  $db = wfGetDB( DB_REPLICA );
994 
995  if ( $db->getType() === 'mysql' ) {
996  // When filtering by tag, we are using the change_tag table twice:
997  // Once in a join for filtering, and once in a sub-query to list all
998  // tags for each revision. This does not work with temporary tables
999  // on some versions of MySQL, which causes phpunit tests to fail.
1000  // As a hacky workaround, we copy the temporary table, and join
1001  // against the copy. It is acknowledged that this is quite horrific.
1002  // Discuss at T256006.
1003 
1004  $tagTable = 'change_tag_for_display_query';
1005  if ( !$db->tableExists( $tagTable ) ) {
1006  $db->query(
1007  'CREATE TEMPORARY TABLE IF NOT EXISTS ' . $db->tableName( $tagTable )
1008  . ' LIKE ' . $db->tableName( self::CHANGE_TAG ),
1009  __METHOD__
1010  );
1011  $db->query(
1012  'INSERT IGNORE INTO ' . $db->tableName( $tagTable )
1013  . ' SELECT * FROM ' . $db->tableName( self::CHANGE_TAG ),
1014  __METHOD__
1015  );
1016  }
1017  }
1018  }
1019  return $tagTable;
1020  }
1021 
1030  public static function makeTagSummarySubquery( $tables ) {
1031  // Normalize to arrays
1032  $tables = (array)$tables;
1033 
1034  // Figure out which ID field to use
1035  if ( in_array( 'recentchanges', $tables ) ) {
1036  $join_cond = 'ct_rc_id=rc_id';
1037  } elseif ( in_array( 'logging', $tables ) ) {
1038  $join_cond = 'ct_log_id=log_id';
1039  } elseif ( in_array( 'revision', $tables ) ) {
1040  $join_cond = 'ct_rev_id=rev_id';
1041  } elseif ( in_array( 'archive', $tables ) ) {
1042  $join_cond = 'ct_rev_id=ar_rev_id';
1043  } else {
1044  throw new MWException( 'Unable to determine appropriate JOIN condition for tagging.' );
1045  }
1046 
1047  $tagTables = [ self::CHANGE_TAG, self::CHANGE_TAG_DEF ];
1048  $join_cond_ts_tags = [ self::CHANGE_TAG_DEF => [ 'JOIN', 'ct_tag_id=ctd_id' ] ];
1049  $field = 'ctd_name';
1050 
1051  return wfGetDB( DB_REPLICA )->buildGroupConcatField(
1052  ',', $tagTables, $field, $join_cond, $join_cond_ts_tags
1053  );
1054  }
1055 
1067  public static function buildTagFilterSelector(
1068  $selected = '', $ooui = false, IContextSource $context = null
1069  ) {
1070  if ( !$context ) {
1071  $context = RequestContext::getMain();
1072  }
1073 
1074  $config = $context->getConfig();
1075  if ( !$config->get( MainConfigNames::UseTagFilter ) ||
1076  !count( self::listDefinedTags() ) ) {
1077  return [];
1078  }
1079 
1080  $tags = self::getChangeTagList( $context, $context->getLanguage() );
1081  $autocomplete = [];
1082  foreach ( $tags as $tagInfo ) {
1083  $autocomplete[ $tagInfo['label'] ] = $tagInfo['name'];
1084  }
1085 
1086  $data = [
1088  'label',
1089  [ 'for' => 'tagfilter' ],
1090  $context->msg( 'tag-filter' )->parse()
1091  )
1092  ];
1093 
1094  if ( $ooui ) {
1095  $options = Xml::listDropDownOptionsOoui( $autocomplete );
1096 
1097  $data[] = new OOUI\ComboBoxInputWidget( [
1098  'id' => 'tagfilter',
1099  'name' => 'tagfilter',
1100  'value' => $selected,
1101  'classes' => 'mw-tagfilter-input',
1102  'options' => $options,
1103  ] );
1104  } else {
1105  $datalist = new XmlSelect( false, 'tagfilter-datalist' );
1106  $datalist->setTagName( 'datalist' );
1107  $datalist->addOptions( $autocomplete );
1108 
1109  $data[] = Xml::input(
1110  'tagfilter',
1111  20,
1112  $selected,
1113  [
1114  'class' => 'mw-tagfilter-input mw-ui-input mw-ui-input-inline',
1115  'id' => 'tagfilter',
1116  'list' => 'tagfilter-datalist',
1117  ]
1118  ) . $datalist->getHTML();
1119  }
1120 
1121  return $data;
1122  }
1123 
1132  public static function defineTag( $tag ) {
1133  $dbw = wfGetDB( DB_PRIMARY );
1134  $tagDef = [
1135  'ctd_name' => $tag,
1136  'ctd_user_defined' => 1,
1137  'ctd_count' => 0
1138  ];
1139  $dbw->upsert(
1140  self::CHANGE_TAG_DEF,
1141  $tagDef,
1142  'ctd_name',
1143  [ 'ctd_user_defined' => 1 ],
1144  __METHOD__
1145  );
1146 
1147  // clear the memcache of defined tags
1149  }
1150 
1159  public static function undefineTag( $tag ) {
1160  $dbw = wfGetDB( DB_PRIMARY );
1161 
1162  $dbw->update(
1163  self::CHANGE_TAG_DEF,
1164  [ 'ctd_user_defined' => 0 ],
1165  [ 'ctd_name' => $tag ],
1166  __METHOD__
1167  );
1168 
1169  $dbw->delete(
1170  self::CHANGE_TAG_DEF,
1171  [ 'ctd_name' => $tag, 'ctd_count' => 0 ],
1172  __METHOD__
1173  );
1174 
1175  // clear the memcache of defined tags
1177  }
1178 
1193  protected static function logTagManagementAction( string $action, string $tag, string $reason,
1194  UserIdentity $user, $tagCount = null, array $logEntryTags = []
1195  ) {
1196  $dbw = wfGetDB( DB_PRIMARY );
1197 
1198  $logEntry = new ManualLogEntry( 'managetags', $action );
1199  $logEntry->setPerformer( $user );
1200  // target page is not relevant, but it has to be set, so we just put in
1201  // the title of Special:Tags
1202  $logEntry->setTarget( Title::newFromText( 'Special:Tags' ) );
1203  $logEntry->setComment( $reason );
1204 
1205  $params = [ '4::tag' => $tag ];
1206  if ( $tagCount !== null ) {
1207  $params['5:number:count'] = $tagCount;
1208  }
1209  $logEntry->setParameters( $params );
1210  $logEntry->setRelations( [ 'Tag' => $tag ] );
1211  $logEntry->addTags( $logEntryTags );
1212 
1213  $logId = $logEntry->insert( $dbw );
1214  $logEntry->publish( $logId );
1215  return $logId;
1216  }
1217 
1227  public static function canActivateTag( $tag, Authority $performer = null ) {
1228  if ( $performer !== null ) {
1229  if ( !$performer->isAllowed( 'managechangetags' ) ) {
1230  return Status::newFatal( 'tags-manage-no-permission' );
1231  }
1232  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1233  return Status::newFatal(
1234  'tags-manage-blocked',
1235  $performer->getUser()->getName()
1236  );
1237  }
1238  }
1239 
1240  // defined tags cannot be activated (a defined tag is either extension-
1241  // defined, in which case the extension chooses whether or not to active it;
1242  // or user-defined, in which case it is considered active)
1243  $definedTags = self::listDefinedTags();
1244  if ( in_array( $tag, $definedTags ) ) {
1245  return Status::newFatal( 'tags-activate-not-allowed', $tag );
1246  }
1247 
1248  // non-existing tags cannot be activated
1249  $tagUsage = self::tagUsageStatistics();
1250  if ( !isset( $tagUsage[$tag] ) ) { // we already know the tag is undefined
1251  return Status::newFatal( 'tags-activate-not-found', $tag );
1252  }
1253 
1254  return Status::newGood();
1255  }
1256 
1274  public static function activateTagWithChecks( string $tag, string $reason, Authority $performer,
1275  bool $ignoreWarnings = false, array $logEntryTags = []
1276  ) {
1277  // are we allowed to do this?
1278  $result = self::canActivateTag( $tag, $performer );
1279  if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1280  $result->value = null;
1281  return $result;
1282  }
1283 
1284  // do it!
1285  self::defineTag( $tag );
1286 
1287  // log it
1288  $logId = self::logTagManagementAction( 'activate', $tag, $reason, $performer->getUser(),
1289  null, $logEntryTags );
1290 
1291  return Status::newGood( $logId );
1292  }
1293 
1303  public static function canDeactivateTag( $tag, Authority $performer = null ) {
1304  if ( $performer !== null ) {
1305  if ( !$performer->isAllowed( 'managechangetags' ) ) {
1306  return Status::newFatal( 'tags-manage-no-permission' );
1307  }
1308  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1309  return Status::newFatal(
1310  'tags-manage-blocked',
1311  $performer->getUser()->getName()
1312  );
1313  }
1314  }
1315 
1316  // only explicitly-defined tags can be deactivated
1317  $explicitlyDefinedTags = self::listExplicitlyDefinedTags();
1318  if ( !in_array( $tag, $explicitlyDefinedTags ) ) {
1319  return Status::newFatal( 'tags-deactivate-not-allowed', $tag );
1320  }
1321  return Status::newGood();
1322  }
1323 
1341  public static function deactivateTagWithChecks( string $tag, string $reason, Authority $performer,
1342  bool $ignoreWarnings = false, array $logEntryTags = []
1343  ) {
1344  // are we allowed to do this?
1345  $result = self::canDeactivateTag( $tag, $performer );
1346  if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1347  $result->value = null;
1348  return $result;
1349  }
1350 
1351  // do it!
1352  self::undefineTag( $tag );
1353 
1354  // log it
1355  $logId = self::logTagManagementAction( 'deactivate', $tag, $reason,
1356  $performer->getUser(), null, $logEntryTags );
1357 
1358  return Status::newGood( $logId );
1359  }
1360 
1368  public static function isTagNameValid( $tag ) {
1369  // no empty tags
1370  if ( $tag === '' ) {
1371  return Status::newFatal( 'tags-create-no-name' );
1372  }
1373 
1374  // tags cannot contain commas (used to be used as a delimiter in tag_summary table),
1375  // pipe (used as a delimiter between multiple tags in
1376  // SpecialRecentchanges and friends), or slashes (would break tag description messages in
1377  // MediaWiki namespace)
1378  if ( strpos( $tag, ',' ) !== false || strpos( $tag, '|' ) !== false
1379  || strpos( $tag, '/' ) !== false ) {
1380  return Status::newFatal( 'tags-create-invalid-chars' );
1381  }
1382 
1383  // could the MediaWiki namespace description messages be created?
1384  $title = Title::makeTitleSafe( NS_MEDIAWIKI, "Tag-$tag-description" );
1385  if ( $title === null ) {
1386  return Status::newFatal( 'tags-create-invalid-title-chars' );
1387  }
1388 
1389  return Status::newGood();
1390  }
1391 
1404  public static function canCreateTag( $tag, Authority $performer = null ) {
1405  $user = null;
1406  if ( $performer !== null ) {
1407  if ( !$performer->isAllowed( 'managechangetags' ) ) {
1408  return Status::newFatal( 'tags-manage-no-permission' );
1409  }
1410  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1411  return Status::newFatal(
1412  'tags-manage-blocked',
1413  $performer->getUser()->getName()
1414  );
1415  }
1416  // ChangeTagCanCreate hook still needs a full User object
1417  $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
1418  }
1419 
1420  $status = self::isTagNameValid( $tag );
1421  if ( !$status->isGood() ) {
1422  return $status;
1423  }
1424 
1425  // does the tag already exist?
1426  $tagUsage = self::tagUsageStatistics();
1427  if ( isset( $tagUsage[$tag] ) || in_array( $tag, self::listDefinedTags() ) ) {
1428  return Status::newFatal( 'tags-create-already-exists', $tag );
1429  }
1430 
1431  // check with hooks
1432  $canCreateResult = Status::newGood();
1433  Hooks::runner()->onChangeTagCanCreate( $tag, $user, $canCreateResult );
1434  return $canCreateResult;
1435  }
1436 
1456  public static function createTagWithChecks( string $tag, string $reason, Authority $performer,
1457  bool $ignoreWarnings = false, array $logEntryTags = []
1458  ) {
1459  // are we allowed to do this?
1460  $result = self::canCreateTag( $tag, $performer );
1461  if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1462  $result->value = null;
1463  return $result;
1464  }
1465 
1466  // do it!
1467  self::defineTag( $tag );
1468 
1469  // log it
1470  $logId = self::logTagManagementAction( 'create', $tag, $reason,
1471  $performer->getUser(), null, $logEntryTags );
1472 
1473  return Status::newGood( $logId );
1474  }
1475 
1488  public static function deleteTagEverywhere( $tag ) {
1489  $dbw = wfGetDB( DB_PRIMARY );
1490  $dbw->startAtomic( __METHOD__ );
1491 
1492  // fetch tag id, this must be done before calling undefineTag(), see T225564
1493  $tagId = MediaWikiServices::getInstance()->getChangeTagDefStore()->getId( $tag );
1494 
1495  // set ctd_user_defined = 0
1496  self::undefineTag( $tag );
1497 
1498  // delete from change_tag
1499  $dbw->delete( self::CHANGE_TAG, [ 'ct_tag_id' => $tagId ], __METHOD__ );
1500  $dbw->delete( self::CHANGE_TAG_DEF, [ 'ctd_name' => $tag ], __METHOD__ );
1501  $dbw->endAtomic( __METHOD__ );
1502 
1503  // give extensions a chance
1504  $status = Status::newGood();
1505  Hooks::runner()->onChangeTagAfterDelete( $tag, $status );
1506  // let's not allow error results, as the actual tag deletion succeeded
1507  if ( !$status->isOK() ) {
1508  wfDebug( 'ChangeTagAfterDelete error condition downgraded to warning' );
1509  $status->setOK( true );
1510  }
1511 
1512  // clear the memcache of defined tags
1514 
1515  return $status;
1516  }
1517 
1530  public static function canDeleteTag( $tag, Authority $performer = null, int $flags = 0 ) {
1531  $tagUsage = self::tagUsageStatistics();
1532  $user = null;
1533  if ( $performer !== null ) {
1534  if ( !$performer->isAllowed( 'deletechangetags' ) ) {
1535  return Status::newFatal( 'tags-delete-no-permission' );
1536  }
1537  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1538  return Status::newFatal(
1539  'tags-manage-blocked',
1540  $performer->getUser()->getName()
1541  );
1542  }
1543  // ChangeTagCanDelete hook still needs a full User object
1544  $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
1545  }
1546 
1547  if ( !isset( $tagUsage[$tag] ) && !in_array( $tag, self::listDefinedTags() ) ) {
1548  return Status::newFatal( 'tags-delete-not-found', $tag );
1549  }
1550 
1551  if ( $flags !== self::BYPASS_MAX_USAGE_CHECK &&
1552  isset( $tagUsage[$tag] ) &&
1553  $tagUsage[$tag] > self::MAX_DELETE_USES
1554  ) {
1555  return Status::newFatal( 'tags-delete-too-many-uses', $tag, self::MAX_DELETE_USES );
1556  }
1557 
1558  $softwareDefined = self::listSoftwareDefinedTags();
1559  if ( in_array( $tag, $softwareDefined ) ) {
1560  // extension-defined tags can't be deleted unless the extension
1561  // specifically allows it
1562  $status = Status::newFatal( 'tags-delete-not-allowed' );
1563  } else {
1564  // user-defined tags are deletable unless otherwise specified
1565  $status = Status::newGood();
1566  }
1567 
1568  Hooks::runner()->onChangeTagCanDelete( $tag, $user, $status );
1569  return $status;
1570  }
1571 
1589  public static function deleteTagWithChecks( string $tag, string $reason, Authority $performer,
1590  bool $ignoreWarnings = false, array $logEntryTags = []
1591  ) {
1592  // are we allowed to do this?
1593  $result = self::canDeleteTag( $tag, $performer );
1594  if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1595  $result->value = null;
1596  return $result;
1597  }
1598 
1599  // store the tag usage statistics
1600  $tagUsage = self::tagUsageStatistics();
1601  $hitcount = $tagUsage[$tag] ?? 0;
1602 
1603  // do it!
1604  $deleteResult = self::deleteTagEverywhere( $tag );
1605  if ( !$deleteResult->isOK() ) {
1606  return $deleteResult;
1607  }
1608 
1609  // log it
1610  $logId = self::logTagManagementAction( 'delete', $tag, $reason, $performer->getUser(),
1611  $hitcount, $logEntryTags );
1612 
1613  $deleteResult->value = $logId;
1614  return $deleteResult;
1615  }
1616 
1623  public static function listSoftwareActivatedTags() {
1624  // core active tags
1625  $tags = self::getSoftwareTags();
1626  $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1627  if ( !$hookContainer->isRegistered( 'ChangeTagsListActive' ) ) {
1628  return $tags;
1629  }
1630  $hookRunner = new HookRunner( $hookContainer );
1631  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1632  return $cache->getWithSetCallback(
1633  $cache->makeKey( 'active-tags' ),
1634  WANObjectCache::TTL_MINUTE * 5,
1635  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags, $hookRunner ) {
1636  $setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
1637 
1638  // Ask extensions which tags they consider active
1639  $hookRunner->onChangeTagsListActive( $tags );
1640  return $tags;
1641  },
1642  [
1643  'checkKeys' => [ $cache->makeKey( 'active-tags' ) ],
1644  'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1645  'pcTTL' => WANObjectCache::TTL_PROC_LONG
1646  ]
1647  );
1648  }
1649 
1657  public static function listDefinedTags() {
1659  $tags2 = self::listSoftwareDefinedTags();
1660  return array_values( array_unique( array_merge( $tags1, $tags2 ) ) );
1661  }
1662 
1671  public static function listExplicitlyDefinedTags() {
1672  $fname = __METHOD__;
1673 
1674  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1675  return $cache->getWithSetCallback(
1676  $cache->makeKey( 'valid-tags-db' ),
1677  WANObjectCache::TTL_MINUTE * 5,
1678  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname ) {
1679  $dbr = wfGetDB( DB_REPLICA );
1680 
1681  $setOpts += Database::getCacheSetOptions( $dbr );
1682  $tags = $dbr->newSelectQueryBuilder()
1683  ->select( 'ctd_name' )
1684  ->from( self::CHANGE_TAG_DEF )
1685  ->where( [ 'ctd_user_defined' => 1 ] )
1686  ->caller( $fname )
1687  ->fetchFieldValues();
1688 
1689  return array_unique( $tags );
1690  },
1691  [
1692  'checkKeys' => [ $cache->makeKey( 'valid-tags-db' ) ],
1693  'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1694  'pcTTL' => WANObjectCache::TTL_PROC_LONG
1695  ]
1696  );
1697  }
1698 
1708  public static function listSoftwareDefinedTags() {
1709  // core defined tags
1710  $tags = self::getSoftwareTags( true );
1711  $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1712  if ( !$hookContainer->isRegistered( 'ListDefinedTags' ) ) {
1713  return $tags;
1714  }
1715  $hookRunner = new HookRunner( $hookContainer );
1716  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1717  return $cache->getWithSetCallback(
1718  $cache->makeKey( 'valid-tags-hook' ),
1719  WANObjectCache::TTL_MINUTE * 5,
1720  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags, $hookRunner ) {
1721  $setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
1722 
1723  $hookRunner->onListDefinedTags( $tags );
1724  return array_unique( $tags );
1725  },
1726  [
1727  'checkKeys' => [ $cache->makeKey( 'valid-tags-hook' ) ],
1728  'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1729  'pcTTL' => WANObjectCache::TTL_PROC_LONG
1730  ]
1731  );
1732  }
1733 
1739  public static function purgeTagCacheAll() {
1740  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1741 
1742  $cache->touchCheckKey( $cache->makeKey( 'active-tags' ) );
1743  $cache->touchCheckKey( $cache->makeKey( 'valid-tags-db' ) );
1744  $cache->touchCheckKey( $cache->makeKey( 'valid-tags-hook' ) );
1745  $cache->touchCheckKey( $cache->makeKey( 'tags-usage-statistics' ) );
1746 
1747  MediaWikiServices::getInstance()->getChangeTagDefStore()->reloadMap();
1748  }
1749 
1756  public static function tagUsageStatistics() {
1757  $fname = __METHOD__;
1758 
1759  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1760  return $cache->getWithSetCallback(
1761  $cache->makeKey( 'tags-usage-statistics' ),
1762  WANObjectCache::TTL_MINUTE * 5,
1763  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname ) {
1764  $dbr = wfGetDB( DB_REPLICA );
1765  $res = $dbr->newSelectQueryBuilder()
1766  ->select( [ 'ctd_name', 'ctd_count' ] )
1767  ->from( self::CHANGE_TAG_DEF )
1768  ->orderBy( 'ctd_count', SelectQueryBuilder::SORT_DESC )
1769  ->caller( $fname )
1770  ->fetchResultSet();
1771 
1772  $out = [];
1773  foreach ( $res as $row ) {
1774  $out[$row->ctd_name] = $row->ctd_count;
1775  }
1776 
1777  return $out;
1778  },
1779  [
1780  'checkKeys' => [ $cache->makeKey( 'tags-usage-statistics' ) ],
1781  'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1782  'pcTTL' => WANObjectCache::TTL_PROC_LONG
1783  ]
1784  );
1785  }
1786 
1791  private const TAG_DESC_CHARACTER_LIMIT = 120;
1792 
1817  public static function getChangeTagListSummary( MessageLocalizer $localizer, Language $lang ) {
1818  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1819  return $cache->getWithSetCallback(
1820  $cache->makeKey( 'tags-list-summary', $lang->getCode() ),
1821  WANObjectCache::TTL_DAY,
1822  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $localizer ) {
1823  $tagHitCounts = self::tagUsageStatistics();
1824 
1825  $result = [];
1826  // Only list tags that are still actively defined
1827  foreach ( self::listDefinedTags() as $tagName ) {
1828  // Only list tags with more than 0 hits
1829  $hits = $tagHitCounts[$tagName] ?? 0;
1830  if ( $hits <= 0 ) {
1831  continue;
1832  }
1833 
1834  $labelMsg = self::tagShortDescriptionMessage( $tagName, $localizer );
1835  $descriptionMsg = self::tagLongDescriptionMessage( $tagName, $localizer );
1836  // Don't cache the message object, use the correct MessageLocalizer to parse later.
1837  $result[] = [
1838  'name' => $tagName,
1839  'labelMsg' => (bool)$labelMsg,
1840  'label' => $labelMsg ? $labelMsg->plain() : $tagName,
1841  'descriptionMsg' => (bool)$descriptionMsg,
1842  'description' => $descriptionMsg ? $descriptionMsg->plain() : '',
1843  'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
1844  ];
1845  }
1846  return $result;
1847  }
1848  );
1849  }
1850 
1863  public static function getChangeTagList( MessageLocalizer $localizer, Language $lang ) {
1864  $tags = self::getChangeTagListSummary( $localizer, $lang );
1865  foreach ( $tags as &$tagInfo ) {
1866  if ( $tagInfo['labelMsg'] ) {
1867  // Use localizer with the correct page title to parse plain message from the cache.
1868  $labelMsg = new RawMessage( $tagInfo['label'] );
1869  $tagInfo['label'] = Sanitizer::stripAllTags( $localizer->msg( $labelMsg )->parse() );
1870  } else {
1871  $tagInfo['label'] = $localizer->msg( 'tag-hidden', $tagInfo['name'] )->text();
1872  }
1873  if ( $tagInfo['descriptionMsg'] ) {
1874  $descriptionMsg = new RawMessage( $tagInfo['description'] );
1875  $tagInfo['description'] = $lang->truncateForVisual(
1876  Sanitizer::stripAllTags( $localizer->msg( $descriptionMsg )->parse() ),
1877  self::TAG_DESC_CHARACTER_LIMIT
1878  );
1879  }
1880  unset( $tagInfo['labelMsg'] );
1881  unset( $tagInfo['descriptionMsg'] );
1882  }
1883 
1884  // Instead of sorting by hit count (disabled for now), sort by display name
1885  usort( $tags, static function ( $a, $b ) {
1886  return strcasecmp( $a['label'], $b['label'] );
1887  } );
1888  return $tags;
1889  }
1890 
1905  public static function showTagEditingUI( Authority $performer ) {
1906  return $performer->isAllowed( 'changetags' ) && (bool)self::listExplicitlyDefinedTags();
1907  }
1908 }
const NS_MEDIAWIKI
Definition: Defines.php:72
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfWarn( $msg, $callerOffset=1, $level=E_USER_NOTICE)
Send a warning either to the debug log or in a PHP error depending on $wgDevelopmentWarnings.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
const TAG_MANUAL_REVERT
The tagged edit restores the page to an earlier revision.
Definition: ChangeTags.php:80
const TAG_SERVER_SIDE_UPLOAD
This tagged edit was performed while importing media files using the importImages....
Definition: ChangeTags.php:92
static modifyDisplayQuery(&$tables, &$fields, &$conds, &$join_conds, &$options, $filter_tag='', bool $exclude=false)
Applies all tags-related changes to a query.
Definition: ChangeTags.php:906
static getTags(IDatabase $db, $rc_id=null, $rev_id=null, $log_id=null)
Return all the tags associated with the given recent change ID, revision ID, and/or log entry ID.
Definition: ChangeTags.php:597
const TAG_REMOVED_REDIRECT
The tagged edit turns a redirect page into a non-redirect.
Definition: ChangeTags.php:47
static listSoftwareDefinedTags()
Lists tags defined by core or extensions using the ListDefinedTags hook.
static canDeactivateTag( $tag, Authority $performer=null)
Is it OK to allow the user to deactivate this tag?
static buildTagFilterSelector( $selected='', $ooui=false, IContextSource $context=null)
Build a text box to select a change tag.
static canCreateTag( $tag, Authority $performer=null)
Is it OK to allow the user to create this tag?
const TAG_REPLACE
The tagged edit removes more than 90% of the content of the page.
Definition: ChangeTags.php:59
static logTagManagementAction(string $action, string $tag, string $reason, UserIdentity $user, $tagCount=null, array $logEntryTags=[])
Writes a tag action into the tag management log.
const TAG_CONTENT_MODEL_CHANGE
The tagged edit changes the content model of the page.
Definition: ChangeTags.php:38
static updateTagsWithChecks( $tagsToAdd, $tagsToRemove, $rc_id, $rev_id, $log_id, $params, string $reason, Authority $performer)
Adds and/or removes tags to/from a given change, checking whether it is allowed first,...
Definition: ChangeTags.php:793
static tagLongDescriptionMessage( $tag, MessageLocalizer $context)
Get the message object for the tag's long description.
Definition: ChangeTags.php:300
static deactivateTagWithChecks(string $tag, string $reason, Authority $performer, bool $ignoreWarnings=false, array $logEntryTags=[])
Deactivates a tag, checking whether it is allowed first, and adding a log entry afterwards.
static showTagEditingUI(Authority $performer)
Indicate whether change tag editing UI is relevant.
static deleteTagWithChecks(string $tag, string $reason, Authority $performer, bool $ignoreWarnings=false, array $logEntryTags=[])
Deletes a tag, checking whether it is allowed first, and adding a log entry afterwards.
static getSoftwareTags( $all=false)
Loads defined core tags, checks for invalid types (if not array), and filters for supported and enabl...
Definition: ChangeTags.php:157
const TAG_CHANGED_REDIRECT_TARGET
The tagged edit changes the target of a redirect page.
Definition: ChangeTags.php:51
const TAG_REVERTED
The tagged edit is reverted by a subsequent edit (which is tagged by one of TAG_ROLLBACK,...
Definition: ChangeTags.php:88
static restrictedTagError( $msgOne, $msgMulti, $tags)
Helper function to generate a fatal status with a 'not-allowed' type error.
Definition: ChangeTags.php:611
const TAG_ROLLBACK
The tagged edit is a rollback (undoes the previous edit and all immediately preceding edits by the sa...
Definition: ChangeTags.php:67
static getChangeTagListSummary(MessageLocalizer $localizer, Language $lang)
Get information about change tags, without parsing messages, for tag filter dropdown menus.
static makeTagSummarySubquery( $tables)
Make the tag summary subquery based on the given tables and return it.
static getChangeTagList(MessageLocalizer $localizer, Language $lang)
Get information about change tags for tag filter dropdown menus.
const BYPASS_MAX_USAGE_CHECK
Flag for canDeleteTag().
Definition: ChangeTags.php:102
static listSoftwareActivatedTags()
Lists those tags which core or extensions report as being "active".
static undefineTag( $tag)
Update ctd_user_defined = 0 field in change_tag_def.
static canActivateTag( $tag, Authority $performer=null)
Is it OK to allow the user to activate this tag?
static purgeTagCacheAll()
Invalidates the short-term cache of defined tags used by the list*DefinedTags functions,...
static addTags( $tags, $rc_id=null, $rev_id=null, $log_id=null, $params=null, RecentChange $rc=null)
Add tags to a change given its rc_id, rev_id and/or log_id.
Definition: ChangeTags.php:328
static addTagsAccompanyingChangeWithChecks(array $tags, $rc_id, $rev_id, $log_id, $params, Authority $performer)
Adds tags to a given change, checking whether it is allowed first, but without adding a log entry.
Definition: ChangeTags.php:689
static canDeleteTag( $tag, Authority $performer=null, int $flags=0)
Is it OK to allow the user to delete this tag?
static getDisplayTableName()
Get the name of the change_tag table to use for modifyDisplayQuery().
Definition: ChangeTags.php:990
static formatSummaryRow( $tags, $page, MessageLocalizer $localizer=null)
Creates HTML for the given tags.
Definition: ChangeTags.php:193
static tagUsageStatistics()
Returns a map of any tags used on the wiki to number of edits tagged with them, ordered descending by...
static tagShortDescriptionMessage( $tag, MessageLocalizer $context)
Get the message object for the tag's short description.
Definition: ChangeTags.php:253
static getTagsWithData(IDatabase $db, $rc_id=null, $rev_id=null, $log_id=null)
Return all the tags associated with the given recent change ID, revision ID, and/or log entry ID,...
Definition: ChangeTags.php:555
static listDefinedTags()
Basically lists defined tags which count even if they aren't applied to anything.
static tagDescription( $tag, MessageLocalizer $context)
Get a short description for a tag.
Definition: ChangeTags.php:283
static defineTag( $tag)
Set ctd_user_defined = 1 in change_tag_def without checking that the tag name is valid.
static createTagWithChecks(string $tag, string $reason, Authority $performer, bool $ignoreWarnings=false, array $logEntryTags=[])
Creates a tag by adding it to change_tag_def table.
const TAG_UNDO
The tagged edit is was performed via the "undo" link.
Definition: ChangeTags.php:74
static canAddTagsAccompanyingChange(array $tags, Authority $performer=null, $checkBlock=true)
Is it OK to allow the user to apply all the specified tags at the same time as they edit/make the cha...
Definition: ChangeTags.php:635
const TAG_BLANK
The tagged edit blanks the page (replaces it with the empty string).
Definition: ChangeTags.php:55
static isTagNameValid( $tag)
Is the tag name valid?
const REVERT_TAGS
List of tags which denote a revert of some sort.
Definition: ChangeTags.php:97
static activateTagWithChecks(string $tag, string $reason, Authority $performer, bool $ignoreWarnings=false, array $logEntryTags=[])
Activates a tag, checking whether it is allowed first, and adding a log entry afterwards.
static deleteTagEverywhere( $tag)
Permanently removes all traces of a tag from the DB.
const TAG_NEW_REDIRECT
The tagged edit creates a new redirect (either by creating a new page or turning an existing page int...
Definition: ChangeTags.php:43
static bool $avoidReopeningTablesForTesting
If true, this class attempts to avoid reopening database tables within the same query,...
Definition: ChangeTags.php:148
static canUpdateTags(array $tagsToAdd, array $tagsToRemove, Authority $performer=null)
Is it OK to allow the user to adds and remove the given tags to/from a change?
Definition: ChangeTags.php:719
static listExplicitlyDefinedTags()
Lists tags explicitly defined in the change_tag_def table of the database.
static updateTags( $tagsToAdd, $tagsToRemove, &$rc_id=null, &$rev_id=null, &$log_id=null, $params=null, RecentChange $rc=null, UserIdentity $user=null)
Add and remove tags to/from a change given its rc_id, rev_id and/or log_id, without verifying that th...
Definition: ChangeTags.php:365
static runner()
Get a HookRunner instance for calling hooks using the new interfaces.
Definition: Hooks.php:173
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:214
Base class for language-specific code.
Definition: Language.php:53
MediaWiki exception.
Definition: MWException.php:29
Class for creating new log entries and inserting them into the database.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:566
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Exception representing a failure to look up a row from a name table.
static plaintextParam( $plaintext)
Definition: Message.php:1266
Variant of the Message class.
Definition: RawMessage.php:35
Utility class for creating new RC entries.
static getMain()
Get the RequestContext object associated with the main request.
static suggestTarget( $target, array $ids)
Suggest a target for the revision deletion Optionally override this function.
static escapeClass( $class)
Given a value, escape it so that it can be used as a CSS class and return it.
Definition: Sanitizer.php:1105
static stripAllTags( $html)
Take a fragment of (potentially invalid) HTML and return a version with any tags removed,...
Definition: Sanitizer.php:1709
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,...
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
static newFromText( $text, $defaultNamespace=NS_MAIN)
Create a new Title from text, such as what one would find in a link.
Definition: Title.php:370
static makeTitleSafe( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:664
Note that none of the methods in this class are stable to override.
Class for generating HTML <select> or <datalist> elements.
Definition: XmlSelect.php:26
static listDropDownOptionsOoui( $options)
Convert options for a drop-down box into a format accepted by OOUI\DropdownInputWidget etc.
Definition: Xml.php:600
static input( $name, $size=false, $value=false, $attribs=[])
Convenience function to build an HTML text input field.
Definition: Xml.php:283
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:134
Interface for objects which can provide a MediaWiki context on request.
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
getUser()
Returns the performer of the actions associated with this authority.
isAllowed(string $permission)
Checks whether this authority has the given permission in general.
Interface for objects representing user identity.
Interface for localizing messages in MediaWiki.
msg( $key,... $params)
This is the method for getting translated interface messages.
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:39
newSelectQueryBuilder()
Create an empty SelectQueryBuilder which can be used to run queries against this connection.
$cache
Definition: mcc.php:33
const DB_REPLICA
Definition: defines.php:26
const DB_PRIMARY
Definition: defines.php:28
if(!isset( $args[0])) $lang