jenkins-bot has submitted this change and it was merged.

Change subject: Creation, deletion and improved management of change tags
......................................................................


Creation, deletion and improved management of change tags

This allows users with the `managechangetags` right to create tags for
use by wiki users. (Currently there is no way for editors to apply tags
to their edits; that's to come in a later patch.)

Extensions can reserve tag names for their own use, even if they do not
define them or mark them as active.

Tag managers can also delete tags with <= 5000 uses. Currently, if a tag is
misspelt ("vandlaism") or no longer wanted (testing of OAuth, etc), the
wiki is stuck with it forever. This change allows users with the
"managechangetags" right to delete change tags from the database,
including removing them from all revisions to which they are applied.

Obviously this is a powerful thing to be able to do, but I view change
tags as a "light" kind of interface, useful for revision patrolling and
spam/vandalism fighting but not something that necessarily needs to hang
around forever. It's not a big deal for this kind of data to be thrown
away without being archived anywhere.

Tags defined by an extension can only be deleted if the extension allows
it.

Changes to tags are logged in the new "tag management" log. There's even
a nice API module, just for fun.

Bug: T20670
Change-Id: I77f476c8d0f32c80f720aa2c5e66869c81faa282
---
M RELEASE-NOTES-1.25
M autoload.php
M docs/hooks.txt
M includes/ChangeTags.php
M includes/DefaultSettings.php
M includes/User.php
M includes/api/ApiMain.php
A includes/api/ApiManageTags.php
M includes/api/ApiQueryTags.php
M includes/api/i18n/en.json
M includes/api/i18n/qqq.json
M includes/specials/SpecialTags.php
M languages/i18n/en.json
M languages/i18n/qqq.json
14 files changed, 1,165 insertions(+), 18 deletions(-)

Approvals:
  Anomie: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/RELEASE-NOTES-1.25 b/RELEASE-NOTES-1.25
index d2edcb3..22e5ac3 100644
--- a/RELEASE-NOTES-1.25
+++ b/RELEASE-NOTES-1.25
@@ -80,6 +80,14 @@
   they will add the proper credits to the skins or extensions section.
 * Update QUnit from v1.14.0 to v1.16.0.
 * Update Moment.js from v2.8.3 to v2.8.4.
+* Special:Tags now allows for manipulating the list of user-modifiable change
+  tags. Actually modifying the tagging of a revision or log entry is not
+  implemented yet.
+* Added 'managetags' user right and 'ChangeTagCanCreate', 'ChangeTagCanDelete',
+  and 'ChangeTagCanCreate' hooks to allow for managing user-modifiable change
+  tags.
+* Added 'ChangeTagsListActive' hook, to separate the concepts of "defined" and
+  "active" formerly conflated by the 'ListDefinedTags' hook.
 
 ==== External libraries ====
 * MediaWiki now requires certain external libraries to be installed. In the 
past
@@ -186,6 +194,11 @@
   interwiki redirects to the list of interwiki titles.
 * (T85417) When outputting the list of redirect titles, a 'tointerwiki'
   property (like the existing 'tofragment' property) will be set.
+* Added action=managetags to allow for managing the list of
+  user-modifiable change tags. Actually modifying the tagging of a revision or
+  log entry is not implemented yet.
+* list=tags has additional properties to indicate 'active' status and tag
+  sources.
 
 === Action API internal changes in 1.25 ===
 * ApiHelp has been rewritten to support i18n and paginated HTML output.
diff --git a/autoload.php b/autoload.php
index 90cd074..948a6aa 100644
--- a/autoload.php
+++ b/autoload.php
@@ -51,6 +51,7 @@
        'ApiLogin' => __DIR__ . '/includes/api/ApiLogin.php',
        'ApiLogout' => __DIR__ . '/includes/api/ApiLogout.php',
        'ApiMain' => __DIR__ . '/includes/api/ApiMain.php',
+       'ApiManageTags' => __DIR__ . '/includes/api/ApiManageTags.php',
        'ApiModuleManager' => __DIR__ . '/includes/api/ApiModuleManager.php',
        'ApiMove' => __DIR__ . '/includes/api/ApiMove.php',
        'ApiOpenSearch' => __DIR__ . '/includes/api/ApiOpenSearch.php',
diff --git a/docs/hooks.txt b/docs/hooks.txt
index 4717c38..dba6281 100644
--- a/docs/hooks.txt
+++ b/docs/hooks.txt
@@ -903,6 +903,38 @@
 &$join_conds: join conditions for the tables
 $opts: FormOptions for this request
 
+'ChangeTagAfterDelete': Called after a change tag has been deleted (that is,
+removed from all revisions and log entries to which it was applied). This gives
+extensions a chance to take it off their books.
+$tag: name of the tag
+&$status: Status object. Add warnings to this as required. There is no point
+  setting errors, as the deletion has already been partly carried out by this
+  point.
+
+'ChangeTagCanCreate': Tell whether a change tag should be able to be created
+from the UI (Special:Tags) or via the API. You could use this hook if you want
+to reserve a specific "namespace" of tags, or something similar.
+$tag: name of the tag
+$user: user initiating the action
+&$status: Status object. Add your errors using `$status->fatal()` or warnings
+  using `$status->warning()`. Errors and warnings will be relayed to the user.
+  If you set an error, the user will be unable to create the tag.
+
+'ChangeTagCanDelete': Tell whether a change tag should be able to be
+deleted from the UI (Special:Tags) or via the API. The default is that tags
+defined using the ListDefinedTags hook are not allowed to be deleted unless
+specifically allowed. If you wish to allow deletion of the tag, set
+`$status = Status::newGood()` to allow deletion, and then `return false` from
+the hook function. Ensure you consume the 'ChangeTagAfterDelete' hook to carry
+out custom deletion actions.
+$tag: name of the tag
+$user: user initiating the action
+&$status: Status object. See above.
+
+'ChangeTagsListActive': Allows you to nominate which of the tags your extension
+uses are in active use.
+&$tags: list of all active tags. Append to this array.
+
 'LoginUserMigrated': Called during login to allow extensions the opportunity to
 inform a user that their username doesn't exist for a specific reason, instead
 of letting the login form give the generic error message that the account does
diff --git a/includes/ChangeTags.php b/includes/ChangeTags.php
index 9ee2460..d597d6d 100644
--- a/includes/ChangeTags.php
+++ b/includes/ChangeTags.php
@@ -22,6 +22,13 @@
 
 class ChangeTags {
        /**
+        * Can't delete tags with more than this many uses. Similar in intent to
+        * the bigdelete user right
+        * @todo Use the job queue for tag deletion to avoid this restriction
+        */
+       const MAX_DELETE_USES = 5000;
+
+       /**
         * Creates HTML for the given tags
         *
         * @param string $tags Comma-separated list of tags
@@ -185,6 +192,7 @@
 
                $dbw->insert( 'change_tag', $tagsRows, __METHOD__, array( 
'IGNORE' ) );
 
+               self::purgeTagUsageCache();
                return true;
        }
 
@@ -294,18 +302,478 @@
        }
 
        /**
-        * Basically lists defined tags which count even if they aren't applied 
to anything.
-        * Tags on items in table 'change_tag' which are not (or no longer) in 
table 'valid_tag'
-        * are not included.
+        * Defines a tag in the valid_tag table, without checking that the tag 
name
+        * is valid.
+        * Extensions should NOT use this function; they can use the 
ListDefinedTags
+        * hook instead.
         *
-        * Tries memcached first.
+        * @param string $tag Tag to create
+        * @since 1.25
+        */
+       public static function defineTag( $tag ) {
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->replace( 'valid_tag',
+                       array( 'vt_tag' ),
+                       array( 'vt_tag' => $tag ),
+                       __METHOD__ );
+
+               // clear the memcache of defined tags
+               self::purgeTagCacheAll();
+       }
+
+       /**
+        * Removes a tag from the valid_tag table. The tag may remain in use by
+        * extensions, and may still show up as 'defined' if an extension is 
setting
+        * it from the ListDefinedTags hook.
+        *
+        * @param string $tag Tag to remove
+        * @since 1.25
+        */
+       public static function undefineTag( $tag ) {
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->delete( 'valid_tag', array( 'vt_tag' => $tag ), 
__METHOD__ );
+
+               // clear the memcache of defined tags
+               self::purgeTagCacheAll();
+       }
+
+       /**
+        * Writes a tag action into the tag management log.
+        *
+        * @param string $action
+        * @param string $tag
+        * @param string $reason
+        * @param User $user Who to attribute the action to
+        * @param int $tagCount For deletion only, how many usages the tag had 
before
+        * it was deleted.
+        * @since 1.25
+        */
+       protected static function logTagAction( $action, $tag, $reason, User 
$user,
+               $tagCount = null ) {
+
+               $dbw = wfGetDB( DB_MASTER );
+
+               $logEntry = new ManualLogEntry( 'managetags', $action );
+               $logEntry->setPerformer( $user );
+               // target page is not relevant, but it has to be set, so we 
just put in
+               // the title of Special:Tags
+               $logEntry->setTarget( Title::newFromText( 'Special:Tags' ) );
+               $logEntry->setComment( $reason );
+
+               $params = array( '4::tag' => $tag );
+               if ( !is_null( $tagCount ) ) {
+                       $params['5:number:count'] = $tagCount;
+               }
+               $logEntry->setParameters( $params );
+               $logEntry->setRelations( array( 'Tag' => $tag ) );
+
+               $logId = $logEntry->insert( $dbw );
+               $logEntry->publish( $logId );
+               return $logId;
+       }
+
+       /**
+        * Is it OK to allow the user to activate this tag?
+        *
+        * @param string $tag Tag that you are interested in activating
+        * @param User|null $user User whose permission you wish to check, or 
null if
+        * you don't care (e.g. maintenance scripts)
+        * @return Status
+        * @since 1.25
+        */
+       public static function canActivateTag( $tag, User $user = null ) {
+               if ( !is_null( $user ) && !$user->isAllowed( 'managechangetags' 
) ) {
+                       return Status::newFatal( 'tags-manage-no-permission' );
+               }
+
+               // non-existing tags cannot be activated
+               $tagUsage = self::tagUsageStatistics();
+               if ( !isset( $tagUsage[$tag] ) ) {
+                       return Status::newFatal( 'tags-activate-not-found', 
$tag );
+               }
+
+               // defined tags cannot be activated (a defined tag is either 
extension-
+               // defined, in which case the extension chooses whether or not 
to active it;
+               // or user-defined, in which case it is considered active)
+               $definedTags = self::listDefinedTags();
+               if ( in_array( $tag, $definedTags ) ) {
+                       return Status::newFatal( 'tags-activate-not-allowed', 
$tag );
+               }
+
+               return Status::newGood();
+       }
+
+       /**
+        * Activates a tag, checking whether it is allowed first, and adding a 
log
+        * entry afterwards.
+        *
+        * Includes a call to ChangeTag::canActivateTag(), so your code doesn't 
need
+        * to do that.
+        *
+        * @param string $tag
+        * @param string $reason
+        * @param User $user Who to give credit for the action
+        * @param bool $ignoreWarnings Can be used for API interaction, default 
false
+        * @return Status If successful, the Status contains the ID of the 
added log
+        * entry as its value
+        * @since 1.25
+        */
+       public static function activateTagWithChecks( $tag, $reason, User $user,
+               $ignoreWarnings = false ) {
+
+               // are we allowed to do this?
+               $result = self::canActivateTag( $tag, $user );
+               if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
+                       $result->value = null;
+                       return $result;
+               }
+
+               // do it!
+               self::defineTag( $tag );
+
+               // log it
+               $logId = self::logTagAction( 'activate', $tag, $reason, $user );
+               return Status::newGood( $logId );
+       }
+
+       /**
+        * Is it OK to allow the user to deactivate this tag?
+        *
+        * @param string $tag Tag that you are interested in deactivating
+        * @param User|null $user User whose permission you wish to check, or 
null if
+        * you don't care (e.g. maintenance scripts)
+        * @return Status
+        * @since 1.25
+        */
+       public static function canDeactivateTag( $tag, User $user = null ) {
+               if ( !is_null( $user ) && !$user->isAllowed( 'managechangetags' 
) ) {
+                       return Status::newFatal( 'tags-manage-no-permission' );
+               }
+
+               // only explicitly-defined tags can be deactivated
+               $explicitlyDefinedTags = self::listExplicitlyDefinedTags();
+               if ( !in_array( $tag, $explicitlyDefinedTags ) ) {
+                       return Status::newFatal( 'tags-deactivate-not-allowed', 
$tag );
+               }
+               return Status::newGood();
+       }
+
+       /**
+        * Deactivates a tag, checking whether it is allowed first, and adding 
a log
+        * entry afterwards.
+        *
+        * Includes a call to ChangeTag::canDeactivateTag(), so your code 
doesn't need
+        * to do that.
+        *
+        * @param string $tag
+        * @param string $reason
+        * @param User $user Who to give credit for the action
+        * @param bool $ignoreWarnings Can be used for API interaction, default 
false
+        * @return Status If successful, the Status contains the ID of the 
added log
+        * entry as its value
+        * @since 1.25
+        */
+       public static function deactivateTagWithChecks( $tag, $reason, User 
$user,
+               $ignoreWarnings = false ) {
+
+               // are we allowed to do this?
+               $result = self::canDeactivateTag( $tag, $user );
+               if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
+                       $result->value = null;
+                       return $result;
+               }
+
+               // do it!
+               self::undefineTag( $tag );
+
+               // log it
+               $logId = self::logTagAction( 'deactivate', $tag, $reason, $user 
);
+               return Status::newGood( $logId );
+       }
+
+       /**
+        * Is it OK to allow the user to create this tag?
+        *
+        * @param string $tag Tag that you are interested in creating
+        * @param User|null $user User whose permission you wish to check, or 
null if
+        * you don't care (e.g. maintenance scripts)
+        * @return Status
+        * @since 1.25
+        */
+       public static function canCreateTag( $tag, User $user = null ) {
+               if ( !is_null( $user ) && !$user->isAllowed( 'managechangetags' 
) ) {
+                       return Status::newFatal( 'tags-manage-no-permission' );
+               }
+
+               // no empty tags
+               if ( $tag === '' ) {
+                       return Status::newFatal( 'tags-create-no-name' );
+               }
+
+               // tags cannot contain commas (used as a delimiter in 
tag_summary table) or
+               // slashes (would break tag description messages in MediaWiki 
namespace)
+               if ( strpos( $tag, ',' ) !== false || strpos( $tag, '/' ) !== 
false ) {
+                       return Status::newFatal( 'tags-create-invalid-chars' );
+               }
+
+               // could the MediaWiki namespace description messages be 
created?
+               $title = Title::makeTitleSafe( NS_MEDIAWIKI, 
"Tag-$tag-description" );
+               if ( is_null( $title ) ) {
+                       return Status::newFatal( 
'tags-create-invalid-title-chars' );
+               }
+
+               // does the tag already exist?
+               $tagUsage = self::tagUsageStatistics();
+               if ( isset( $tagUsage[$tag] ) ) {
+                       return Status::newFatal( 'tags-create-already-exists', 
$tag );
+               }
+
+               // check with hooks
+               $canCreateResult = Status::newGood();
+               Hooks::run( 'ChangeTagCanCreate', array( $tag, $user, 
&$canCreateResult ) );
+               return $canCreateResult;
+       }
+
+       /**
+        * Creates a tag by adding a row to the `valid_tag` table.
+        *
+        * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't 
need to
+        * do that.
+        *
+        * @param string $tag
+        * @param string $reason
+        * @param User $user Who to give credit for the action
+        * @param bool $ignoreWarnings Can be used for API interaction, default 
false
+        * @return Status If successful, the Status contains the ID of the 
added log
+        * entry as its value
+        * @since 1.25
+        */
+       public static function createTagWithChecks( $tag, $reason, User $user,
+               $ignoreWarnings = false ) {
+
+               // are we allowed to do this?
+               $result = self::canCreateTag( $tag, $user );
+               if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
+                       $result->value = null;
+                       return $result;
+               }
+
+               // do it!
+               self::defineTag( $tag );
+
+               // log it
+               $logId = self::logTagAction( 'create', $tag, $reason, $user );
+               return Status::newGood( $logId );
+       }
+
+       /**
+        * Permanently removes all traces of a tag from the DB. Good for 
removing
+        * misspelt or temporary tags.
+        *
+        * This function should be directly called by maintenance scripts only, 
never
+        * by user-facing code. See deleteTagWithChecks() for functionality 
that can
+        * safely be exposed to users.
+        *
+        * @param string $tag Tag to remove
+        * @return Status The returned status will be good unless a hook 
changed it
+        * @since 1.25
+        */
+       public static function deleteTagEverywhere( $tag ) {
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->begin( __METHOD__ );
+
+               // delete from valid_tag
+               self::undefineTag( $tag );
+
+               // find out which revisions use this tag, so we can delete from 
tag_summary
+               $result = $dbw->select( 'change_tag',
+                       array( 'ct_rc_id', 'ct_log_id', 'ct_rev_id', 'ct_tag' ),
+                       array( 'ct_tag' => $tag ),
+                       __METHOD__ );
+               foreach ( $result as $row ) {
+                       if ( $row->ct_rev_id ) {
+                               $field = 'ts_rev_id';
+                               $fieldValue = $row->ct_rev_id;
+                       } elseif ( $row->ct_log_id ) {
+                               $field = 'ts_log_id';
+                               $fieldValue = $row->ct_log_id;
+                       } elseif ( $row->ct_rc_id ) {
+                               $field = 'ts_rc_id';
+                               $fieldValue = $row->ct_rc_id;
+                       } else {
+                               // don't know what's up; just skip it
+                               continue;
+                       }
+
+                       // remove the tag from the relevant row of tag_summary
+                       $tsResult = $dbw->selectField( 'tag_summary',
+                               'ts_tags',
+                               array( $field => $fieldValue ),
+                               __METHOD__ );
+                       $tsValues = explode( ',', $tsResult );
+                       $tsValues = array_values( array_diff( $tsValues, array( 
$tag ) ) );
+                       if ( !$tsValues ) {
+                               // no tags left, so delete the row altogether
+                               $dbw->delete( 'tag_summary',
+                                       array( $field => $fieldValue ),
+                                       __METHOD__ );
+                       } else {
+                               $dbw->update( 'tag_summary',
+                                       array( 'ts_tags' => implode( ',', 
$tsValues ) ),
+                                       array( $field => $fieldValue ),
+                                       __METHOD__ );
+                       }
+               }
+
+               // delete from change_tag
+               $dbw->delete( 'change_tag', array( 'ct_tag' => $tag ), 
__METHOD__ );
+
+               $dbw->commit( __METHOD__ );
+
+               // give extensions a chance
+               $status = Status::newGood();
+               Hooks::run( 'ChangeTagAfterDelete', array( $tag, &$status ) );
+               // let's not allow error results, as the actual tag deletion 
succeeded
+               if ( !$status->isOK() ) {
+                       wfDebug( 'ChangeTagAfterDelete error condition 
downgraded to warning' );
+                       $status->ok = true;
+               }
+
+               // clear the memcache of defined tags
+               self::purgeTagCacheAll();
+
+               return $status;
+       }
+
+       /**
+        * Is it OK to allow the user to delete this tag?
+        *
+        * @param string $tag Tag that you are interested in deleting
+        * @param User|null $user User whose permission you wish to check, or 
null if
+        * you don't care (e.g. maintenance scripts)
+        * @return Status
+        * @since 1.25
+        */
+       public static function canDeleteTag( $tag, User $user = null ) {
+               $tagUsage = self::tagUsageStatistics();
+
+               if ( !is_null( $user ) && !$user->isAllowed( 'managechangetags' 
) ) {
+                       return Status::newFatal( 'tags-manage-no-permission' );
+               }
+
+               if ( !isset( $tagUsage[$tag] ) ) {
+                       return Status::newFatal( 'tags-delete-not-found', $tag 
);
+               }
+
+               if ( $tagUsage[$tag] > self::MAX_DELETE_USES ) {
+                       return Status::newFatal( 'tags-delete-too-many-uses', 
$tag, self::MAX_DELETE_USES );
+               }
+
+               $extensionDefined = self::listExtensionDefinedTags();
+               if ( in_array( $tag, $extensionDefined ) ) {
+                       // extension-defined tags can't be deleted unless the 
extension
+                       // specifically allows it
+                       $status = Status::newFatal( 'tags-delete-not-allowed' );
+               } else {
+                       // user-defined tags are deletable unless otherwise 
specified
+                       $status = Status::newGood();
+               }
+
+               Hooks::run( 'ChangeTagCanDelete', array( $tag, $user, &$status 
) );
+               return $status;
+       }
+
+       /**
+        * Deletes a tag, checking whether it is allowed first, and adding a 
log entry
+        * afterwards.
+        *
+        * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't 
need to
+        * do that.
+        *
+        * @param string $tag
+        * @param string $reason
+        * @param User $user Who to give credit for the action
+        * @param bool $ignoreWarnings Can be used for API interaction, default 
false
+        * @return Status If successful, the Status contains the ID of the 
added log
+        * entry as its value
+        * @since 1.25
+        */
+       public static function deleteTagWithChecks( $tag, $reason, User $user,
+               $ignoreWarnings = false ) {
+
+               // are we allowed to do this?
+               $result = self::canDeleteTag( $tag, $user );
+               if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
+                       $result->value = null;
+                       return $result;
+               }
+
+               // store the tag usage statistics
+               $tagUsage = self::tagUsageStatistics();
+
+               // do it!
+               $deleteResult = self::deleteTagEverywhere( $tag );
+               if ( !$deleteResult->isOK() ) {
+                       return $deleteResult;
+               }
+
+               // log it
+               $logId = self::logTagAction( 'delete', $tag, $reason, $user, 
$tagUsage[$tag] );
+               $deleteResult->value = $logId;
+               return $deleteResult;
+       }
+
+       /**
+        * Lists those tags which extensions report as being "active".
+        *
+        * @return array
+        * @since 1.25
+        */
+       public static function listExtensionActivatedTags() {
+               // Caching...
+               global $wgMemc;
+               $key = wfMemcKey( 'active-tags' );
+               $tags = $wgMemc->get( $key );
+               if ( $tags ) {
+                       return $tags;
+               }
+
+               // ask extensions which tags they consider active
+               $extensionActive = array();
+               Hooks::run( 'ChangeTagsListActive', array( &$extensionActive ) 
);
+
+               // Short-term caching.
+               $wgMemc->set( $key, $extensionActive, 300 );
+               return $extensionActive;
+       }
+
+       /**
+        * Basically lists defined tags which count even if they aren't applied 
to anything.
+        * It returns a union of the results of listExplicitlyDefinedTags() and
+        * listExtensionDefinedTags().
         *
         * @return string[] Array of strings: tags
         */
        public static function listDefinedTags() {
+               $tags1 = self::listExplicitlyDefinedTags();
+               $tags2 = self::listExtensionDefinedTags();
+               return array_values( array_unique( array_merge( $tags1, $tags2 
) ) );
+       }
+
+       /**
+        * Lists tags explicitly defined in the `valid_tag` table of the 
database.
+        * Tags in table 'change_tag' which are not in table 'valid_tag' are not
+        * included.
+        *
+        * Tries memcached first.
+        *
+        * @return string[] Array of strings: tags
+        * @since 1.25
+        */
+       public static function listExplicitlyDefinedTags() {
                // Caching...
                global $wgMemc;
-               $key = wfMemcKey( 'valid-tags' );
+               $key = wfMemcKey( 'valid-tags-db' );
                $tags = $wgMemc->get( $key );
                if ( $tags ) {
                        return $tags;
@@ -320,8 +788,6 @@
                        $emptyTags[] = $row->vt_tag;
                }
 
-               Hooks::run( 'ListDefinedTags', array( &$emptyTags ) );
-
                $emptyTags = array_filter( array_unique( $emptyTags ) );
 
                // Short-term caching.
@@ -330,12 +796,72 @@
        }
 
        /**
+        * Lists tags defined by extensions using the ListDefinedTags hook.
+        * Extensions need only define those tags they deem to be in active use.
+        *
+        * Tries memcached first.
+        *
+        * @return string[] Array of strings: tags
+        * @since 1.25
+        */
+       public static function listExtensionDefinedTags() {
+               // Caching...
+               global $wgMemc;
+               $key = wfMemcKey( 'valid-tags-hook' );
+               $tags = $wgMemc->get( $key );
+               if ( $tags ) {
+                       return $tags;
+               }
+
+               $emptyTags = array();
+               Hooks::run( 'ListDefinedTags', array( &$emptyTags ) );
+               $emptyTags = array_filter( array_unique( $emptyTags ) );
+
+               // Short-term caching.
+               $wgMemc->set( $key, $emptyTags, 300 );
+               return $emptyTags;
+       }
+
+       /**
+        * Invalidates the short-term cache of defined tags used by the
+        * list*DefinedTags functions, as well as the tag statistics cache.
+        * @since 1.25
+        */
+       public static function purgeTagCacheAll() {
+               global $wgMemc;
+               $wgMemc->delete( wfMemcKey( 'active-tags' ) );
+               $wgMemc->delete( wfMemcKey( 'valid-tags-db' ) );
+               $wgMemc->delete( wfMemcKey( 'valid-tags-hook' ) );
+               self::purgeTagUsageCache();
+       }
+
+       /**
+        * Invalidates the tag statistics cache only.
+        * @since 1.25
+        */
+       public static function purgeTagUsageCache() {
+               global $wgMemc;
+               $wgMemc->delete( wfMemcKey( 'change-tag-statistics' ) );
+       }
+
+       /**
         * Returns a map of any tags used on the wiki to number of edits
         * tagged with them, ordered descending by the hitcount.
+        *
+        * Keeps a short-term cache in memory, so calling this multiple times 
in the
+        * same request should be fine.
         *
         * @return array Array of string => int
         */
        public static function tagUsageStatistics() {
+               // Caching...
+               global $wgMemc;
+               $key = wfMemcKey( 'change-tag-statistics' );
+               $stats = $wgMemc->get( $key );
+               if ( $stats ) {
+                       return $stats;
+               }
+
                $out = array();
 
                $dbr = wfGetDB( DB_SLAVE );
@@ -356,6 +882,8 @@
                        }
                }
 
