Creation, deletion and improved management of change tags
authorThis, that and the other <at.light@live.com.au>
Wed, 4 Feb 2015 11:32:00 +0000 (22:32 +1100)
committerBrad Jorsch <bjorsch@wikimedia.org>
Wed, 4 Feb 2015 19:14:18 +0000 (14:14 -0500)
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

14 files changed:
RELEASE-NOTES-1.25
autoload.php
docs/hooks.txt
includes/ChangeTags.php
includes/DefaultSettings.php
includes/User.php
includes/api/ApiMain.php
includes/api/ApiManageTags.php [new file with mode: 0644]
includes/api/ApiQueryTags.php
includes/api/i18n/en.json
includes/api/i18n/qqq.json
includes/specials/SpecialTags.php
languages/i18n/en.json
languages/i18n/qqq.json

index d2edcb3..22e5ac3 100644 (file)
@@ -80,6 +80,14 @@ production.
   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 @@ production.
   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.
index 90cd074..948a6aa 100644 (file)
@@ -51,6 +51,7 @@ $wgAutoloadLocalClasses = array(
        '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',
index 4717c38..dba6281 100644 (file)
@@ -903,6 +903,38 @@ $name: name of the special page, e.g. 'Watchlist'
 &$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
index 9ee2460..d597d6d 100644 (file)
  */
 
 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
         *
@@ -185,6 +192,7 @@ class ChangeTags {
 
                $dbw->insert( 'change_tag', $tagsRows, __METHOD__, array( 'IGNORE' ) );
 
+               self::purgeTagUsageCache();
                return true;
        }
 
@@ -293,19 +301,479 @@ class ChangeTags {
                return $html;
        }
 
+       /**
+        * 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.
+        *
+        * @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.
-        * Tags on items in table 'change_tag' which are not (or no longer) in table 'valid_tag'
-        * are not included.
+        * 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 listDefinedTags() {
+       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,33 @@ class ChangeTags {
                        $emptyTags[] = $row->vt_tag;
                }
 
-               Hooks::run( 'ListDefinedTags', array( &$emptyTags ) );
+               $emptyTags = array_filter( array_unique( $emptyTags ) );
 
+               // Short-term caching.
+               $wgMemc->set( $key, $emptyTags, 300 );
+               return $emptyTags;
+       }
+
+       /**
+        * 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.
@@ -329,13 +822,46 @@ class ChangeTags {
                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 @@ class ChangeTags {
                        }
                }
 
+               // Cache for a very short time
+               $wgMemc->set( $key, $out, 300 );
                return $out;
        }
 }
index 7bd80c4..c19c13b 100644 (file)
@@ -4622,6 +4622,7 @@ $wgGroupPermissions['sysop']['suppressredirect'] = true;
 #$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 @@ $wgLogTypes = array(
        'patrol',
        'merge',
        'suppress',
+       'managetags',
 );
 
 /**
@@ -6693,6 +6695,10 @@ $wgLogActionsHandlers = array(
        'upload/overwrite' => 'LogFormatter',
        'upload/revert' => 'LogFormatter',
        'merge/merge' => 'MergeLogFormatter',
+       'managetags/create' => 'LogFormatter',
+       'managetags/delete' => 'LogFormatter',
+       'managetags/activate' => 'LogFormatter',
+       'managetags/deactivate' => 'LogFormatter',
 );
 
 /**
index dd199ee..c2db67a 100644 (file)
@@ -134,6 +134,7 @@ class User implements IDBAccessObject {
                'import',
                'importupload',
                'ipblock-exempt',
+               'managechangetags',
                'markbotedits',
                'mergehistory',
                'minoredit',
index 9a98054..f17b874 100644 (file)
@@ -87,6 +87,7 @@ class ApiMain extends ApiBase {
                'options' => 'ApiOptions',
                'imagerotate' => 'ApiImageRotate',
                'revisiondelete' => 'ApiRevisionDelete',
+               'managetags' => 'ApiManageTags',
        );
 
        /**
diff --git a/includes/api/ApiManageTags.php b/includes/api/ApiManageTags.php
new file mode 100644 (file)
index 0000000..b027f33
--- /dev/null
@@ -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';
+       }
+}
index 7f2dc85..0e3307b 100644 (file)
@@ -44,11 +44,17 @@ class ApiQueryTags extends ApiQueryBase {
                $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,10 +105,29 @@ class ApiQueryTags extends ApiQueryBase {
                                $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 );
                        if ( !$fit ) {
                                $this->setContinueEnumParameter( 'continue', $tagName );
@@ -137,6 +162,8 @@ class ApiQueryTags extends ApiQueryBase {
                                        'description',
                                        'hitcount',
                                        'defined',
+                                       'source',
+                                       'active',
                                ),
                                ApiBase::PARAM_ISMULTI => true
                        )
index c19aeb2..0e766bf 100644 (file)
        "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>.",
 
        "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.",
index 0791acd..f539ac6 100644 (file)
        "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}}",
index b762728..ff263b6 100644 (file)
@@ -31,6 +31,10 @@ class SpecialTags extends SpecialPage {
         * @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 @@ class SpecialTags extends SpecialPage {
                $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 @@ class SpecialTags extends SpecialPage {
                }
                $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 @@ class SpecialTags extends SpecialPage {
                // 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';
        }
index e701137..cff74b4 100644 (file)
        "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",
        "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",
        "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",
        "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]]\"",
index 64582cc..5ce194c 100644 (file)
        "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]]",
        "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}}",
        "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}}",
        "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}}",