MediaWiki  master
ChangeTags.php
Go to the documentation of this file.
1 <?php
34 
35 class ChangeTags {
39  public const TAG_CONTENT_MODEL_CHANGE = 'mw-contentmodelchange';
44  public const TAG_NEW_REDIRECT = 'mw-new-redirect';
48  public const TAG_REMOVED_REDIRECT = 'mw-removed-redirect';
52  public const TAG_CHANGED_REDIRECT_TARGET = 'mw-changed-redirect-target';
56  public const TAG_BLANK = 'mw-blank';
60  public const TAG_REPLACE = 'mw-replace';
68  public const TAG_ROLLBACK = 'mw-rollback';
75  public const TAG_UNDO = 'mw-undo';
81  public const TAG_MANUAL_REVERT = 'mw-manual-revert';
89  public const TAG_REVERTED = 'mw-reverted';
93  public const TAG_SERVER_SIDE_UPLOAD = 'mw-server-side-upload';
94 
99 
103  public const BYPASS_MAX_USAGE_CHECK = 1;
104 
110  private const MAX_DELETE_USES = 5000;
111 
115  private const DEFINED_SOFTWARE_TAGS = [
116  'mw-contentmodelchange',
117  'mw-new-redirect',
118  'mw-removed-redirect',
119  'mw-changed-redirect-target',
120  'mw-blank',
121  'mw-replace',
122  'mw-rollback',
123  'mw-undo',
124  'mw-manual-revert',
125  'mw-reverted',
126  'mw-server-side-upload',
127  ];
128 
132  private const CHANGE_TAG = 'change_tag';
133 
137  private const CHANGE_TAG_DEF = 'change_tag_def';
138 
139  public const DISPLAY_TABLE_ALIAS = 'changetagdisplay';
140 
151  public static $avoidReopeningTablesForTesting = false;
152 
160  public static function getSoftwareTags( $all = false ) {
161  $coreTags = MediaWikiServices::getInstance()->getMainConfig()->get(
162  MainConfigNames::SoftwareTags );
163  $softwareTags = [];
164 
165  if ( !is_array( $coreTags ) ) {
166  wfWarn( 'wgSoftwareTags should be associative array of enabled tags.
167  Please refer to documentation for the list of tags you can enable' );
168  return $softwareTags;
169  }
170 
171  $availableSoftwareTags = !$all ?
172  array_keys( array_filter( $coreTags ) ) :
173  array_keys( $coreTags );
174 
175  $softwareTags = array_intersect(
176  $availableSoftwareTags,
177  self::DEFINED_SOFTWARE_TAGS
178  );
179 
180  return $softwareTags;
181  }
182 
196  public static function formatSummaryRow( $tags, $page, MessageLocalizer $localizer = null ) {
197  if ( $tags === '' || $tags === null ) {
198  return [ '', [] ];
199  }
200  if ( !$localizer ) {
201  $localizer = RequestContext::getMain();
202  }
203 
204  $classes = [];
205 
206  $tags = explode( ',', $tags );
207  $order = array_flip( self::listDefinedTags() );
208  usort( $tags, static function ( $a, $b ) use ( $order ) {
209  return ( $order[ $a ] ?? INF ) <=> ( $order[ $b ] ?? INF );
210  } );
211 
212  $displayTags = [];
213  foreach ( $tags as $tag ) {
214  if ( $tag === '' ) {
215  continue;
216  }
217  $classes[] = Sanitizer::escapeClass( "mw-tag-$tag" );
218  $description = self::tagDescription( $tag, $localizer );
219  if ( $description === false ) {
220  continue;
221  }
222  $displayTags[] = Xml::tags(
223  'span',
224  [ 'class' => 'mw-tag-marker ' .
225  Sanitizer::escapeClass( "mw-tag-marker-$tag" ) ],
226  $description
227  );
228  }
229 
230  if ( !$displayTags ) {
231  return [ '', $classes ];
232  }
233 
234  $markers = $localizer->msg( 'tag-list-wrapper' )
235  ->numParams( count( $displayTags ) )
236  ->rawParams( implode( ' ', $displayTags ) )
237  ->parse();
238  $markers = Xml::tags( 'span', [ 'class' => 'mw-tag-markers' ], $markers );
239 
240  return [ $markers, $classes ];
241  }
242 
256  public static function tagShortDescriptionMessage( $tag, MessageLocalizer $context ) {
257  $msg = $context->msg( "tag-$tag" );
258  if ( !$msg->exists() ) {
259  // No such message
260  // Pass through ->msg(), even though it seems redundant, to avoid requesting
261  // the user's language from session-less entry points (T227233)
262  return $context->msg( new RawMessage( '$1', [ Message::plaintextParam( $tag ) ] ) );
263  }
264  if ( $msg->isDisabled() ) {
265  // The message exists but is disabled, hide the tag.
266  return false;
267  }
268 
269  // Message exists and isn't disabled, use it.
270  return $msg;
271  }
272 
286  public static function tagDescription( $tag, MessageLocalizer $context ) {
287  $msg = self::tagShortDescriptionMessage( $tag, $context );
288  return $msg ? $msg->parse() : false;
289  }
290 
303  public static function tagLongDescriptionMessage( $tag, MessageLocalizer $context ) {
304  $msg = $context->msg( "tag-$tag-description" );
305  if ( !$msg->exists() ) {
306  return false;
307  }
308  if ( $msg->isDisabled() ) {
309  // The message exists but is disabled, hide the description.
310  return false;
311  }
312 
313  // Message exists and isn't disabled, use it.
314  return $msg;
315  }
316 
330  public static function addTags( $tags, $rc_id = null, $rev_id = null,
331  $log_id = null, $params = null, RecentChange $rc = null
332  ) {
333  $result = self::updateTags( $tags, null, $rc_id, $rev_id, $log_id, $params, $rc );
334  return (bool)$result[0];
335  }
336 
366  public static function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id = null,
367  &$rev_id = null, &$log_id = null, $params = null, RecentChange $rc = null,
368  UserIdentity $user = null
369  ) {
370  $tagsToAdd = array_filter(
371  (array)$tagsToAdd, // Make sure we're submitting all tags...
372  static function ( $value ) {
373  return ( $value ?? '' ) !== '';
374  }
375  );
376  $tagsToRemove = array_filter(
377  (array)$tagsToRemove,
378  static function ( $value ) {
379  return ( $value ?? '' ) !== '';
380  }
381  );
382 
383  if ( !$rc_id && !$rev_id && !$log_id ) {
384  throw new BadMethodCallException( 'At least one of: RCID, revision ID, and log ID MUST be ' .
385  'specified when adding or removing a tag from a change!' );
386  }
387 
388  $dbw = wfGetDB( DB_PRIMARY );
389 
390  // Might as well look for rcids and so on.
391  if ( !$rc_id ) {
392  // Info might be out of date, somewhat fractionally, on replica DB.
393  // LogEntry/LogPage and WikiPage match rev/log/rc timestamps,
394  // so use that relation to avoid full table scans.
395  if ( $log_id ) {
396  $rc_id = $dbw->newSelectQueryBuilder()
397  ->select( 'rc_id' )
398  ->from( 'logging' )
399  ->join( 'recentchanges', null, [
400  'rc_timestamp = log_timestamp',
401  'rc_logid = log_id'
402  ] )
403  ->where( [ 'log_id' => $log_id ] )
404  ->caller( __METHOD__ )
405  ->fetchField();
406  } elseif ( $rev_id ) {
407  $rc_id = $dbw->newSelectQueryBuilder()
408  ->select( 'rc_id' )
409  ->from( 'revision' )
410  ->join( 'recentchanges', null, [
411  'rc_this_oldid = rev_id'
412  ] )
413  ->where( [ 'rev_id' => $rev_id ] )
414  ->caller( __METHOD__ )
415  ->fetchField();
416  }
417  } elseif ( !$log_id && !$rev_id ) {
418  // Info might be out of date, somewhat fractionally, on replica DB.
419  $log_id = $dbw->newSelectQueryBuilder()
420  ->select( 'rc_logid' )
421  ->from( 'recentchanges' )
422  ->where( [ 'rc_id' => $rc_id ] )
423  ->caller( __METHOD__ )
424  ->fetchField();
425  $rev_id = $dbw->newSelectQueryBuilder()
426  ->select( 'rc_this_oldid' )
427  ->from( 'recentchanges' )
428  ->where( [ 'rc_id' => $rc_id ] )
429  ->caller( __METHOD__ )
430  ->fetchField();
431  }
432 
433  if ( $log_id && !$rev_id ) {
434  $rev_id = $dbw->newSelectQueryBuilder()
435  ->select( 'ls_value' )
436  ->from( 'log_search' )
437  ->where( [ 'ls_field' => 'associated_rev_id', 'ls_log_id' => $log_id ] )
438  ->caller( __METHOD__ )
439  ->fetchField();
440  } elseif ( !$log_id && $rev_id ) {
441  $log_id = $dbw->newSelectQueryBuilder()
442  ->select( 'ls_log_id' )
443  ->from( 'log_search' )
444  ->where( [ 'ls_field' => 'associated_rev_id', 'ls_value' => (string)$rev_id ] )
445  ->caller( __METHOD__ )
446  ->fetchField();
447  }
448 
449  $prevTags = self::getTags( $dbw, $rc_id, $rev_id, $log_id );
450 
451  // add tags
452  $tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
453  $newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
454 
455  // remove tags
456  $tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
457  $newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
458 
459  sort( $prevTags );
460  sort( $newTags );
461  if ( $prevTags == $newTags ) {
462  return [ [], [], $prevTags ];
463  }
464 
465  // insert a row into change_tag for each new tag
466  $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
467  if ( count( $tagsToAdd ) ) {
468  $changeTagMapping = [];
469  foreach ( $tagsToAdd as $tag ) {
470  $changeTagMapping[$tag] = $changeTagDefStore->acquireId( $tag );
471  }
472  $fname = __METHOD__;
473  // T207881: update the counts at the end of the transaction
474  $dbw->onTransactionPreCommitOrIdle( static function () use ( $dbw, $tagsToAdd, $fname ) {
475  $dbw->update(
476  self::CHANGE_TAG_DEF,
477  [ 'ctd_count = ctd_count + 1' ],
478  [ 'ctd_name' => $tagsToAdd ],
479  $fname
480  );
481  }, $fname );
482 
483  $tagsRows = [];
484  foreach ( $tagsToAdd as $tag ) {
485  // Filter so we don't insert NULLs as zero accidentally.
486  // Keep in mind that $rc_id === null means "I don't care/know about the
487  // rc_id, just delete $tag on this revision/log entry". It doesn't
488  // mean "only delete tags on this revision/log WHERE rc_id IS NULL".
489  $tagsRows[] = array_filter(
490  [
491  'ct_rc_id' => $rc_id,
492  'ct_log_id' => $log_id,
493  'ct_rev_id' => $rev_id,
494  'ct_params' => $params,
495  'ct_tag_id' => $changeTagMapping[$tag] ?? null,
496  ]
497  );
498 
499  }
500 
501  $dbw->insert( self::CHANGE_TAG, $tagsRows, __METHOD__, [ 'IGNORE' ] );
502  }
503 
504  // delete from change_tag
505  if ( count( $tagsToRemove ) ) {
506  $fname = __METHOD__;
507  foreach ( $tagsToRemove as $tag ) {
508  $conds = array_filter(
509  [
510  'ct_rc_id' => $rc_id,
511  'ct_log_id' => $log_id,
512  'ct_rev_id' => $rev_id,
513  'ct_tag_id' => $changeTagDefStore->getId( $tag ),
514  ]
515  );
516  $dbw->delete( self::CHANGE_TAG, $conds, __METHOD__ );
517  if ( $dbw->affectedRows() ) {
518  // T207881: update the counts at the end of the transaction
519  $dbw->onTransactionPreCommitOrIdle( static function () use ( $dbw, $tag, $fname ) {
520  $dbw->update(
521  self::CHANGE_TAG_DEF,
522  [ 'ctd_count = ctd_count - 1' ],
523  [ 'ctd_name' => $tag ],
524  $fname
525  );
526 
527  $dbw->delete(
528  self::CHANGE_TAG_DEF,
529  [ 'ctd_name' => $tag, 'ctd_count' => 0, 'ctd_user_defined' => 0 ],
530  $fname
531  );
532  }, $fname );
533  }
534  }
535  }
536 
537  $userObj = $user ? MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $user ) : null;
538  Hooks::runner()->onChangeTagsAfterUpdateTags( $tagsToAdd, $tagsToRemove, $prevTags,
539  $rc_id, $rev_id, $log_id, $params, $rc, $userObj );
540 
541  return [ $tagsToAdd, $tagsToRemove, $prevTags ];
542  }
543 
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 BadMethodCallException( '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 && !$tagsToRemove ) {
797  // no-op, don't bother
798  return Status::newGood( (object)[
799  'logId' => null,
800  'addedTags' => [],
801  'removedTags' => [],
802  ] );
803  }
804 
805  $tagsToAdd ??= [];
806  $tagsToRemove ??= [];
807 
808  // are we allowed to do this?
809  $result = self::canUpdateTags( $tagsToAdd, $tagsToRemove, $performer );
810  if ( !$result->isOK() ) {
811  $result->value = null;
812  return $result;
813  }
814 
815  // basic rate limiting
816  $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
817  if ( $user->pingLimiter( 'changetag' ) ) {
818  return Status::newFatal( 'actionthrottledtext' );
819  }
820 
821  // do it!
822  [ $tagsAdded, $tagsRemoved, $initialTags ] = self::updateTags( $tagsToAdd,
823  $tagsToRemove, $rc_id, $rev_id, $log_id, $params, null, $user );
824  if ( !$tagsAdded && !$tagsRemoved ) {
825  // no-op, don't log it
826  return Status::newGood( (object)[
827  'logId' => null,
828  'addedTags' => [],
829  'removedTags' => [],
830  ] );
831  }
832 
833  // log it
834  $logEntry = new ManualLogEntry( 'tag', 'update' );
835  $logEntry->setPerformer( $performer->getUser() );
836  $logEntry->setComment( $reason );
837 
838  // find the appropriate target page
839  if ( $rev_id ) {
840  $revisionRecord = MediaWikiServices::getInstance()
841  ->getRevisionLookup()
842  ->getRevisionById( $rev_id );
843  if ( $revisionRecord ) {
844  $logEntry->setTarget( $revisionRecord->getPageAsLinkTarget() );
845  }
846  } elseif ( $log_id ) {
847  // This function is from revision deletion logic and has nothing to do with
848  // change tags, but it appears to be the only other place in core where we
849  // perform logged actions on log items.
850  $logEntry->setTarget( RevDelLogList::suggestTarget( null, [ $log_id ] ) );
851  }
852 
853  if ( !$logEntry->getTarget() ) {
854  // target is required, so we have to set something
855  $logEntry->setTarget( SpecialPage::getTitleFor( 'Tags' ) );
856  }
857 
858  $logParams = [
859  '4::revid' => $rev_id,
860  '5::logid' => $log_id,
861  '6:list:tagsAdded' => $tagsAdded,
862  '7:number:tagsAddedCount' => count( $tagsAdded ),
863  '8:list:tagsRemoved' => $tagsRemoved,
864  '9:number:tagsRemovedCount' => count( $tagsRemoved ),
865  'initialTags' => $initialTags,
866  ];
867  $logEntry->setParameters( $logParams );
868  $logEntry->setRelations( [ 'Tag' => array_merge( $tagsAdded, $tagsRemoved ) ] );
869 
870  $dbw = wfGetDB( DB_PRIMARY );
871  $logId = $logEntry->insert( $dbw );
872  // Only send this to UDP, not RC, similar to patrol events
873  $logEntry->publish( $logId, 'udp' );
874 
875  return Status::newGood( (object)[
876  'logId' => $logId,
877  'addedTags' => $tagsAdded,
878  'removedTags' => $tagsRemoved,
879  ] );
880  }
881 
902  public static function modifyDisplayQuery( &$tables, &$fields, &$conds,
903  &$join_conds, &$options, $filter_tag = '', bool $exclude = false
904  ) {
905  $useTagFilter = MediaWikiServices::getInstance()->getMainConfig()->get(
906  MainConfigNames::UseTagFilter );
907 
908  // Normalize to arrays
909  $tables = (array)$tables;
910  $fields = (array)$fields;
911  $conds = (array)$conds;
912  $options = (array)$options;
913 
914  $fields['ts_tags'] = self::makeTagSummarySubquery( $tables );
915  // We use an alias and qualify the conditions in case there are
916  // multiple joins to this table.
917  // In particular for compatibility with the RC filters that extension Translate does.
918 
919  // Figure out which ID field to use
920  if ( in_array( 'recentchanges', $tables ) ) {
921  $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rc_id=rc_id';
922  } elseif ( in_array( 'logging', $tables ) ) {
923  $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_log_id=log_id';
924  } elseif ( in_array( 'revision', $tables ) ) {
925  $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=rev_id';
926  } elseif ( in_array( 'archive', $tables ) ) {
927  $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=ar_rev_id';
928  } else {
929  throw new InvalidArgumentException( 'Unable to determine appropriate JOIN condition for tagging.' );
930  }
931 
932  if ( !$useTagFilter ) {
933  return;
934  }
935 
936  if ( !is_array( $filter_tag ) ) {
937  // some callers provide false or null
938  $filter_tag = (string)$filter_tag;
939  }
940 
941  if ( $filter_tag !== [] && $filter_tag !== '' ) {
942  // Somebody wants to filter on a tag.
943  // Add an INNER JOIN on change_tag
944  $tagTable = self::getDisplayTableName();
945  $filterTagIds = [];
946  $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
947  foreach ( (array)$filter_tag as $filterTagName ) {
948  try {
949  $filterTagIds[] = $changeTagDefStore->getId( $filterTagName );
950  } catch ( NameTableAccessException $exception ) {
951  }
952  }
953 
954  if ( $exclude ) {
955  if ( $filterTagIds !== [] ) {
956  $tables[self::DISPLAY_TABLE_ALIAS] = $tagTable;
957  $join_conds[self::DISPLAY_TABLE_ALIAS] = [
958  'LEFT JOIN',
959  [ $join_cond, self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => $filterTagIds ]
960  ];
961  $conds[] = self::DISPLAY_TABLE_ALIAS . ".ct_tag_id IS NULL";
962  }
963  } else {
964  $tables[self::DISPLAY_TABLE_ALIAS] = $tagTable;
965  $join_conds[self::DISPLAY_TABLE_ALIAS] = [ 'JOIN', $join_cond ];
966  if ( $filterTagIds !== [] ) {
967  $conds[self::DISPLAY_TABLE_ALIAS . '.ct_tag_id'] = $filterTagIds;
968  } else {
969  // all tags were invalid, return nothing
970  $conds[] = '0=1';
971  }
972 
973  if (
974  is_array( $filter_tag ) && count( $filter_tag ) > 1 &&
975  !in_array( 'DISTINCT', $options )
976  ) {
977  $options[] = 'DISTINCT';
978  }
979  }
980  }
981  }
982 
989  public static function getDisplayTableName() {
990  $tagTable = self::CHANGE_TAG;
991  if ( self::$avoidReopeningTablesForTesting && defined( 'MW_PHPUNIT_TEST' ) ) {
992  $db = wfGetDB( DB_REPLICA );
993 
994  if ( $db->getType() === 'mysql' ) {
995  // When filtering by tag, we are using the change_tag table twice:
996  // Once in a join for filtering, and once in a sub-query to list all
997  // tags for each revision. This does not work with temporary tables
998  // on some versions of MySQL, which causes phpunit tests to fail.
999  // As a hacky workaround, we copy the temporary table, and join
1000  // against the copy. It is acknowledged that this is quite horrific.
1001  // Discuss at T256006.
1002 
1003  $tagTable = 'change_tag_for_display_query';
1004  if ( !$db->tableExists( $tagTable ) ) {
1005  $db->query(
1006  'CREATE TEMPORARY TABLE IF NOT EXISTS ' . $db->tableName( $tagTable )
1007  . ' LIKE ' . $db->tableName( self::CHANGE_TAG ),
1008  __METHOD__
1009  );
1010  $db->query(
1011  'INSERT IGNORE INTO ' . $db->tableName( $tagTable )
1012  . ' SELECT * FROM ' . $db->tableName( self::CHANGE_TAG ),
1013  __METHOD__
1014  );
1015  }
1016  }
1017  }
1018  return $tagTable;
1019  }
1020 
1028  public static function makeTagSummarySubquery( $tables ) {
1029  // Normalize to arrays
1030  $tables = (array)$tables;
1031 
1032  // Figure out which ID field to use
1033  if ( in_array( 'recentchanges', $tables ) ) {
1034  $join_cond = 'ct_rc_id=rc_id';
1035  } elseif ( in_array( 'logging', $tables ) ) {
1036  $join_cond = 'ct_log_id=log_id';
1037  } elseif ( in_array( 'revision', $tables ) ) {
1038  $join_cond = 'ct_rev_id=rev_id';
1039  } elseif ( in_array( 'archive', $tables ) ) {
1040  $join_cond = 'ct_rev_id=ar_rev_id';
1041  } else {
1042  throw new InvalidArgumentException( 'Unable to determine appropriate JOIN condition for tagging.' );
1043  }
1044 
1045  $tagTables = [ self::CHANGE_TAG, self::CHANGE_TAG_DEF ];
1046  $join_cond_ts_tags = [ self::CHANGE_TAG_DEF => [ 'JOIN', 'ct_tag_id=ctd_id' ] ];
1047  $field = 'ctd_name';
1048 
1049  return wfGetDB( DB_REPLICA )->buildGroupConcatField(
1050  ',', $tagTables, $field, $join_cond, $join_cond_ts_tags
1051  );
1052  }
1053 
1065  public static function buildTagFilterSelector(
1066  $selected = '', $ooui = false, IContextSource $context = null
1067  ) {
1068  if ( !$context ) {
1069  $context = RequestContext::getMain();
1070  }
1071 
1072  $config = $context->getConfig();
1073  if ( !$config->get( MainConfigNames::UseTagFilter ) ||
1074  !count( self::listDefinedTags() ) ) {
1075  return [];
1076  }
1077 
1078  $tags = self::getChangeTagList( $context, $context->getLanguage() );
1079  $autocomplete = [];
1080  foreach ( $tags as $tagInfo ) {
1081  $autocomplete[ $tagInfo['label'] ] = $tagInfo['name'];
1082  }
1083 
1084  $data = [
1086  'label',
1087  [ 'for' => 'tagfilter' ],
1088  $context->msg( 'tag-filter' )->parse()
1089  )
1090  ];
1091 
1092  if ( $ooui ) {
1093  $options = Xml::listDropDownOptionsOoui( $autocomplete );
1094 
1095  $data[] = new OOUI\ComboBoxInputWidget( [
1096  'id' => 'tagfilter',
1097  'name' => 'tagfilter',
1098  'value' => $selected,
1099  'classes' => 'mw-tagfilter-input',
1100  'options' => $options,
1101  ] );
1102  } else {
1103  $datalist = new XmlSelect( false, 'tagfilter-datalist' );
1104  $datalist->setTagName( 'datalist' );
1105  $datalist->addOptions( $autocomplete );
1106 
1107  $data[] = Xml::input(
1108  'tagfilter',
1109  20,
1110  $selected,
1111  [
1112  'class' => 'mw-tagfilter-input mw-ui-input mw-ui-input-inline',
1113  'id' => 'tagfilter',
1114  'list' => 'tagfilter-datalist',
1115  ]
1116  ) . $datalist->getHTML();
1117  }
1118 
1119  return $data;
1120  }
1121 
1130  public static function defineTag( $tag ) {
1131  $dbw = wfGetDB( DB_PRIMARY );
1132  $tagDef = [
1133  'ctd_name' => $tag,
1134  'ctd_user_defined' => 1,
1135  'ctd_count' => 0
1136  ];
1137  $dbw->upsert(
1138  self::CHANGE_TAG_DEF,
1139  $tagDef,
1140  'ctd_name',
1141  [ 'ctd_user_defined' => 1 ],
1142  __METHOD__
1143  );
1144 
1145  // clear the memcache of defined tags
1147  }
1148 
1157  public static function undefineTag( $tag ) {
1158  $dbw = wfGetDB( DB_PRIMARY );
1159 
1160  $dbw->update(
1161  self::CHANGE_TAG_DEF,
1162  [ 'ctd_user_defined' => 0 ],
1163  [ 'ctd_name' => $tag ],
1164  __METHOD__
1165  );
1166 
1167  $dbw->delete(
1168  self::CHANGE_TAG_DEF,
1169  [ 'ctd_name' => $tag, 'ctd_count' => 0 ],
1170  __METHOD__
1171  );
1172 
1173  // clear the memcache of defined tags
1175  }
1176 
1191  protected static function logTagManagementAction( string $action, string $tag, string $reason,
1192  UserIdentity $user, $tagCount = null, array $logEntryTags = []
1193  ) {
1194  $dbw = wfGetDB( DB_PRIMARY );
1195 
1196  $logEntry = new ManualLogEntry( 'managetags', $action );
1197  $logEntry->setPerformer( $user );
1198  // target page is not relevant, but it has to be set, so we just put in
1199  // the title of Special:Tags
1200  $logEntry->setTarget( Title::newFromText( 'Special:Tags' ) );
1201  $logEntry->setComment( $reason );
1202 
1203  $params = [ '4::tag' => $tag ];
1204  if ( $tagCount !== null ) {
1205  $params['5:number:count'] = $tagCount;
1206  }
1207  $logEntry->setParameters( $params );
1208  $logEntry->setRelations( [ 'Tag' => $tag ] );
1209  $logEntry->addTags( $logEntryTags );
1210 
1211  $logId = $logEntry->insert( $dbw );
1212  $logEntry->publish( $logId );
1213  return $logId;
1214  }
1215 
1225  public static function canActivateTag( $tag, Authority $performer = null ) {
1226  if ( $performer !== null ) {
1227  if ( !$performer->isAllowed( 'managechangetags' ) ) {
1228  return Status::newFatal( 'tags-manage-no-permission' );
1229  }
1230  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1231  return Status::newFatal(
1232  'tags-manage-blocked',
1233  $performer->getUser()->getName()
1234  );
1235  }
1236  }
1237 
1238  // defined tags cannot be activated (a defined tag is either extension-
1239  // defined, in which case the extension chooses whether or not to active it;
1240  // or user-defined, in which case it is considered active)
1241  $definedTags = self::listDefinedTags();
1242  if ( in_array( $tag, $definedTags ) ) {
1243  return Status::newFatal( 'tags-activate-not-allowed', $tag );
1244  }
1245 
1246  // non-existing tags cannot be activated
1247  $tagUsage = self::tagUsageStatistics();
1248  if ( !isset( $tagUsage[$tag] ) ) { // we already know the tag is undefined
1249  return Status::newFatal( 'tags-activate-not-found', $tag );
1250  }
1251 
1252  return Status::newGood();
1253  }
1254 
1272  public static function activateTagWithChecks( string $tag, string $reason, Authority $performer,
1273  bool $ignoreWarnings = false, array $logEntryTags = []
1274  ) {
1275  // are we allowed to do this?
1276  $result = self::canActivateTag( $tag, $performer );
1277  if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1278  $result->value = null;
1279  return $result;
1280  }
1281 
1282  // do it!
1283  self::defineTag( $tag );
1284 
1285  // log it
1286  $logId = self::logTagManagementAction( 'activate', $tag, $reason, $performer->getUser(),
1287  null, $logEntryTags );
1288 
1289  return Status::newGood( $logId );
1290  }
1291 
1301  public static function canDeactivateTag( $tag, Authority $performer = null ) {
1302  if ( $performer !== null ) {
1303  if ( !$performer->isAllowed( 'managechangetags' ) ) {
1304  return Status::newFatal( 'tags-manage-no-permission' );
1305  }
1306  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1307  return Status::newFatal(
1308  'tags-manage-blocked',
1309  $performer->getUser()->getName()
1310  );
1311  }
1312  }
1313 
1314  // only explicitly-defined tags can be deactivated
1315  $explicitlyDefinedTags = self::listExplicitlyDefinedTags();
1316  if ( !in_array( $tag, $explicitlyDefinedTags ) ) {
1317  return Status::newFatal( 'tags-deactivate-not-allowed', $tag );
1318  }
1319  return Status::newGood();
1320  }
1321 
1339  public static function deactivateTagWithChecks( string $tag, string $reason, Authority $performer,
1340  bool $ignoreWarnings = false, array $logEntryTags = []
1341  ) {
1342  // are we allowed to do this?
1343  $result = self::canDeactivateTag( $tag, $performer );
1344  if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1345  $result->value = null;
1346  return $result;
1347  }
1348 
1349  // do it!
1350  self::undefineTag( $tag );
1351 
1352  // log it
1353  $logId = self::logTagManagementAction( 'deactivate', $tag, $reason,
1354  $performer->getUser(), null, $logEntryTags );
1355 
1356  return Status::newGood( $logId );
1357  }
1358 
1366  public static function isTagNameValid( $tag ) {
1367  // no empty tags
1368  if ( $tag === '' ) {
1369  return Status::newFatal( 'tags-create-no-name' );
1370  }
1371 
1372  // tags cannot contain commas (used to be used as a delimiter in tag_summary table),
1373  // pipe (used as a delimiter between multiple tags in
1374  // SpecialRecentchanges and friends), or slashes (would break tag description messages in
1375  // MediaWiki namespace)
1376  if ( strpos( $tag, ',' ) !== false || strpos( $tag, '|' ) !== false
1377  || strpos( $tag, '/' ) !== false ) {
1378  return Status::newFatal( 'tags-create-invalid-chars' );
1379  }
1380 
1381  // could the MediaWiki namespace description messages be created?
1382  $title = Title::makeTitleSafe( NS_MEDIAWIKI, "Tag-$tag-description" );
1383  if ( $title === null ) {
1384  return Status::newFatal( 'tags-create-invalid-title-chars' );
1385  }
1386 
1387  return Status::newGood();
1388  }
1389 
1402  public static function canCreateTag( $tag, Authority $performer = null ) {
1403  $user = null;
1404  if ( $performer !== null ) {
1405  if ( !$performer->isAllowed( 'managechangetags' ) ) {
1406  return Status::newFatal( 'tags-manage-no-permission' );
1407  }
1408  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1409  return Status::newFatal(
1410  'tags-manage-blocked',
1411  $performer->getUser()->getName()
1412  );
1413  }
1414  // ChangeTagCanCreate hook still needs a full User object
1415  $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
1416  }
1417 
1418  $status = self::isTagNameValid( $tag );
1419  if ( !$status->isGood() ) {
1420  return $status;
1421  }
1422 
1423  // does the tag already exist?
1424  $tagUsage = self::tagUsageStatistics();
1425  if ( isset( $tagUsage[$tag] ) || in_array( $tag, self::listDefinedTags() ) ) {
1426  return Status::newFatal( 'tags-create-already-exists', $tag );
1427  }
1428 
1429  // check with hooks
1430  $canCreateResult = Status::newGood();
1431  Hooks::runner()->onChangeTagCanCreate( $tag, $user, $canCreateResult );
1432  return $canCreateResult;
1433  }
1434 
1454  public static function createTagWithChecks( string $tag, string $reason, Authority $performer,
1455  bool $ignoreWarnings = false, array $logEntryTags = []
1456  ) {
1457  // are we allowed to do this?
1458  $result = self::canCreateTag( $tag, $performer );
1459  if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1460  $result->value = null;
1461  return $result;
1462  }
1463 
1464  // do it!
1465  self::defineTag( $tag );
1466 
1467  // log it
1468  $logId = self::logTagManagementAction( 'create', $tag, $reason,
1469  $performer->getUser(), null, $logEntryTags );
1470 
1471  return Status::newGood( $logId );
1472  }
1473 
1486  public static function deleteTagEverywhere( $tag ) {
1487  $dbw = wfGetDB( DB_PRIMARY );
1488  $dbw->startAtomic( __METHOD__ );
1489 
1490  // fetch tag id, this must be done before calling undefineTag(), see T225564
1491  $tagId = MediaWikiServices::getInstance()->getChangeTagDefStore()->getId( $tag );
1492 
1493  // set ctd_user_defined = 0
1494  self::undefineTag( $tag );
1495 
1496  // delete from change_tag
1497  $dbw->delete( self::CHANGE_TAG, [ 'ct_tag_id' => $tagId ], __METHOD__ );
1498  $dbw->delete( self::CHANGE_TAG_DEF, [ 'ctd_name' => $tag ], __METHOD__ );
1499  $dbw->endAtomic( __METHOD__ );
1500 
1501  // give extensions a chance
1502  $status = Status::newGood();
1503  Hooks::runner()->onChangeTagAfterDelete( $tag, $status );
1504  // let's not allow error results, as the actual tag deletion succeeded
1505  if ( !$status->isOK() ) {
1506  wfDebug( 'ChangeTagAfterDelete error condition downgraded to warning' );
1507  $status->setOK( true );
1508  }
1509 
1510  // clear the memcache of defined tags
1512 
1513  return $status;
1514  }
1515 
1528  public static function canDeleteTag( $tag, Authority $performer = null, int $flags = 0 ) {
1529  $tagUsage = self::tagUsageStatistics();
1530  $user = null;
1531  if ( $performer !== null ) {
1532  if ( !$performer->isAllowed( 'deletechangetags' ) ) {
1533  return Status::newFatal( 'tags-delete-no-permission' );
1534  }
1535  if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1536  return Status::newFatal(
1537  'tags-manage-blocked',
1538  $performer->getUser()->getName()
1539  );
1540  }
1541  // ChangeTagCanDelete hook still needs a full User object
1542  $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
1543  }
1544 
1545  if ( !isset( $tagUsage[$tag] ) && !in_array( $tag, self::listDefinedTags() ) ) {
1546  return Status::newFatal( 'tags-delete-not-found', $tag );
1547  }
1548 
1549  if ( $flags !== self::BYPASS_MAX_USAGE_CHECK &&
1550  isset( $tagUsage[$tag] ) &&
1551  $tagUsage[$tag] > self::MAX_DELETE_USES
1552  ) {
1553  return Status::newFatal( 'tags-delete-too-many-uses', $tag, self::MAX_DELETE_USES );
1554  }
1555 
1556  $softwareDefined = self::listSoftwareDefinedTags();
1557  if ( in_array( $tag, $softwareDefined ) ) {
1558  // extension-defined tags can't be deleted unless the extension
1559  // specifically allows it
1560  $status = Status::newFatal( 'tags-delete-not-allowed' );
1561  } else {
1562  // user-defined tags are deletable unless otherwise specified
1563  $status = Status::newGood();
1564  }
1565 
1566  Hooks::runner()->onChangeTagCanDelete( $tag, $user, $status );
1567  return $status;
1568  }
1569 
1587  public static function deleteTagWithChecks( string $tag, string $reason, Authority $performer,
1588  bool $ignoreWarnings = false, array $logEntryTags = []
1589  ) {
1590  // are we allowed to do this?
1591  $result = self::canDeleteTag( $tag, $performer );
1592  if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1593  $result->value = null;
1594  return $result;
1595  }
1596 
1597  // store the tag usage statistics
1598  $tagUsage = self::tagUsageStatistics();
1599  $hitcount = $tagUsage[$tag] ?? 0;
1600 
1601  // do it!
1602  $deleteResult = self::deleteTagEverywhere( $tag );
1603  if ( !$deleteResult->isOK() ) {
1604  return $deleteResult;
1605  }
1606 
1607  // log it
1608  $logId = self::logTagManagementAction( 'delete', $tag, $reason, $performer->getUser(),
1609  $hitcount, $logEntryTags );
1610 
1611  $deleteResult->value = $logId;
1612  return $deleteResult;
1613  }
1614 
1621  public static function listSoftwareActivatedTags() {
1622  // core active tags
1623  $tags = self::getSoftwareTags();
1624  $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1625  if ( !$hookContainer->isRegistered( 'ChangeTagsListActive' ) ) {
1626  return $tags;
1627  }
1628  $hookRunner = new HookRunner( $hookContainer );
1629  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1630  return $cache->getWithSetCallback(
1631  $cache->makeKey( 'active-tags' ),
1632  WANObjectCache::TTL_MINUTE * 5,
1633  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags, $hookRunner ) {
1634  $setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
1635 
1636  // Ask extensions which tags they consider active
1637  $hookRunner->onChangeTagsListActive( $tags );
1638  return $tags;
1639  },
1640  [
1641  'checkKeys' => [ $cache->makeKey( 'active-tags' ) ],
1642  'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1643  'pcTTL' => WANObjectCache::TTL_PROC_LONG
1644  ]
1645  );
1646  }
1647 
1655  public static function listDefinedTags() {
1657  $tags2 = self::listSoftwareDefinedTags();
1658  return array_values( array_unique( array_merge( $tags1, $tags2 ) ) );
1659  }
1660 
1669  public static function listExplicitlyDefinedTags() {
1670  $fname = __METHOD__;
1671 
1672  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1673  return $cache->getWithSetCallback(
1674  $cache->makeKey( 'valid-tags-db' ),
1675  WANObjectCache::TTL_MINUTE * 5,
1676  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname ) {
1677  $dbr = wfGetDB( DB_REPLICA );
1678 
1679  $setOpts += Database::getCacheSetOptions( $dbr );
1680  $tags = $dbr->newSelectQueryBuilder()
1681  ->select( 'ctd_name' )
1682  ->from( self::CHANGE_TAG_DEF )
1683  ->where( [ 'ctd_user_defined' => 1 ] )
1684  ->caller( $fname )
1685  ->fetchFieldValues();
1686 
1687  return array_unique( $tags );
1688  },
1689  [
1690  'checkKeys' => [ $cache->makeKey( 'valid-tags-db' ) ],
1691  'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1692  'pcTTL' => WANObjectCache::TTL_PROC_LONG
1693  ]
1694  );
1695  }
1696 
1706  public static function listSoftwareDefinedTags() {
1707  // core defined tags
1708  $tags = self::getSoftwareTags( true );
1709  $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1710  if ( !$hookContainer->isRegistered( 'ListDefinedTags' ) ) {
1711  return $tags;
1712  }
1713  $hookRunner = new HookRunner( $hookContainer );
1714  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1715  return $cache->getWithSetCallback(
1716  $cache->makeKey( 'valid-tags-hook' ),
1717  WANObjectCache::TTL_MINUTE * 5,
1718  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags, $hookRunner ) {
1719  $setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
1720 
1721  $hookRunner->onListDefinedTags( $tags );
1722  return array_unique( $tags );
1723  },
1724  [
1725  'checkKeys' => [ $cache->makeKey( 'valid-tags-hook' ) ],
1726  'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1727  'pcTTL' => WANObjectCache::TTL_PROC_LONG
1728  ]
1729  );
1730  }
1731 
1737  public static function purgeTagCacheAll() {
1738  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1739 
1740  $cache->touchCheckKey( $cache->makeKey( 'active-tags' ) );
1741  $cache->touchCheckKey( $cache->makeKey( 'valid-tags-db' ) );
1742  $cache->touchCheckKey( $cache->makeKey( 'valid-tags-hook' ) );
1743  $cache->touchCheckKey( $cache->makeKey( 'tags-usage-statistics' ) );
1744 
1745  MediaWikiServices::getInstance()->getChangeTagDefStore()->reloadMap();
1746  }
1747 
1754  public static function tagUsageStatistics() {
1755  $fname = __METHOD__;
1756 
1757  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1758  return $cache->getWithSetCallback(
1759  $cache->makeKey( 'tags-usage-statistics' ),
1760  WANObjectCache::TTL_MINUTE * 5,
1761  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname ) {
1762  $dbr = wfGetDB( DB_REPLICA );
1763  $res = $dbr->newSelectQueryBuilder()
1764  ->select( [ 'ctd_name', 'ctd_count' ] )
1765  ->from( self::CHANGE_TAG_DEF )
1766  ->orderBy( 'ctd_count', SelectQueryBuilder::SORT_DESC )
1767  ->caller( $fname )
1768  ->fetchResultSet();
1769 
1770  $out = [];
1771  foreach ( $res as $row ) {
1772  $out[$row->ctd_name] = $row->ctd_count;
1773  }
1774 
1775  return $out;
1776  },
1777  [
1778  'checkKeys' => [ $cache->makeKey( 'tags-usage-statistics' ) ],
1779  'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1780  'pcTTL' => WANObjectCache::TTL_PROC_LONG
1781  ]
1782  );
1783  }
1784 
1789  private const TAG_DESC_CHARACTER_LIMIT = 120;
1790 
1815  public static function getChangeTagListSummary( MessageLocalizer $localizer, Language $lang ) {
1816  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1817  return $cache->getWithSetCallback(
1818  $cache->makeKey( 'tags-list-summary', $lang->getCode() ),
1819  WANObjectCache::TTL_DAY,
1820  static function ( $oldValue, &$ttl, array &$setOpts ) use ( $localizer ) {
1821  $tagHitCounts = self::tagUsageStatistics();
1822 
1823  $result = [];
1824  // Only list tags that are still actively defined
1825  foreach ( self::listDefinedTags() as $tagName ) {
1826  // Only list tags with more than 0 hits
1827  $hits = $tagHitCounts[$tagName] ?? 0;
1828  if ( $hits <= 0 ) {
1829  continue;
1830  }
1831 
1832  $labelMsg = self::tagShortDescriptionMessage( $tagName, $localizer );
1833  $descriptionMsg = self::tagLongDescriptionMessage( $tagName, $localizer );
1834  // Don't cache the message object, use the correct MessageLocalizer to parse later.
1835  $result[] = [
1836  'name' => $tagName,
1837  'labelMsg' => (bool)$labelMsg,
1838  'label' => $labelMsg ? $labelMsg->plain() : $tagName,
1839  'descriptionMsg' => (bool)$descriptionMsg,
1840  'description' => $descriptionMsg ? $descriptionMsg->plain() : '',
1841  'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
1842  ];
1843  }
1844  return $result;
1845  }
1846  );
1847  }
1848 
1861  public static function getChangeTagList( MessageLocalizer $localizer, Language $lang ) {
1862  $tags = self::getChangeTagListSummary( $localizer, $lang );
1863  foreach ( $tags as &$tagInfo ) {
1864  if ( $tagInfo['labelMsg'] ) {
1865  // Use localizer with the correct page title to parse plain message from the cache.
1866  $labelMsg = new RawMessage( $tagInfo['label'] );
1867  $tagInfo['label'] = Sanitizer::stripAllTags( $localizer->msg( $labelMsg )->parse() );
1868  } else {
1869  $tagInfo['label'] = $localizer->msg( 'tag-hidden', $tagInfo['name'] )->text();
1870  }
1871  if ( $tagInfo['descriptionMsg'] ) {
1872  $descriptionMsg = new RawMessage( $tagInfo['description'] );
1873  $tagInfo['description'] = $lang->truncateForVisual(
1874  Sanitizer::stripAllTags( $localizer->msg( $descriptionMsg )->parse() ),
1875  self::TAG_DESC_CHARACTER_LIMIT
1876  );
1877  }
1878  unset( $tagInfo['labelMsg'] );
1879  unset( $tagInfo['descriptionMsg'] );
1880  }
1881 
1882  // Instead of sorting by hit count (disabled for now), sort by display name
1883  usort( $tags, static function ( $a, $b ) {
1884  return strcasecmp( $a['label'], $b['label'] );
1885  } );
1886  return $tags;
1887  }
1888 
1903  public static function showTagEditingUI( Authority $performer ) {
1904  return $performer->isAllowed( 'changetags' ) && (bool)self::listExplicitlyDefinedTags();
1905  }
1906 }
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:81
const TAG_SERVER_SIDE_UPLOAD
This tagged edit was performed while importing media files using the importImages....
Definition: ChangeTags.php:93
static modifyDisplayQuery(&$tables, &$fields, &$conds, &$join_conds, &$options, $filter_tag='', bool $exclude=false)
Applies all tags-related changes to a query.
Definition: ChangeTags.php:902
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:48
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:60
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:39
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:303
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:160
const TAG_CHANGED_REDIRECT_TARGET
The tagged edit changes the target of a redirect page.
Definition: ChangeTags.php:52
const TAG_REVERTED
The tagged edit is reverted by a subsequent edit (which is tagged by one of TAG_ROLLBACK,...
Definition: ChangeTags.php:89
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:68
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:103
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?
const DISPLAY_TABLE_ALIAS
Definition: ChangeTags.php:139
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:330
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:989
static formatSummaryRow( $tags, $page, MessageLocalizer $localizer=null)
Creates HTML for the given tags.
Definition: ChangeTags.php:196
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:256
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:286
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:75
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:56
static isTagNameValid( $tag)
Is the tag name valid?
const REVERT_TAGS
List of tags which denote a revert of some sort.
Definition: ChangeTags.php:98
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:44
static bool $avoidReopeningTablesForTesting
If true, this class attempts to avoid reopening database tables within the same query,...
Definition: ChangeTags.php:151
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:366
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:55
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:560
Variant of the Message class.
Definition: RawMessage.php:40
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
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:1107
static stripAllTags( $html)
Take a fragment of (potentially invalid) HTML and return a version with any tags removed,...
Definition: Sanitizer.php:1725
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:373
static makeTitleSafe( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:667
A query builder for SELECT queries with a fluent interface.
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:597
static input( $name, $size=false, $value=false, $attribs=[])
Convenience function to build an HTML text input field.
Definition: Xml.php:280
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:40
newSelectQueryBuilder()
Create an empty SelectQueryBuilder which can be used to run queries against this connection.
const DB_REPLICA
Definition: defines.php:26
const DB_PRIMARY
Definition: defines.php:28
if(!isset( $args[0])) $lang