+               // Cache for a very short time
+               $wgMemc->set( $key, $out, 300 );
                return $out;
        }
 }
diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php
index 7bd80c4..c19c13b 100644
--- a/includes/DefaultSettings.php
+++ b/includes/DefaultSettings.php
@@ -4622,6 +4622,7 @@
 #$wgGroupPermissions['sysop']['pagelang'] = true;
 #$wgGroupPermissions['sysop']['upload_by_url'] = true;
 $wgGroupPermissions['sysop']['mergehistory'] = true;
+$wgGroupPermissions['sysop']['managechangetags'] = true;
 
 // Permission to change users' group assignments
 $wgGroupPermissions['bureaucrat']['userrights'] = true;
@@ -6565,6 +6566,7 @@
        'patrol',
        'merge',
        'suppress',
+       'managetags',
 );
 
 /**
@@ -6693,6 +6695,10 @@
        'upload/overwrite' => 'LogFormatter',
        'upload/revert' => 'LogFormatter',
        'merge/merge' => 'MergeLogFormatter',
+       'managetags/create' => 'LogFormatter',
+       'managetags/delete' => 'LogFormatter',
+       'managetags/activate' => 'LogFormatter',
+       'managetags/deactivate' => 'LogFormatter',
 );
 
 /**
diff --git a/includes/User.php b/includes/User.php
index dd199ee..c2db67a 100644
--- a/includes/User.php
+++ b/includes/User.php
@@ -134,6 +134,7 @@
                'import',
                'importupload',
                'ipblock-exempt',
+               'managechangetags',
                'markbotedits',
                'mergehistory',
                'minoredit',
diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php
index 9a98054..f17b874 100644
--- a/includes/api/ApiMain.php
+++ b/includes/api/ApiMain.php
@@ -87,6 +87,7 @@
                'options' => 'ApiOptions',
                'imagerotate' => 'ApiImageRotate',
                'revisiondelete' => 'ApiRevisionDelete',
+               'managetags' => 'ApiManageTags',
        );
 
        /**
diff --git a/includes/api/ApiManageTags.php b/includes/api/ApiManageTags.php
new file mode 100644
index 0000000..b027f33
--- /dev/null
+++ b/includes/api/ApiManageTags.php
@@ -0,0 +1,107 @@
+<?php
+
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ * @since 1.25
+ */
+class ApiManageTags extends ApiBase {
+
+       public function execute() {
+               $params = $this->extractRequestParams();
+
+               // make sure the user is allowed
+               if ( !$this->getUser()->isAllowed( 'managechangetags' ) ) {
+                       $this->dieUsage( "You don't have permission to manage 
change tags", 'permissiondenied' );
+               }
+
+               $result = $this->getResult();
+               $funcName = "{$params['operation']}TagWithChecks";
+               $status = ChangeTags::$funcName( $params['tag'], 
$params['reason'],
+                       $this->getUser(), $params['ignorewarnings'] );
+
+               if ( !$status->isOK() ) {
+                       $this->dieStatus( $status );
+               }
+
+               $ret = array(
+                       'operation' => $params['operation'],
+                       'tag' => $params['tag'],
+               );
+               if ( !$status->isGood() ) {
+                       $ret['warnings'] = $result->convertStatusToArray( 
$status, 'warning' );
+               }
+               if ( $status->value !== null ) {
+                       $ret['success'] = '';
+                       $ret['logid'] = $status->value;
+               }
+               $result->addValue( null, $this->getModuleName(), $ret );
+       }
+
+       public function mustBePosted() {
+               return true;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function getAllowedParams() {
+               return array(
+                       'operation' => array(
+                               ApiBase::PARAM_TYPE => array( 'create', 
'delete', 'activate', 'deactivate' ),
+                               ApiBase::PARAM_REQUIRED => true,
+                       ),
+                       'tag' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => true,
+                       ),
+                       'reason' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                       ),
+                       'ignorewarnings' => array(
+                               ApiBase::PARAM_TYPE => 'boolean',
+                               ApiBase::PARAM_DFLT => false,
+                       ),
+               );
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       protected function getExamplesMessages() {
+               return array(
+                       
'action=managetags&operation=create&tag=spam&reason=For+use+in+edit+patrolling&token=123ABC'
+                               => 'apihelp-managetags-example-create',
+                       
'action=managetags&operation=delete&tag=vandlaism&reason=Misspelt&token=123ABC'
+                               => 'apihelp-managetags-example-delete',
+                       
'action=managetags&operation=activate&tag=spam&reason=For+use+in+edit+patrolling&token=123ABC'
+                               => 'apihelp-managetags-example-activate',
+                       
'action=managetags&operation=deactivate&tag=spam&reason=No+longer+required&token=123ABC'
+                               => 'apihelp-managetags-example-deactivate',
+               );
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Tag_management';
+       }
+}
diff --git a/includes/api/ApiQueryTags.php b/includes/api/ApiQueryTags.php
index 7f2dc85..0e3307b 100644
--- a/includes/api/ApiQueryTags.php
+++ b/includes/api/ApiQueryTags.php
@@ -44,11 +44,17 @@
                $fld_description = isset( $prop['description'] );
                $fld_hitcount = isset( $prop['hitcount'] );
                $fld_defined = isset( $prop['defined'] );
+               $fld_source = isset( $prop['source'] );
+               $fld_active = isset( $prop['active'] );
 
                $limit = $params['limit'];
                $result = $this->getResult();
 
-               $definedTags = array_fill_keys( ChangeTags::listDefinedTags(), 
0 );
+               $extensionDefinedTags = array_fill_keys( 
ChangeTags::listExtensionDefinedTags(), 0 );
+               $explicitlyDefinedTags = array_fill_keys( 
ChangeTags::listExplicitlyDefinedTags(), 0 );
+               $extensionActivatedTags = array_fill_keys( 
ChangeTags::listExtensionActivatedTags(), 0 );
+
+               $definedTags = array_merge( $extensionDefinedTags, 
$explicitlyDefinedTags );
 
                # Fetch defined tags that aren't past the continuation
                if ( $params['continue'] !== null ) {
@@ -99,8 +105,27 @@
                                $tag['hitcount'] = $hitcount;
                        }
 
-                       if ( $fld_defined && isset( $definedTags[$tagName] ) ) {
+                       $isExtension = isset( $extensionDefinedTags[$tagName] );
+                       $isExplicit = isset( $explicitlyDefinedTags[$tagName] );
+
+                       if ( $fld_defined && ( $isExtension || $isExplicit ) ) {
                                $tag['defined'] = '';
+                       }
+
+                       if ( $fld_source ) {
+                               $tag['source'] = array();
+                               if ( $isExtension ) {
+                                       $tag['source'][] = 'extension';
+                               }
+                               if ( $isExplicit ) {
+                                       $tag['source'][] = 'manual';
+                               }
+                       }
+
+                       if ( $fld_active &&
+                               ( $isExplicit || isset( 
$extensionActivatedTags[$tagName] ) )
+                       ) {
+                               $tag['active'] = '';
                        }
 
                        $fit = $result->addValue( array( 'query', 
$this->getModuleName() ), null, $tag );
@@ -137,6 +162,8 @@
                                        'description',
                                        'hitcount',
                                        'defined',
+                                       'source',
+                                       'active',
                                ),
                                ApiBase::PARAM_ISMULTI => true
                        )
diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json
index c19aeb2..0e766bf 100644
--- a/includes/api/i18n/en.json
+++ b/includes/api/i18n/en.json
@@ -204,6 +204,16 @@
        "apihelp-logout-description": "Log out and clear session data.",
        "apihelp-logout-example-logout": "Log the current user out.",
 
+       "apihelp-managetags-description": "Perform management tasks relating to 
change tags.",
+       "apihelp-managetags-param-operation": "Which operation to 
perform:\n;create:Create a new change tag for manual use.\n;delete:Remove a 
change tag from the database, including removing the tag from all revisions, 
recent change entries and log entries on which it is used.\n;activate:Activate 
a change tag, allowing users to apply it manually.\n;deactivate:Deactivate a 
change tag, preventing users from applying it manually.",
+       "apihelp-managetags-param-tag": "Tag to create, delete, activate or 
deactivate. For tag creation, the tag must not exist. For tag deletion, the tag 
must exist. For tag activation, the tag must exist and not be in use by an 
extension. For tag deactivation, the tag must be currently active and manually 
defined.",
+       "apihelp-managetags-param-reason": "An optional reason for creating, 
deleting, activating or deactivating the tag.",
+       "apihelp-managetags-param-ignorewarnings": "Whether to ignore any 
warnings that are issued during the operation.",
+       "apihelp-managetags-example-create": "Create a tag named 
<kbd>spam</kbd> with the reason <kbd>For use in edit patrolling</kbd>",
+       "apihelp-managetags-example-delete": "Delete the <kbd>vandlaism</kbd> 
tag with the reason <kbd>Misspelt</kbd>",
+       "apihelp-managetags-example-activate": "Activate a tag named 
<kbd>spam</kbd> with the reason <kbd>For use in edit patrolling</kbd>",
+       "apihelp-managetags-example-deactivate": "Deactivate a tag named 
<kbd>spam</kbd> with the reason <kbd>No longer required</kbd>",
+
        "apihelp-move-description": "Move a page.",
        "apihelp-move-param-from": "Title of the page to rename. Cannot be used 
together with <var>$1fromid</var>.",
        "apihelp-move-param-fromid": "Page ID of the page to rename. Cannot be 
used together with <var>$1from</var>.",
@@ -878,7 +888,7 @@
 
        "apihelp-query+tags-description": "List change tags.",
        "apihelp-query+tags-param-limit": "The maximum number of tags to list.",
-       "apihelp-query+tags-param-prop": "Which properties to get:\n;name:Adds 
name of tag.\n;displayname:Adds system message for the tag.\n;description:Adds 
description of the tag.\n;hitcount:Adds the amount of revisions that have this 
tag.\n;defined:Indicate whether the tag is defined.",
+       "apihelp-query+tags-param-prop": "Which properties to get:\n;name:Adds 
name of tag.\n;displayname:Adds system message for the tag.\n;description:Adds 
description of the tag.\n;hitcount:Adds the number of revisions and log entries 
that have this tag.\n;defined:Indicate whether the tag is 
defined.\n;source:Gets the sources of the tag, which may include 
<samp>extension</samp> for extension-defined tags and <samp>manual</samp> for 
tags that may be applied manually by users.\n;active:Whether the tag is still 
being applied.",
        "apihelp-query+tags-example-simple": "List available tags.",
 
        "apihelp-query+templates-description": "Returns all pages transcluded 
on the given pages.",
diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json
index 0791acd..f539ac6 100644
--- a/includes/api/i18n/qqq.json
+++ b/includes/api/i18n/qqq.json
@@ -188,6 +188,15 @@
        "apihelp-login-example-login": "{{doc-apihelp-example|login}}",
        "apihelp-logout-description": "{{doc-apihelp-description|logout}}",
        "apihelp-logout-example-logout": "{{doc-apihelp-example|logout}}",
+       "apihelp-managetags-description": 
"{{doc-apihelp-description|managetags}}",
+       "apihelp-managetags-param-operation": 
"{{doc-apihelp-param|managetags|operation}}",
+       "apihelp-managetags-param-tag": "{{doc-apihelp-param|managetags|tag}}",
+       "apihelp-managetags-param-reason": 
"{{doc-apihelp-param|managetags|reason}}",
+       "apihelp-managetags-param-ignorewarnings": 
"{{doc-apihelp-param|managetags|ignorewarnings}}",
+       "apihelp-managetags-example-create": 
"{{doc-apihelp-example|managetags}}",
+       "apihelp-managetags-example-delete": 
"{{doc-apihelp-example|managetags}}",
+       "apihelp-managetags-example-activate": 
"{{doc-apihelp-example|managetags}}",
+       "apihelp-managetags-example-deactivate": 
"{{doc-apihelp-example|managetags}}",
        "apihelp-move-description": "{{doc-apihelp-description|move}}",
        "apihelp-move-param-from": "{{doc-apihelp-param|move|from}}",
        "apihelp-move-param-fromid": "{{doc-apihelp-param|move|fromid}}",
diff --git a/includes/specials/SpecialTags.php 
b/includes/specials/SpecialTags.php
index b762728..ff263b6 100644
--- a/includes/specials/SpecialTags.php
+++ b/includes/specials/SpecialTags.php
@@ -31,6 +31,10 @@
         * @var array List of defined tags
         */
        public $definedTags;
+       /**
+        * @var array List of active tags
+        */
+       public $activeTags;
 
        function __construct() {
                parent::__construct( 'Tags' );
@@ -40,33 +44,108 @@
                $this->setHeaders();
                $this->outputHeader();
 
+               $request = $this->getRequest();
+               switch ( $par ) {
+                       case 'delete':
+                               $this->showDeleteTagForm( $request->getVal( 
'tag' ) );
+                               break;
+                       case 'activate':
+                               $this->showActivateDeactivateForm( 
$request->getVal( 'tag' ), true );
+                               break;
+                       case 'deactivate':
+                               $this->showActivateDeactivateForm( 
$request->getVal( 'tag' ), false );
+                               break;
+                       case 'create':
+                               // fall through, thanks to HTMLForm's logic
+                       default:
+                               $this->showTagList();
+                               break;
+               }
+       }
+
+       function showTagList() {
                $out = $this->getOutput();
                $out->setPageTitle( $this->msg( 'tags-title' ) );
                $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 
'tags-intro' );
+
+               $user = $this->getUser();
+
+               // Show form to create a tag
+               if ( $user->isAllowed( 'managechangetags' ) ) {
+                       $fields = array(
+                               'Tag' => array(
+                                       'type' => 'text',
+                                       'label' => $this->msg( 
'tags-create-tag-name' )->plain(),
+                                       'required' => true,
+                               ),
+                               'Reason' => array(
+                                       'type' => 'text',
+                                       'label' => $this->msg( 
'tags-create-reason' )->plain(),
+                                       'size' => 50,
+                               ),
+                               'IgnoreWarnings' => array(
+                                       'type' => 'hidden',
+                               ),
+                       );
+
+                       $form = new HTMLForm( $fields, $this->getContext() );
+                       $form->setAction( $this->getPageTitle( 'create' 
)->getLocalURL() );
+                       $form->setWrapperLegendMsg( 'tags-create-heading' );
+                       $form->setHeaderText( $this->msg( 
'tags-create-explanation' )->plain() );
+                       $form->setSubmitCallback( array( $this, 
'processCreateTagForm' ) );
+                       $form->setSubmitTextMsg( 'tags-create-submit' );
+                       $form->show();
+
+                       // If processCreateTagForm generated a redirect, 
there's no point
+                       // continuing with this, as the user is just going to 
end up getting sent
+                       // somewhere else. Additionally, if we keep going here, 
we end up
+                       // populating the memcache of tag data (see 
ChangeTags::listDefinedTags)
+                       // with out-of-date data from the slave, because the 
slave hasn't caught
+                       // up to the fact that a new tag has been created as 
part of an implicit,
+                       // as yet uncommitted transaction on master.
+                       if ( $out->getRedirect() !== '' ) {
+                               return;
+                       }
+               }
+
+               // Whether to show the "Actions" column in the tag list
+               // If any actions added in the future require other user 
rights, add those
+               // rights here
+               $showActions = $user->isAllowed( 'managechangetags' );
 
                // Write the headers
                $html = Xml::tags( 'tr', null, Xml::tags( 'th', null, 
$this->msg( 'tags-tag' )->parse() ) .
                        Xml::tags( 'th', null, $this->msg( 
'tags-display-header' )->parse() ) .
                        Xml::tags( 'th', null, $this->msg( 
'tags-description-header' )->parse() ) .
+                       Xml::tags( 'th', null, $this->msg( 'tags-source-header' 
)->parse() ) .
                        Xml::tags( 'th', null, $this->msg( 'tags-active-header' 
)->parse() ) .
-                       Xml::tags( 'th', null, $this->msg( 
'tags-hitcount-header' )->parse() )
+                       Xml::tags( 'th', null, $this->msg( 
'tags-hitcount-header' )->parse() ) .
+                       ( $showActions ?
+                               Xml::tags( 'th', array( 'class' => 'unsortable' 
),
+                                       $this->msg( 'tags-actions-header' 
)->parse() ) :
+                               '' )
                );
 
                // Used in #doTagRow()
-               $this->definedTags = array_fill_keys( 
ChangeTags::listDefinedTags(), true );
+               $this->explicitlyDefinedTags = array_fill_keys(
+                       ChangeTags::listExplicitlyDefinedTags(), true );
+               $this->extensionDefinedTags = array_fill_keys(
+                       ChangeTags::listExtensionDefinedTags(), true );
+               $this->extensionActivatedTags = array_fill_keys(
+                       ChangeTags::listExtensionActivatedTags(), true );
 
                foreach ( ChangeTags::tagUsageStatistics() as $tag => $hitcount 
) {
-                       $html .= $this->doTagRow( $tag, $hitcount );
+                       $html .= $this->doTagRow( $tag, $hitcount, $showActions 
);
                }
 
                $out->addHTML( Xml::tags(
                        'table',
-                       array( 'class' => 'wikitable sortable mw-tags-table' ),
+                       array( 'class' => 'mw-datatable sortable mw-tags-table' 
),
                        $html
                ) );
        }
 
-       function doTagRow( $tag, $hitcount ) {
+       function doTagRow( $tag, $hitcount, $showActions ) {
                $user = $this->getUser();
                $newRow = '';
                $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, 
$tag ) );
@@ -94,9 +173,23 @@
                }
                $newRow .= Xml::tags( 'td', null, $desc );
 
-               $active = isset( $this->definedTags[$tag] ) ? 'tags-active-yes' 
: 'tags-active-no';
-               $active = $this->msg( $active )->escaped();
-               $newRow .= Xml::tags( 'td', null, $active );
+               $sourceMsgs = array();
+               $isExtension = isset( $this->extensionDefinedTags[$tag] );
+               $isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
+               if ( $isExtension ) {
+                       $sourceMsgs[] = $this->msg( 'tags-source-extension' 
)->escaped();
+               }
+               if ( $isExplicit ) {
+                       $sourceMsgs[] = $this->msg( 'tags-source-manual' 
)->escaped();
+               }
+               if ( !$sourceMsgs ) {
+                       $sourceMsgs[] = $this->msg( 'tags-source-none' 
)->escaped();
+               }
+               $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' 
), $sourceMsgs ) );
+
+               $isActive = $isExplicit || isset( 
$this->extensionActivatedTags[$tag] );
+               $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' 
);
+               $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg 
)->escaped() );
 
                $hitcountLabel = $this->msg( 'tags-hitcount' )->numParams( 
$hitcount )->escaped();
                $hitcountLink = Linker::link(
@@ -109,9 +202,228 @@
                // add raw $hitcount for sorting, because tags-hitcount 
contains numbers and letters
                $newRow .= Xml::tags( 'td', array( 'data-sort-value' => 
$hitcount ), $hitcountLink );
 
+               // actions
+               $actionLinks = array();
+               if ( $showActions ) {
+                       // delete
+                       if ( ChangeTags::canDeleteTag( $tag, $user )->isOK() ) {
+                               $actionLinks[] = Linker::linkKnown( 
$this->getPageTitle( 'delete' ),
+                                       $this->msg( 'tags-delete' )->escaped(),
+                                       array(),
+                                       array( 'tag' => $tag ) );
+                       }
+
+                       // activate
+                       if ( ChangeTags::canActivateTag( $tag, $user )->isOK() 
) {
+                               $actionLinks[] = Linker::linkKnown( 
$this->getPageTitle( 'activate' ),
+                                       $this->msg( 'tags-activate' 
)->escaped(),
+                                       array(),
+                                       array( 'tag' => $tag ) );
+                       }
+
+                       // deactivate
+                       if ( ChangeTags::canDeactivateTag( $tag, $user 
)->isOK() ) {
+                               $actionLinks[] = Linker::linkKnown( 
$this->getPageTitle( 'deactivate' ),
+                                       $this->msg( 'tags-deactivate' 
)->escaped(),
+                                       array(),
+                                       array( 'tag' => $tag ) );
+                       }
+
+                       $newRow .= Xml::tags( 'td', null, 
$this->getLanguage()->pipeList( $actionLinks ) );
+               }
+
                return Xml::tags( 'tr', null, $newRow ) . "\n";
        }
 
+       public function processCreateTagForm( array $data, HTMLForm $form ) {
+               $context = $form->getContext();
+               $out = $context->getOutput();
+
+               $tag = trim( strval( $data['Tag'] ) );
+               $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && 
$data['IgnoreWarnings'] === '1';
+               $status = ChangeTags::createTagWithChecks( $tag, 
$data['Reason'],
+                       $context->getUser(), $ignoreWarnings );
+
+               if ( $status->isGood() ) {
+                       $out->redirect( $this->getPageTitle()->getLocalURL() );
+                       return true;
+               } elseif ( $status->isOK() ) {
+                       // we have some warnings, so we show a confirmation form
+                       $fields = array(
+                               'Tag' => array(
+                                       'type' => 'hidden',
+                                       'default' => $data['Tag'],
+                               ),
+                               'Reason' => array(
+                                       'type' => 'hidden',
+                                       'default' => $data['Reason'],
+                               ),
+                               'IgnoreWarnings' => array(
+                                       'type' => 'hidden',
+                                       'default' => '1',
+                               ),
+                       );
+
+                       // fool HTMLForm into thinking the form hasn't been 
submitted yet. Otherwise
+                       // we get into an infinite loop!
+                       $context->getRequest()->unsetVal( 'wpEditToken' );
+
+                       $headerText = $this->msg( 'tags-create-warnings-above', 
$tag,
+                               count( $status->getWarningsArray() ) 
)->parseAsBlock() .
+                               $out->parse( $status->getWikitext() ) .
+                               $this->msg( 'tags-create-warnings-below' 
)->parseAsBlock();
+
+                       $subform = new HTMLForm( $fields, $this->getContext() );
+                       $subform->setAction( $this->getPageTitle( 'create' 
)->getLocalURL() );
+                       $subform->setWrapperLegendMsg( 'tags-create-heading' );
+                       $subform->setHeaderText( $headerText );
+                       $subform->setSubmitCallback( array( $this, 
'processCreateTagForm' ) );
+                       $subform->setSubmitTextMsg( 'htmlform-yes' );
+                       $subform->show();
+
+                       $out->addBacklinkSubtitle( $this->getPageTitle() );
+                       return true;
+               } else {
+                       $out->addWikiText( "<div class=\"error\">\n" . 
$status->getWikitext() .
+                               "\n</div>" );
+                       return false;
+               }
+       }
+
+       protected function showDeleteTagForm( $tag ) {
+               $user = $this->getUser();
+               if ( !$user->isAllowed( 'managechangetags' ) ) {
+                       throw new PermissionsError( 'managechangetags' );
+               }
+
+               $out = $this->getOutput();
+               $out->setPageTitle( $this->msg( 'tags-delete-title' ) );
+               $out->addBacklinkSubtitle( $this->getPageTitle() );
+
+               // is the tag actually able to be deleted?
+               $canDeleteResult = ChangeTags::canDeleteTag( $tag, $user );
+               if ( !$canDeleteResult->isGood() ) {
+                       $out->addWikiText( "<div class=\"error\">\n" . 
$canDeleteResult->getWikiText() .
+                               "\n</div>" );
+                       if ( !$canDeleteResult->isOK() ) {
+                               return;
+                       }
+               }
+
+               $preText = $this->msg( 'tags-delete-explanation-initial', $tag 
)->parseAsBlock();
+               $tagUsage = ChangeTags::tagUsageStatistics();
+               if ( $tagUsage[$tag] > 0 ) {
+                       $preText .= $this->msg( 
'tags-delete-explanation-in-use', $tag,
+                               $tagUsage[$tag] )->parseAsBlock();
+               }
+               $preText .= $this->msg( 'tags-delete-explanation-warning', $tag 
)->parseAsBlock();
+
+               // see if the tag is in use
+               $this->extensionActivatedTags = array_fill_keys(
+                       ChangeTags::listExtensionActivatedTags(), true );
+               if ( isset( $this->extensionActivatedTags[$tag] ) ) {
+                       $preText .= $this->msg( 
'tags-delete-explanation-active', $tag )->parseAsBlock();
+               }
+
+               $fields = array();
+               $fields['Reason'] = array(
+                       'type' => 'text',
+                       'label' => $this->msg( 'tags-delete-reason' )->plain(),
+                       'size' => 50,
+               );
+               $fields['HiddenTag'] = array(
+                       'type' => 'hidden',
+                       'name' => 'tag',
+                       'default' => $tag,
+                       'required' => true,
+               );
+
+               $form = new HTMLForm( $fields, $this->getContext() );
+               $form->setAction( $this->getPageTitle( 'delete' 
)->getLocalURL() );
+               $form->tagAction = 'delete'; // custom property on HTMLForm 
object
+               $form->setSubmitCallback( array( $this, 'processTagForm' ) );
+               $form->setSubmitTextMsg( 'tags-delete-submit' );
+               $form->setSubmitDestructive(); // nasty!
+               $form->addPreText( $preText );
+               $form->show();
+       }
+
+       protected function showActivateDeactivateForm( $tag, $activate ) {
+               $actionStr = $activate ? 'activate' : 'deactivate';
+
+               $user = $this->getUser();
+               if ( !$user->isAllowed( 'managechangetags' ) ) {
+                       throw new PermissionsError( 'managechangetags' );
+               }
+
+               $out = $this->getOutput();
+               // tags-activate-title, tags-deactivate-title
+               $out->setPageTitle( $this->msg( "tags-$actionStr-title" ) );
+               $out->addBacklinkSubtitle( $this->getPageTitle() );
+
+               // is it possible to do this?
+               $func = $activate ? 'canActivateTag' : 'canDeactivateTag';
+               $result = ChangeTags::$func( $tag, $user );
+               if ( !$result->isGood() ) {
+                       $out->wrapWikiMsg( "<div class=\"error\">\n$1" . 
$result->getWikiText() .
+                               "\n</div>" );
+                       if ( !$result->isOK() ) {
+                               return;
+                       }
+               }
+
+               // tags-activate-question, tags-deactivate-question
+               $preText = $this->msg( "tags-$actionStr-question", $tag 
)->parseAsBlock();
+
+               $fields = array();
+               // tags-activate-reason, tags-deactivate-reason
+               $fields['Reason'] = array(
+                       'type' => 'text',
+                       'label' => $this->msg( "tags-$actionStr-reason" 
)->plain(),
+                       'size' => 50,
+               );
+               $fields['HiddenTag'] = array(
+                       'type' => 'hidden',
+                       'name' => 'tag',
+                       'default' => $tag,
+                       'required' => true,
+               );
+
+               $form = new HTMLForm( $fields, $this->getContext() );
+               $form->setAction( $this->getPageTitle( $actionStr 
)->getLocalURL() );
+               $form->tagAction = $actionStr;
+               $form->setSubmitCallback( array( $this, 'processTagForm' ) );
+               // tags-activate-submit, tags-deactivate-submit
+               $form->setSubmitTextMsg( "tags-$actionStr-submit" );
+               $form->addPreText( $preText );
+               $form->show();
+       }
+
+       public function processTagForm( array $data, HTMLForm $form ) {
+               $context = $form->getContext();
+               $out = $context->getOutput();
+
+               $tag = $data['HiddenTag'];
+               $status = call_user_func( array( 'ChangeTags', 
"{$form->tagAction}TagWithChecks" ),
+                       $tag, $data['Reason'], $context->getUser(), true );
+
+               if ( $status->isGood() ) {
+                       $out->redirect( $this->getPageTitle()->getLocalURL() );
+                       return true;
+               } elseif ( $status->isOK() && $form->tagAction === 'delete' ) {
+                       // deletion succeeded, but hooks raised a warning
+                       $out->addWikiText( $this->msg( 
'tags-delete-warnings-after-delete', $tag,
+                               count( $status->getWarningsArray() ) )->text() 
. "\n" .
+                               $status->getWikitext() );
+                       $out->addReturnTo( $this->getPageTitle() );
+                       return true;
+               } else {
+                       $out->addWikiText( "<div class=\"error\">\n" . 
$status->getWikitext() .
+                               "\n</div>" );
+                       return false;
+               }
+       }
+
        protected function getGroupName() {
                return 'changes';
        }
diff --git a/languages/i18n/en.json b/languages/i18n/en.json
index e701137..cff74b4 100644
--- a/languages/i18n/en.json
+++ b/languages/i18n/en.json
@@ -1146,6 +1146,7 @@
        "right-override-export-depth": "Export pages including linked pages up 
to a depth of 5",
        "right-sendemail": "Send email to other users",
        "right-passwordreset": "View password reset emails",
+       "right-managechangetags": "Create and delete [[Special:Tags|tags]] from 
the database",
        "newuserlogpage": "User creation log",
        "newuserlogpagetext": "This is a log of user creations.",
        "rightslog": "User rights log",
@@ -1192,6 +1193,7 @@
        "action-viewmyprivateinfo": "view your private information",
        "action-editmyprivateinfo": "edit your private information",
        "action-editcontentmodel": "edit the content model of a page",
+       "action-managechangetags": "create and delete tags from the database",
        "nchanges": "$1 {{PLURAL:$1|change|changes}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|since last visit}}",
        "enhancedrc-history": "history",
@@ -3361,12 +3363,54 @@
        "tags-tag": "Tag name",
        "tags-display-header": "Appearance on change lists",
        "tags-description-header": "Full description of meaning",
+       "tags-source-header": "Source",
        "tags-active-header": "Active?",
        "tags-hitcount-header": "Tagged changes",
+       "tags-actions-header": "Actions",
        "tags-active-yes": "Yes",
        "tags-active-no": "No",
+       "tags-source-extension": "Defined by an extension",
+       "tags-source-manual": "Applied manually by users and bots",
+       "tags-source-none": "No longer in use",
        "tags-edit": "edit",
+       "tags-delete": "delete",
+       "tags-activate": "activate",
+       "tags-deactivate": "deactivate",
        "tags-hitcount": "$1 {{PLURAL:$1|change|changes}}",
+       "tags-manage-no-permission": "You do not have permission to manage 
change tags.",
+       "tags-create-heading": "Create a new tag",
+       "tags-create-explanation": "By default, newly created tags will be made 
available for use by users and bots.",
+       "tags-create-tag-name": "Tag name:",
+       "tags-create-reason": "Reason:",
+       "tags-create-submit": "Create",
+       "tags-create-no-name": "You must specify a tag name.",
+       "tags-create-invalid-chars": "Tag names must not contain commas 
(<code>,</code>) or forward slashes (<code>/</code>).",
+       "tags-create-invalid-title-chars": "Tag names must not contain 
characters that cannot be used in page titles.",
+       "tags-create-already-exists": "The tag \"$1\" already exists.",
+       "tags-create-warnings-above": "The following {{PLURAL:$2|warning 
was|warnings were}} encountered when attempting to create the tag \"$1\":",
+       "tags-create-warnings-below": "Do you wish to continue creating the 
tag?",
+       "tags-delete-title": "Delete tag",
+       "tags-delete-explanation-initial": "You are about to delete the tag 
\"$1\" from the database.",
+       "tags-delete-explanation-in-use": "It will be removed from 
{{PLURAL:$2|$2 revision or log entry|all $2 revisions and/or log entries}} to 
which it is currently applied.",
+       "tags-delete-explanation-warning": "This action is 
<strong>irreversible</strong> and <strong>cannot be undone</strong>, not even 
by database administrators. Be certain this is the tag you mean to delete.",
+       "tags-delete-explanation-active": "<strong>The tag \"$1\" is still 
active, and will continue to be applied in the future.</strong> To stop this 
from happening, go to the place(s) where the tag is set to be applied, and 
disable it there.",
+       "tags-delete-reason": "Reason:",
+       "tags-delete-submit": "Irreversibly delete this tag",
+       "tags-delete-not-allowed": "Tags defined by an extension cannot be 
deleted unless the extension specifically allows it.",
+       "tags-delete-not-found": "The tag \"$1\" does not exist.",
+       "tags-delete-too-many-uses": "The tag \"$1\" is applied to more than $2 
{{PLURAL:$2|revision|revisions}}, which means it cannot be deleted.",
+       "tags-delete-warnings-after-delete": "The tag \"$1\" was deleted 
successfully, but the following {{PLURAL:$2|warning was|warnings were}} 
encountered:",
+       "tags-activate-title": "Activate tag",
+       "tags-activate-question": "You are about to activate the tag \"$1\".",
+       "tags-activate-reason": "Reason:",
+       "tags-activate-not-allowed": "It is not possible to activate the tag 
\"$1\".",
+       "tags-activate-not-found": "The tag \"$1\" does not exist.",
+       "tags-activate-submit": "Activate",
+       "tags-deactivate-title": "Deactivate tag",
+       "tags-deactivate-question": "You are about to deactivate the tag 
\"$1\".",
+       "tags-deactivate-reason": "Reason:",
+       "tags-deactivate-not-allowed": "It is not possible to deactivate the 
tag \"$1\".",
+       "tags-deactivate-submit": "Deactivate",
        "comparepages": "Compare pages",
        "comparepages-summary": "",
        "compare-page1": "Page 1",
@@ -3439,6 +3483,12 @@
        "logentry-upload-upload": "$1 {{GENDER:$2|uploaded}} $3",
        "logentry-upload-overwrite": "$1 {{GENDER:$2|uploaded}} a new version 
of $3",
        "logentry-upload-revert": "$1 {{GENDER:$2|uploaded}} $3",
+       "log-name-managetags": "Tag management log",
+       "log-description-managetags": "This page lists management tasks related 
to [[Special:Tags|tags]]. The log contains only actions carried out manually by 
an administrator; tags may be created or deleted by the wiki software without 
an entry being recorded in this log.",
+       "logentry-managetags-create": "$1 {{GENDER:$2|created}} the tag \"$4\"",
+       "logentry-managetags-delete": "$1 {{GENDER:$2|deleted}} the tag \"$4\" 
(removed from $5 {{PLURAL:$5|revision or log entry|revisions and/or log 
entries}})",
+       "logentry-managetags-activate": "$1 {{GENDER:$2|activated}} the tag 
\"$4\" for use by users and bots",
+       "logentry-managetags-deactivate": "$1 {{GENDER:$2|deactivated}} the tag 
\"$4\" for use by users and bots",
        "rightsnone": "(none)",
        "revdelete-logentry": "changed revision visibility of \"[[$1]]\"",
        "logdelete-logentry": "changed event visibility of \"[[$1]]\"",
diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json
index 64582cc..5ce194c 100644
--- a/languages/i18n/qqq.json
+++ b/languages/i18n/qqq.json
@@ -1310,6 +1310,7 @@
        "right-override-export-depth": "{{doc-right|override-export-depth}}",
        "right-sendemail": "{{doc-right|sendemail}}",
        "right-passwordreset": "{{doc-right|passwordreset}}",
+       "right-managechangetags": "{{doc-right|managechangetags}}",
        "newuserlogpage": "{{doc-logpage}}\n\nPart of the \"Newuserlog\" 
extension. It is both the title of [[Special:Log/newusers]] and the link you 
can see in [[Special:RecentChanges]].",
        "newuserlogpagetext": "Part of the \"Newuserlog\" extension. It is the 
description you can see on [[Special:Log/newusers]].",
        "rightslog": "{{doc-logpage}}\n\nIn [[Special:Log]]",
@@ -1356,6 +1357,7 @@
        "action-viewmyprivateinfo": "{{doc-action|viewmyprivateinfo}}",
        "action-editmyprivateinfo": "{{doc-action|editmyprivateinfo}}",
        "action-editcontentmodel": "{{doc-action|editcontentmodel}}",
+       "action-managechangetags": "{{doc-action|managechangetags}}",
        "nchanges": "Appears on enhanced watchlist and recent changes when page 
has more than one change on given date, linking to a diff of the 
changes.\n\nParameters:\n* $1 - the number of changes on that day (2 or 
more)\nThree messages are shown side-by-side: ({{msg-mw|Nchanges}} | 
{{msg-mw|Enhancedrc-since-last-visit}} | {{msg-mw|Enhancedrc-history}}).",
        "enhancedrc-since-last-visit": "Appears on enhanced watchlist and 
recent changes when page has more than one change on given date and at least 
one that the user hasn't seen yet, linking to a diff of the unviewed 
changes.\n\nParameters:\n* $1 - the number of unviewed changes (1 or 
more)\nThree messages are shown side-by-side: ({{msg-mw|nchanges}} | 
{{msg-mw|enhancedrc-since-last-visit}} | {{msg-mw|enhancedrc-history}}).",
        "enhancedrc-history": "Appears on enhanced watchlist and recent changes 
when page has more than one change on given date, linking to its 
history.\n\nThis is the same as {{msg-mw|hist}}, but not abbreviated.\n\nThree 
messages are shown side-by-side: ({{msg-mw|nchanges}} | 
{{msg-mw|enhancedrc-since-last-visit}} | 
{{msg-mw|enhancedrc-history}}).\n{{Identical|History}}",
@@ -3525,12 +3527,54 @@
        "tags-tag": "Caption of a column in [[Special:Tags]]. For more 
information on tags see [[mw:Manual:Tags|MediaWiki]].",
        "tags-display-header": "Caption of a column in [[Special:Tags]]. For 
more information on tags see [[mw:Manual:Tags|MediaWiki]].",
        "tags-description-header": "Caption of a column in [[Special:Tags]]. 
For more information on tags see [[mw:Manual:Tags|MediaWiki]].",
+       "tags-source-header": "Caption of a column in [[Special:Tags]]. For 
more information on tags see [[mw:Manual:Tags|MediaWiki]].",
        "tags-active-header": "Caption of a column in [[Special:Tags]]. Values 
are \"Yes\" or \"No\" to indicate if a tag that was ever used is current still 
registered.\n\nSee example: [[mw:Special:Tags]].\n\nFor more information on 
tags see [[mw:Manual:Tags|MediaWiki]].\n{{Identical|Active}}",
        "tags-hitcount-header": "Caption of a column in [[Special:Tags]]. For 
more information on tags see [[mw:Manual:Tags|MediaWiki]].",
+       "tags-actions-header": "Caption of a column in [[Special:Tags]]. The 
column contains action links like \"delete\". For more information on tags see 
[[mw:Manual:Tags|MediaWiki]].",
        "tags-active-yes": "Table cell contents if given tag is 
\"active\".\n\nSee also:\n* {{msg-mw|Tags-active-no}}\n{{Identical|Yes}}",
        "tags-active-no": "Table cell contents if given tag is not 
\"active\".\n\nSee also:\n* {{msg-mw|Tags-active-yes}}\n{{Identical|No}}",
+       "tags-source-extension": "Table cell contents if given tag can be 
applied automatically by a software [[mw:Manual:Extensions|extension]].\n\nSee 
also:\n* {{msg-mw|Tags-source-manual}}\n* {{msg-mw|Tags-source-none}}",
+       "tags-source-manual": "Table cell contents if given tag can be applied 
by users or bots.\n\nSee also:\n* {{msg-mw|Tags-source-extension}}\n* 
{{msg-mw|Tags-source-none}}",
+       "tags-source-none": "Table cell contents if given tag is no longer in 
use. (It was applied in the past, but it is currently not applied.)\n\nSee 
also:\n* {{msg-mw|Tags-source-extension}}\n* {{msg-mw|Tags-source-manual}}",
        "tags-edit": "Used on [[Special:Tags]]. Verb. Used as display text on a 
link to create/edit a description.\n{{Identical|Edit}}",
+       "tags-delete": "Used on [[Special:Tags]]. Verb. Used as display text on 
a link to delete a tag.\n{{Identical|Delete}}",
+       "tags-activate": "Used on [[Special:Tags]]. Verb. Used as display text 
on a link to activate a tag.\n{{Identical|Activate}}",
+       "tags-deactivate": "Used on [[Special:Tags]]. Verb. Used as display 
text on a link to deactivate a tag.\n{{Identical|Delete}}",
        "tags-hitcount": "Shown in the \"{{msg-mw|Tags-hitcount-header}}\" 
column in [[Special:Tags]]. For more information on tags see 
[[mw:Manual:Tags|MediaWiki]].\n\nParameters:\n* $1 - the number of changes 
marked with the tag",
+       "tags-manage-no-permission": "Error message on [[Special:Tags]]",
+       "tags-create-heading": "The title of a fieldset, beneath which lies a 
form used to create a tag. For more information on tags see 
[[mw:Manual:Tags|MediaWiki]].",
+       "tags-create-explanation": "The first paragraph of an explanation to 
tell users what they are about to do.\n\nParameters:\n* $1 - the code name of 
the tag that is about to be deleted",
+       "tags-create-tag-name": "Form field label for the name of the tag to be 
created.",
+       "tags-create-reason": "{{Identical|Reason}}",
+       "tags-create-submit": "The label of the form \"submit\" button when the 
user is about to create a tag.",
+       "tags-create-no-name": "Error message on [[Special:Tags]]",
+       "tags-create-invalid-chars": "Error message on [[Special:Tags]]",
+       "tags-create-invalid-title-chars": "Error message on [[Special:Tags]]",
+       "tags-create-already-exists": "Error message on [[Special:Tags]]",
+       "tags-create-warnings-above": "Explanation placed before warning 
messages upon creating a tag.\n\nParameters:\n* $1 - the code name of the tag 
that the user is attempting to create\n* $2 - the number of warnings",
+       "tags-create-warnings-below": "Question placed after warning messages 
upon creating a tag.",
+       "tags-delete-title": "The title of a page used to delete a tag. For 
more information on tags see [[mw:Manual:Tags|MediaWiki]].",
+       "tags-delete-explanation-initial": "The first paragraph of an 
explanation to tell users what they are about to do.\n\nParameters:\n* $1 - the 
code name of the tag that is about to be deleted",
+       "tags-delete-explanation-in-use": "The second paragraph (not always 
shown) of an explanation to tell users what they are about to 
do.\n\nParameters:\n* $1 - the code name of the tag that is about to be 
deleted\n*$2 - the number of places the tag is used. The value is the sum of 
(revisions + log entries) where the tag is used.",
+       "tags-delete-explanation-warning": "The third paragraph of an 
explanation to tell users what they are about to do.\n\nParameters:\n* $1 - the 
code name of the tag that is about to be deleted",
+       "tags-delete-explanation-active": "The fourth paragraph (not always 
shown) of an explanation to tell users what they are about to 
do.\n\nParameters:\n* $1 - the code name of the tag that is about to be 
deleted\n*$2 - the number of places the tag is used. The value is the sum of 
(rev",
+       "tags-delete-reason": "{{Identical|Reason}}",
+       "tags-delete-submit": "The label of the form \"submit\" button when the 
user is about to delete a tag. The word \"irreversibly\" is used to emphasise 
that the action destroys some data and is impossible to undo, even by server 
administrators.",
+       "tags-delete-not-allowed": "Error message on [[Special:Tags]]",
+       "tags-delete-not-found": "Error message on [[Special:Tags]]",
+       "tags-delete-too-many-uses": "Error message on [[Special:Tags]]",
+       "tags-delete-warnings-after-delete": "Warning shown after deleting a 
tag.\n\nParameters:\n* $1 - the code name of the tag that was deleted\n* $2 - 
the number of warnings",
+       "tags-activate-title": "The title of a page used to activate a tag. For 
more information on tags see [[mw:Manual:Tags|MediaWiki]].",
+       "tags-activate-question": "An explanation to tell users what they are 
about to do.\n\nParameters:\n* $1 - the code name of the tag that is about to 
be activated",
+       "tags-activate-reason": "{{Identical|Reason}}",
+       "tags-activate-submit": "The label of the form \"submit\" button when 
the user is about to activate a tag.",
+       "tags-activate-not-allowed": "Error message on [[Special:Tags]]",
+       "tags-activate-not-found": "Error message on [[Special:Tags]]",
+       "tags-deactivate-title": "The title of a page used to deactivate a tag. 
For more information on tags see [[mw:Manual:Tags|MediaWiki]].",
+       "tags-deactivate-question": "An explanation to tell users what they are 
about to do.\n\nParameters:\n* $1 - the code name of the tag that is about to 
be deactivated",
+       "tags-deactivate-reason": "{{Identical|Reason}}",
+       "tags-deactivate-submit": "The label of the form \"submit\" button when 
the user is about to deactivate a tag.",
+       "tags-deactivate-not-allowed": "Error message on [[Special:Tags]]",
        "comparepages": "The title of [[Special:ComparePages]]",
        "comparepages-summary": "{{doc-specialpagesummary|comparepages}}",
        "compare-page1": "Label for the field of the 1st page in the comparison 
for [[Special:ComparePages]]\n{{Identical|Page}}",
@@ -3603,6 +3647,12 @@
        "logentry-upload-upload": "{{Logentry|[[Special:Log/upload]]}}",
        "logentry-upload-overwrite": "{{Logentry|[[Special:Log/upload]]}}",
        "logentry-upload-revert": "{{Logentry|[[Special:Log/upload]]}}",
+       "log-name-managetags": "The title of a log which contains entries 
related to the management of change tags. \"Tag\" here refers to the same thing 
as {{msg-mw|tags-tag}}.",
+       "log-description-managetags": "The description of the tag management 
log. \"Tag\" here refers to the same thing as {{msg-mw|tags-tag}}.",
+       "logentry-managetags-create": 
"{{Logentry|[[Special:Log/managetags]]}}\n*$4 - tag name",
+       "logentry-managetags-delete": 
"{{Logentry|[[Special:Log/managetags]]}}\n*$4 - tag name\n* $5 - number of 
revisions + log entries that were tagged with the tag",
+       "logentry-managetags-activate": 
"{{Logentry|[[Special:Log/managetags]]}}\n*$4 - tag name",
+       "logentry-managetags-deactivate": 
"{{Logentry|[[Special:Log/managetags]]}}\n*$4 - tag name",
        "rightsnone": "Default rights for registered 
users.\n\n{{Identical|None}}",
        "revdelete-logentry": "{{RevisionDelete}}\nThis is the message for the 
log entry in [[Special:Log/delete]] when changing visibility restrictions for 
page revisions.\n\nFollowed by the message {{msg-mw|revdelete-log-message}} in 
brackets.\n\nPreceded by the name of the user doing this 
task.\n\nParameters:\n* $1 - the page name\nSee also:\n* 
{{msg-mw|Logdelete-logentry}}",
        "logdelete-logentry": "{{RevisionDelete}}\nThis is the message for the 
log entry in [[Special:Log/delete]] when changing visibility restrictions for 
log events.\n\nFollowed by the message {{msg-mw|logdelete-log-message}} in 
brackets.\n\nPreceded by the name of the user who did this 
task.\n\nParameters:\n* $1 - the log name in brackets\nSee also:\n* 
{{msg-mw|Revdelete-logentry}}",

-- 
To view, visit https://gerrit.wikimedia.org/r/181958
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I77f476c8d0f32c80f720aa2c5e66869c81faa282
Gerrit-PatchSet: 30
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: TTO <at.li...@live.com.au>
Gerrit-Reviewer: Alex Monk <kren...@wikimedia.org>
Gerrit-Reviewer: Anomie <bjor...@wikimedia.org>
Gerrit-Reviewer: Jackmcbarn <jackmcb...@gmail.com>
Gerrit-Reviewer: Legoktm <legoktm.wikipe...@gmail.com>
Gerrit-Reviewer: MZMcBride <w...@mzmcbride.com>
Gerrit-Reviewer: Parent5446 <tylerro...@gmail.com>
Gerrit-Reviewer: Se4598 <se4...@gmx.de>
Gerrit-Reviewer: Siebrand <siebr...@kitano.nl>
Gerrit-Reviewer: TTO <at.li...@live.com.au>
Gerrit-Reviewer: Technical 13 <technical...@yahoo.com>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to