Allow users to add, remove and apply change tags using the API
authorThis, that and the other <at.light@live.com.au>
Wed, 15 Apr 2015 01:33:08 +0000 (11:33 +1000)
committerAnomie <bjorsch@wikimedia.org>
Wed, 15 Apr 2015 18:30:45 +0000 (18:30 +0000)
You can add tags at the same time as performing action=edit, as long as you
have the "applychangetags" right. Also, you can add or remove tags after
the fact from revisions and log entries using the API action=tags.

No UI is provided for either of these changes. The target audience is user
scripts, gadgets and similar tools.

Includes a new log parameter format type: "list", for a comma-separated
list of values.

Logging of change tag events is limited to those that do not accompany an
edit (i.e. those done after the fact), and is hidden from Special:Log by
default, similar to the patrol log.

Bug: T20670
Change-Id: I37275e0f73fa3127f55da0c320b892551b61ee80

15 files changed:
autoload.php
includes/ChangeTags.php
includes/DefaultSettings.php
includes/EditPage.php
includes/User.php
includes/api/ApiBase.php
includes/api/ApiEditPage.php
includes/api/ApiMain.php
includes/api/ApiTag.php [new file with mode: 0644]
includes/api/i18n/en.json
includes/api/i18n/qqq.json
includes/logging/LogFormatter.php
includes/logging/TagLogFormatter.php [new file with mode: 0644]
languages/i18n/en.json
languages/i18n/qqq.json

index b480096..2fe805f 100644 (file)
@@ -124,6 +124,7 @@ $wgAutoloadLocalClasses = array(
        'ApiRsd' => __DIR__ . '/includes/api/ApiRsd.php',
        'ApiSetNotificationTimestamp' => __DIR__ . '/includes/api/ApiSetNotificationTimestamp.php',
        'ApiStashEdit' => __DIR__ . '/includes/api/ApiStashEdit.php',
+       'ApiTag' => __DIR__ . '/includes/api/ApiTag.php',
        'ApiTokens' => __DIR__ . '/includes/api/ApiTokens.php',
        'ApiUnblock' => __DIR__ . '/includes/api/ApiUnblock.php',
        'ApiUndelete' => __DIR__ . '/includes/api/ApiUndelete.php',
@@ -1194,6 +1195,7 @@ $wgAutoloadLocalClasses = array(
        'TableCleanupTest' => __DIR__ . '/maintenance/cleanupTable.inc',
        'TableDiffFormatter' => __DIR__ . '/includes/diff/TableDiffFormatter.php',
        'TablePager' => __DIR__ . '/includes/pager/TablePager.php',
+       'TagLogFormatter' => __DIR__ . '/includes/logging/TagLogFormatter.php',
        'TempFSFile' => __DIR__ . '/includes/filebackend/TempFSFile.php',
        'TempFileRepo' => __DIR__ . '/includes/filerepo/FileRepo.php',
        'TemplateParser' => __DIR__ . '/includes/TemplateParser.php',
index 754c0f8..a097cd6 100644 (file)
@@ -91,21 +91,50 @@ class ChangeTags {
         *
         * @throws MWException
         * @return bool False if no changes are made, otherwise true
-        *
-        * @exception MWException When $rc_id, $rev_id and $log_id are all null
         */
        public static function addTags( $tags, $rc_id = null, $rev_id = null,
                $log_id = null, $params = null
        ) {
-               if ( !is_array( $tags ) ) {
-                       $tags = array( $tags );
-               }
+               $result = self::updateTags( $tags, null, $rc_id, $rev_id, $log_id, $params );
+               return (bool)$result[0];
+       }
 
-               $tags = array_filter( $tags ); // Make sure we're submitting all tags...
+       /**
+        * Add and remove tags to/from a change given its rc_id, rev_id and/or log_id,
+        * without verifying that the tags exist or are valid. If a tag is present in
+        * both $tagsToAdd and $tagsToRemove, it will be removed.
+        *
+        * This function should only be used by extensions to manipulate tags they
+        * have registered using the ListDefinedTags hook. When dealing with user
+        * input, call updateTagsWithChecks() instead.
+        *
+        * @param string|array|null $tagsToAdd Tags to add to the change
+        * @param string|array|null $tagsToRemove Tags to remove from the change
+        * @param int|null &$rc_id The rc_id of the change to add the tags to.
+        * Pass a variable whose value is null if the rc_id is not relevant or unknown.
+        * @param int|null &$rev_id The rev_id of the change to add the tags to.
+        * Pass a variable whose value is null if the rev_id is not relevant or unknown.
+        * @param int|null &$log_id The log_id of the change to add the tags to.
+        * Pass a variable whose value is null if the log_id is not relevant or unknown.
+        * @param string $params Params to put in the ct_params field of table
+        * 'change_tag' when adding tags
+        *
+        * @throws MWException When $rc_id, $rev_id and $log_id are all null
+        * @return array Index 0 is an array of tags actually added, index 1 is an
+        * array of tags actually removed, index 2 is an array of tags present on the
+        * revision or log entry before any changes were made
+        *
+        * @since 1.25
+        */
+       public static function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id = null,
+               &$rev_id = null, &$log_id = null, $params = null ) {
+
+               $tagsToAdd = array_filter( (array)$tagsToAdd ); // Make sure we're submitting all tags...
+               $tagsToRemove = array_filter( (array)$tagsToRemove );
 
                if ( !$rc_id && !$rev_id && !$log_id ) {
                        throw new MWException( 'At least one of: RCID, revision ID, and log ID MUST be ' .
-                               'specified when adding a tag to a change!' );
+                               'specified when adding or removing a tag from a change!' );
                }
 
                $dbw = wfGetDB( DB_MASTER );
@@ -144,11 +173,85 @@ class ChangeTags {
                        );
                }
 
+               // update the tag_summary row
+               $prevTags = array();
+               if ( !self::updateTagSummaryRow( $tagsToAdd, $tagsToRemove, $rc_id, $rev_id,
+                       $log_id, $prevTags ) ) {
+
+                       // nothing to do
+                       return array( array(), array(), $prevTags );
+               }
+
+               // insert a row into change_tag for each new tag
+               if ( count( $tagsToAdd ) ) {
+                       $tagsRows = array();
+                       foreach ( $tagsToAdd as $tag ) {
+                               // Filter so we don't insert NULLs as zero accidentally.
+                               // Keep in mind that $rc_id === null means "I don't care/know about the
+                               // rc_id, just delete $tag on this revision/log entry". It doesn't
+                               // mean "only delete tags on this revision/log WHERE rc_id IS NULL".
+                               $tagsRows[] = array_filter(
+                                       array(
+                                               'ct_tag' => $tag,
+                                               'ct_rc_id' => $rc_id,
+                                               'ct_log_id' => $log_id,
+                                               'ct_rev_id' => $rev_id,
+                                               'ct_params' => $params
+                                       )
+                               );
+                       }
+
+                       $dbw->insert( 'change_tag', $tagsRows, __METHOD__, array( 'IGNORE' ) );
+               }
+
+               // delete from change_tag
+               if ( count( $tagsToRemove ) ) {
+                       foreach ( $tagsToRemove as $tag ) {
+                               $conds = array_filter(
+                                       array(
+                                               'ct_tag' => $tag,
+                                               'ct_rc_id' => $rc_id,
+                                               'ct_log_id' => $log_id,
+                                               'ct_rev_id' => $rev_id
+                                       )
+                               );
+                               $dbw->delete( 'change_tag', $conds, __METHOD__ );
+                       }
+               }
+
+               self::purgeTagUsageCache();
+               return array( $tagsToAdd, $tagsToRemove, $prevTags );
+       }
+
+       /**
+        * Adds or removes a given set of tags to/from the relevant row of the
+        * tag_summary table. Modifies the tagsToAdd and tagsToRemove arrays to
+        * reflect the tags that were actually added and/or removed.
+        *
+        * @param array &$tagsToAdd
+        * @param array &$tagsToRemove If a tag is present in both $tagsToAdd and
+        * $tagsToRemove, it will be removed
+        * @param int|null $rc_id Null if not known or not applicable
+        * @param int|null $rev_id Null if not known or not applicable
+        * @param int|null $log_id Null if not known or not applicable
+        * @param array &$prevTags Optionally outputs a list of the tags that were
+        * in the tag_summary row to begin with
+        * @return bool True if any modifications were made, otherwise false
+        * @since 1.25
+        */
+       protected static function updateTagSummaryRow( &$tagsToAdd, &$tagsToRemove,
+               $rc_id, $rev_id, $log_id, &$prevTags = array() ) {
+
+               $dbw = wfGetDB( DB_MASTER );
+
                $tsConds = array_filter( array(
                        'ts_rc_id' => $rc_id,
                        'ts_rev_id' => $rev_id,
-                       'ts_log_id' => $log_id )
-               );
+                       'ts_log_id' => $log_id
+               ) );
+
+               // Can't both add and remove a tag at the same time...
+               $tagsToAdd = array_diff( $tagsToAdd, $tagsToRemove );
 
                // Update the summary row.
                // $prevTags can be out of date on slaves, especially when addTags is called consecutively,
@@ -156,42 +259,276 @@ class ChangeTags {
                $prevTags = $dbw->selectField( 'tag_summary', 'ts_tags', $tsConds, __METHOD__ );
                $prevTags = $prevTags ? $prevTags : '';
                $prevTags = array_filter( explode( ',', $prevTags ) );
-               $newTags = array_unique( array_merge( $prevTags, $tags ) );
+
+               // add tags
+               $tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
+               $newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
+
+               // remove tags
+               $tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
+               $newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
+
                sort( $prevTags );
                sort( $newTags );
-
                if ( $prevTags == $newTags ) {
                        // No change.
                        return false;
                }
 
-               $dbw->replace(
-                       'tag_summary',
-                       array( 'ts_rev_id', 'ts_rc_id', 'ts_log_id' ),
-                       array_filter( array_merge( $tsConds, array( 'ts_tags' => implode( ',', $newTags ) ) ) ),
-                       __METHOD__
-               );
-
-               // Insert the tags rows.
-               $tagsRows = array();
-               foreach ( $tags as $tag ) { // Filter so we don't insert NULLs as zero accidentally.
-                       $tagsRows[] = array_filter(
-                               array(
-                                       'ct_tag' => $tag,
-                                       'ct_rc_id' => $rc_id,
-                                       'ct_log_id' => $log_id,
-                                       'ct_rev_id' => $rev_id,
-                                       'ct_params' => $params
-                               )
+               if ( !$newTags ) {
+                       // no tags left, so delete the row altogether
+                       $dbw->delete( 'tag_summary', $tsConds, __METHOD__ );
+               } else {
+                       $dbw->replace( 'tag_summary',
+                               array( 'ts_rev_id', 'ts_rc_id', 'ts_log_id' ),
+                               array_filter( array_merge( $tsConds, array( 'ts_tags' => implode( ',', $newTags ) ) ) ),
+                               __METHOD__
                        );
                }
 
-               $dbw->insert( 'change_tag', $tagsRows, __METHOD__, array( 'IGNORE' ) );
-
-               self::purgeTagUsageCache();
                return true;
        }
 
+       /**
+        * Helper function to generate a fatal status with a 'not-allowed' type error.
+        *
+        * @param string $msgOne Message key to use in the case of one tag
+        * @param string $msgMulti Message key to use in the case of more than one tag
+        * @param array $tags Restricted tags (passed as $1 into the message, count of
+        * $tags passed as $2)
+        * @return Status
+        * @since 1.25
+        */
+       protected static function restrictedTagError( $msgOne, $msgMulti, $tags ) {
+               $lang = RequestContext::getMain()->getLanguage();
+               $count = count( $tags );
+               return Status::newFatal( ( $count > 1 ) ? $msgMulti : $msgOne,
+                       $lang->commaList( $tags ), $count );
+       }
+
+       /**
+        * Is it OK to allow the user to apply all the specified tags at the same time
+        * as they edit/make the change?
+        *
+        * @param array $tags Tags that you are interested in applying
+        * @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 canAddTagsAccompanyingChange( array $tags,
+               User $user = null ) {
+
+               if ( !is_null( $user ) && !$user->isAllowed( 'applychangetags' ) ) {
+                       return Status::newFatal( 'tags-apply-no-permission' );
+               }
+
+               // to be applied, a tag has to be explicitly defined
+               // @todo Allow extensions to define tags that can be applied by users...
+               $allowedTags = self::listExplicitlyDefinedTags();
+               $disallowedTags = array_diff( $tags, $allowedTags );
+               if ( $disallowedTags ) {
+                       return self::restrictedTagError( 'tags-apply-not-allowed-one',
+                               'tags-apply-not-allowed-multi', $disallowedTags );
+               }
+
+               return Status::newGood();
+       }
+
+       /**
+        * Adds tags to a given change, checking whether it is allowed first, but
+        * without adding a log entry. Useful for cases where the tag is being added
+        * along with the action that generated the change (e.g. tagging an edit as
+        * it is being made).
+        *
+        * Extensions should not use this function, unless directly handling a user
+        * request to add a particular tag. Normally, extensions should call
+        * ChangeTags::updateTags() instead.
+        *
+        * @param array $tags Tags to apply
+        * @param int|null $rc_id The rc_id of the change to add the tags to
+        * @param int|null $rev_id The rev_id of the change to add the tags to
+        * @param int|null $log_id The log_id of the change to add the tags to
+        * @param string $params Params to put in the ct_params field of table
+        * 'change_tag' when adding tags
+        * @param User $user Who to give credit for the action
+        * @return Status
+        * @since 1.25
+        */
+       public static function addTagsAccompanyingChangeWithChecks( array $tags,
+               $rc_id, $rev_id, $log_id, $params, User $user ) {
+
+               // are we allowed to do this?
+               $result = self::canAddTagsAccompanyingChange( $tags, $user );
+               if ( !$result->isOK() ) {
+                       $result->value = null;
+                       return $result;
+               }
+
+               // do it!
+               self::addTags( $tagsToAdd, $rc_id, $rev_id, $log_id, $params );
+
+               return Status::newGood( true );
+       }
+
+       /**
+        * Is it OK to allow the user to adds and remove the given tags tags to/from a
+        * change?
+        *
+        * @param array $tagsToAdd Tags that you are interested in adding
+        * @param array $tagsToRemove Tags that you are interested in removing
+        * @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 canUpdateTags( array $tagsToAdd, array $tagsToRemove,
+               User $user = null ) {
+
+               if ( !is_null( $user ) && !$user->isAllowed( 'changetags' ) ) {
+                       return Status::newFatal( 'tags-update-no-permission' );
+               }
+
+               // to be added, a tag has to be explicitly defined
+               // @todo Allow extensions to define tags that can be applied by users...
+               $explicitlyDefinedTags = self::listExplicitlyDefinedTags();
+               $diff = array_diff( $tagsToAdd, $explicitlyDefinedTags );
+               if ( $diff ) {
+                       return self::restrictedTagError( 'tags-update-add-not-allowed-one',
+                               'tags-update-add-not-allowed-multi', $diff );
+               }
+
+               // to be removed, a tag has to be either explicitly defined or not defined
+               // at all
+               $definedTags = self::listDefinedTags();
+               $diff = array_diff( $tagsToRemove, $explicitlyDefinedTags );
+               if ( $diff ) {
+                       $intersect = array_intersect( $diff, $definedTags );
+                       if ( $intersect ) {
+                               return self::restrictedTagError( 'tags-update-remove-not-allowed-one',
+                                       'tags-update-remove-not-allowed-multi', $intersect );
+                       }
+               }
+
+               return Status::newGood();
+       }
+
+       /**
+        * Adds and/or removes tags to/from a given change, checking whether it is
+        * allowed first, and adding a log entry afterwards.
+        *
+        * Includes a call to ChangeTag::canUpdateTags(), so your code doesn't need
+        * to do that. However, it doesn't check whether the *_id parameters are a
+        * valid combination. That is up to you to enforce. See ApiTag::execute() for
+        * an example.
+        *
+        * @param array|null $tagsToAdd If none, pass array() or null
+        * @param array|null $tagsToRemove If none, pass array() or null
+        * @param int|null $rc_id The rc_id of the change to add the tags to
+        * @param int|null $rev_id The rev_id of the change to add the tags to
+        * @param int|null $log_id The log_id of the change to add the tags to
+        * @param string $params Params to put in the ct_params field of table
+        * 'change_tag' when adding tags
+        * @param string $reason Comment for the log
+        * @param User $user Who to give credit for the action
+        * @return Status If successful, the value of this Status object will be an
+        * object (stdClass) with the following fields:
+        *  - logId: the ID of the added log entry, or null if no log entry was added
+        *    (i.e. no operation was performed)
+        *  - addedTags: an array containing the tags that were actually added
+        *  - removedTags: an array containing the tags that were actually removed
+        * @since 1.25
+        */
+       public static function updateTagsWithChecks( $tagsToAdd, $tagsToRemove,
+               $rc_id, $rev_id, $log_id, $params, $reason, User $user ) {
+
+               if ( is_null( $tagsToAdd ) ) {
+                       $tagsToAdd = array();
+               }
+               if ( is_null( $tagsToRemove ) ) {
+                       $tagsToRemove = array();
+               }
+               if ( !$tagsToAdd && !$tagsToRemove ) {
+                       // no-op, don't bother
+                       return Status::newGood( (object)array(
+                               'logId' => null,
+                               'addedTags' => array(),
+                               'removedTags' => array(),
+                       ) );
+               }
+
+               // are we allowed to do this?
+               $result = self::canUpdateTags( $tagsToAdd, $tagsToRemove, $user );
+               if ( !$result->isOK() ) {
+                       $result->value = null;
+                       return $result;
+               }
+
+               // basic rate limiting
+               if ( $user->pingLimiter( 'changetag' ) ) {
+                       return Status::newFatal( 'actionthrottledtext' );
+               }
+
+               // do it!
+               list( $tagsAdded, $tagsRemoved, $initialTags ) = self::updateTags( $tagsToAdd,
+                       $tagsToRemove, $rc_id, $rev_id, $log_id, $params );
+               if ( !$tagsAdded && !$tagsRemoved ) {
+                       // no-op, don't log it
+                       return Status::newGood( (object)array(
+                               'logId' => null,
+                               'addedTags' => array(),
+                               'removedTags' => array(),
+                       ) );
+               }
+
+               // log it
+               $logEntry = new ManualLogEntry( 'tag', 'update' );
+               $logEntry->setPerformer( $user );
+               $logEntry->setComment( $reason );
+
+               // find the appropriate target page
+               if ( $rev_id ) {
+                       $rev = Revision::newFromId( $rev_id );
+                       if ( $rev ) {
+                               $title = $rev->getTitle();
+                               $logEntry->setTarget( $rev->getTitle() );
+                       }
+               } elseif ( $log_id ) {
+                       // This function is from revision deletion logic and has nothing to do with
+                       // change tags, but it appears to be the only other place in core where we
+                       // perform logged actions on log items.
+                       $logEntry->setTarget( RevDelLogList::suggestTarget( 0, array( $log_id ) ) );
+               }
+
+               if ( !$logEntry->getTarget() ) {
+                       // target is required, so we have to set something
+                       $logEntry->setTarget( SpecialPage::getTitleFor( 'Tags' ) );
+               }
+
+               $logParams = array(
+                       '4::revid' => $rev_id,
+                       '5::logid' => $log_id,
+                       '6:list:tagsAdded' => $tagsAdded,
+                       '7:number:tagsAddedCount' => count( $tagsAdded ),
+                       '8:list:tagsRemoved' => $tagsRemoved,
+                       '9:number:tagsRemovedCount' => count( $tagsRemoved ),
+                       'initialTags' => $initialTags,
+               );
+               $logEntry->setParameters( $logParams );
+               $logEntry->setRelations( array( 'Tag' => array_merge( $tagsAdded, $tagsRemoved ) ) );
+
+               $dbw = wfGetDB( DB_MASTER );
+               $logId = $logEntry->insert( $dbw );
+               // Only send this to UDP, not RC, similar to patrol events
+               $logEntry->publish( $logId, 'udp' );
+
+               return Status::newGood( (object)array(
+                       'logId' => $logId,
+                       'addedTags' => $tagsAdded,
+                       'removedTags' => $tagsRemoved,
+               ) );
+       }
+
        /**
         * Applies all tags-related changes to a query.
         * Handles selecting tags, and filtering.
@@ -344,8 +681,8 @@ class ChangeTags {
         * it was deleted.
         * @since 1.25
         */
-       protected static function logTagAction( $action, $tag, $reason, User $user,
-               $tagCount = null ) {
+       protected static function logTagManagementAction( $action, $tag, $reason,
+               User $user, $tagCount = null ) {
 
                $dbw = wfGetDB( DB_MASTER );
 
@@ -428,7 +765,7 @@ class ChangeTags {
                self::defineTag( $tag );
 
                // log it
-               $logId = self::logTagAction( 'activate', $tag, $reason, $user );
+               $logId = self::logTagManagementAction( 'activate', $tag, $reason, $user );
                return Status::newGood( $logId );
        }
 
@@ -483,7 +820,7 @@ class ChangeTags {
                self::undefineTag( $tag );
 
                // log it
-               $logId = self::logTagAction( 'deactivate', $tag, $reason, $user );
+               $logId = self::logTagManagementAction( 'deactivate', $tag, $reason, $user );
                return Status::newGood( $logId );
        }
 
@@ -558,7 +895,7 @@ class ChangeTags {
                self::defineTag( $tag );
 
                // log it
-               $logId = self::logTagAction( 'create', $tag, $reason, $user );
+               $logId = self::logTagManagementAction( 'create', $tag, $reason, $user );
                return Status::newGood( $logId );
        }
 
@@ -587,38 +924,11 @@ class ChangeTags {
                        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__ );
-                       }
+                       $tagsToAdd = array();
+                       $tagsToRemove = array( $tag );
+                       self::updateTagSummaryRow( $tagsToAdd, $tagsToRemove, $row->ct_rc_id,
+                               $row->ct_rev_id, $row->ct_log_id );
                }
 
                // delete from change_tag
@@ -714,7 +1024,7 @@ class ChangeTags {
                }
 
                // log it
-               $logId = self::logTagAction( 'delete', $tag, $reason, $user, $tagUsage[$tag] );
+               $logId = self::logTagManagementAction( 'delete', $tag, $reason, $user, $tagUsage[$tag] );
                $deleteResult->value = $logId;
                return $deleteResult;
        }
index dc16ae3..5c0bcff 100644 (file)
@@ -4556,6 +4556,8 @@ $wgGroupPermissions['user']['reupload-shared'] = true;
 $wgGroupPermissions['user']['minoredit'] = true;
 $wgGroupPermissions['user']['purge'] = true; // can use ?action=purge without clicking "ok"
 $wgGroupPermissions['user']['sendemail'] = true;
+$wgGroupPermissions['user']['applychangetags'] = true;
+$wgGroupPermissions['user']['changetags'] = true;
 
 // Implicit group for accounts that pass $wgAutoConfirmAge
 $wgGroupPermissions['autoconfirmed']['autoconfirmed'] = true;
@@ -5036,7 +5038,11 @@ $wgRateLimits = array(
                'newbie' => null,
                'ip' => null,
                'subnet' => null,
-       )
+       ),
+       'changetag' => array( // adding or removing change tags
+               'user' => null,
+               'newbie' => null,
+       ),
 );
 
 /**
@@ -6566,6 +6572,7 @@ $wgLogTypes = array(
        'patrol',
        'merge',
        'suppress',
+       'tag',
        'managetags',
 );
 
@@ -6603,7 +6610,8 @@ $wgLogRestrictions = array(
  * for the link text.
  */
 $wgFilterLogTypes = array(
-       'patrol' => true
+       'patrol' => true,
+       'tag' => true,
 );
 
 /**
@@ -6688,6 +6696,7 @@ $wgLogActionsHandlers = array(
        'upload/overwrite' => 'LogFormatter',
        'upload/revert' => 'LogFormatter',
        'merge/merge' => 'MergeLogFormatter',
+       'tag/update' => 'TagLogFormatter',
        'managetags/create' => 'LogFormatter',
        'managetags/delete' => 'LogFormatter',
        'managetags/activate' => 'LogFormatter',
index e113426..8d27eac 100644 (file)
@@ -156,6 +156,12 @@ class EditPage {
         */
        const AS_SELF_REDIRECT = 236;
 
+       /**
+        * Status: an error relating to change tagging. Look at the message key for
+        * more details
+        */
+       const AS_CHANGE_TAG_ERROR = 237;
+
        /**
         * Status: can't parse content
         */
@@ -351,6 +357,9 @@ class EditPage {
        /** @var null|string */
        public $contentFormat = null;
 
+       /** @var null|array */
+       public $changeTags = null;
+
        # Placeholders for text injection by hooks (must be HTML)
        # extensions should take care to _append_ to the present value
 
@@ -844,6 +853,14 @@ class EditPage {
 
                        $this->allowBlankArticle = $request->getBool( 'wpIgnoreBlankArticle' );
                        $this->allowSelfRedirect = $request->getBool( 'wpIgnoreSelfRedirect' );
+
+                       $changeTags = $request->getVal( 'wpChangeTags' );
+                       if ( is_null( $changeTags ) || $changeTags === '' ) {
+                               $this->changeTags = array();
+                       } else {
+                               $this->changeTags = array_filter( array_map( 'trim', explode( ',',
+                                       $changeTags ) ) );
+                       }
                } else {
                        # Not a posted form? Start with nothing.
                        wfDebug( __METHOD__ . ": Not a posted form.\n" );
@@ -1642,6 +1659,15 @@ class EditPage {
                        return $status;
                }
 
+               if ( $this->changeTags ) {
+                       $changeTagsStatus = ChangeTags::canAddTagsAccompanyingChange(
+                               $this->changeTags, $wgUser );
+                       if ( !$changeTagsStatus->isOK() ) {
+                               $changeTagsStatus->value = self::AS_CHANGE_TAG_ERROR;
+                               return $changeTagsStatus;
+                       }
+               }
+
                if ( wfReadOnly() ) {
                        $status->fatal( 'readonlytext' );
                        $status->value = self::AS_READ_ONLY_PAGE;
@@ -1915,7 +1941,18 @@ class EditPage {
                        $wgUser->pingLimiter( 'linkpurge' );
                }
                $result['redirect'] = $content->isRedirect();
+
                $this->updateWatchlist();
+
+               if ( $this->changeTags && isset( $doEditStatus->value['revision'] ) ) {
+                       // If a revision was created, apply any change tags that were requested
+                       ChangeTags::addTags(
+                               $this->changeTags,
+                               isset( $doEditStatus->value['rc'] ) ? $doEditStatus->value['rc']->mAttribs['rc_id'] : null,
+                               $doEditStatus->value['revision']->getId()
+                       );
+               }
+
                return $status;
        }
 
index 9168c33..3c2939f 100644 (file)
@@ -102,6 +102,7 @@ class User implements IDBAccessObject {
         */
        protected static $mCoreRights = array(
                'apihighlimits',
+               'applychangetags',
                'autoconfirmed',
                'autopatrol',
                'bigdelete',
@@ -109,6 +110,7 @@ class User implements IDBAccessObject {
                'blockemail',
                'bot',
                'browsearchive',
+               'changetags',
                'createaccount',
                'createpage',
                'createtalk',
index 6e289dc..f4f2c8c 100644 (file)
@@ -1668,6 +1668,10 @@ abstract class ApiBase extends ContextSource {
                        'code' => 'nosuchrcid',
                        'info' => "There is no change with rcid \"\$1\""
                ),
+               'nosuchlogid' => array(
+                       'code' => 'nosuchlogid',
+                       'info' => "There is no log entry with ID \"\$1\""
+               ),
                'protect-invalidaction' => array(
                        'code' => 'protect-invalidaction',
                        'info' => "Invalid protection type \"\$1\""
index ef8957e..8c7d31d 100644 (file)
@@ -331,6 +331,15 @@ class ApiEditPage extends ApiBase {
                        $requestArray['wpWatchthis'] = '';
                }
 
+               // Apply change tags
+               if ( count( $params['tags'] ) ) {
+                       if ( $user->isAllowed( 'applychangetags' ) ) {
+                               $requestArray['wpChangeTags'] = implode( ',', $params['tags'] );
+                       } else {
+                               $this->dieUsage( 'You don\'t have permission to set change tags.', 'taggingnotallowed' );
+                       }
+               }
+
                // Pass through anything else we might have been given, to support extensions
                // This is kind of a hack but it's the best we can do to make extensions work
                $requestArray += $this->getRequest()->getValues();
@@ -475,6 +484,9 @@ class ApiEditPage extends ApiBase {
                        case EditPage::AS_TEXTBOX_EMPTY:
                                $this->dieUsageMsg( 'emptynewsection' );
 
+                       case EditPage::AS_CHANGE_TAG_ERROR:
+                               $this->dieStatus( $status );
+
                        case EditPage::AS_SUCCESS_NEW_ARTICLE:
                                $r['new'] = '';
                                // fall-through
@@ -531,6 +543,10 @@ class ApiEditPage extends ApiBase {
                        ),
                        'text' => null,
                        'summary' => null,
+                       'tags' => array(
+                               ApiBase::PARAM_TYPE => ChangeTags::listExplicitlyDefinedTags(),
+                               ApiBase::PARAM_ISMULTI => true,
+                       ),
                        'minor' => false,
                        'notminor' => false,
                        'bot' => false,
index 2978453..ee1cfa6 100644 (file)
@@ -89,6 +89,7 @@ class ApiMain extends ApiBase {
                'imagerotate' => 'ApiImageRotate',
                'revisiondelete' => 'ApiRevisionDelete',
                'managetags' => 'ApiManageTags',
+               'tag' => 'ApiTag',
        );
 
        /**
diff --git a/includes/api/ApiTag.php b/includes/api/ApiTag.php
new file mode 100644 (file)
index 0000000..fcf0ac1
--- /dev/null
@@ -0,0 +1,178 @@
+<?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 ApiTag extends ApiBase {
+
+       protected function getAvailableTags() {
+               return ChangeTags::listExplicitlyDefinedTags();
+       }
+
+       public function execute() {
+               $params = $this->extractRequestParams();
+
+               // make sure the user is allowed
+               if ( !$this->getUser()->isAllowed( 'changetags' ) ) {
+                       $this->dieUsage( "You don't have permission to add or remove change tags from individual edits",
+                               'permissiondenied' );
+               }
+
+               // validate and process each revid, rcid and logid
+               $this->requireAtLeastOneParameter( $params, 'revid', 'rcid', 'logid' );
+               $result = $this->getResult();
+               $ret = array();
+               if ( $params['revid'] ) {
+                       foreach ( $params['revid'] as $id ) {
+                               $ret[] = $this->processIndividual( 'revid', $params, $id, $result );
+                       }
+               }
+               if ( $params['rcid'] ) {
+                       foreach ( $params['rcid'] as $id ) {
+                               $ret[] = $this->processIndividual( 'rcid', $params, $id, $result );
+                       }
+               }
+               if ( $params['logid'] ) {
+                       foreach ( $params['logid'] as $id ) {
+                               $ret[] = $this->processIndividual( 'logid', $params, $id, $result );
+                       }
+               }
+
+               $result->setIndexedTagName( $ret, 'result' );
+               $result->addValue( null, $this->getModuleName(), $ret );
+       }
+
+       protected static function validateLogId( $logid ) {
+               $dbr = wfGetDB( DB_SLAVE );
+               $result = $dbr->selectField( 'logging', 'log_id', array( 'log_id' => $logid ),
+                       __METHOD__ );
+               return (bool)$result;
+       }
+
+       protected function processIndividual( $type, $params, $id, &$result ) {
+               $idResult = array( $type => $id );
+
+               // validate the ID
+               $valid = false;
+               switch ( $type ) {
+                       case 'rcid':
+                               $valid = RecentChange::newFromId( $id );
+                               break;
+                       case 'revid':
+                               $valid = Revision::newFromId( $id );
+                               break;
+                       case 'logid':
+                               $valid = self::validateLogId( $id );
+                               break;
+               }
+
+               if ( !$valid ) {
+                       $idResult['status'] = 'error';
+                       $idResult += $this->parseMsg( array( "nosuch$type", $id ) );
+                       return $idResult;
+               }
+
+               $status = ChangeTags::updateTagsWithChecks( $params['add'],
+                       $params['remove'],
+                       ( $type === 'rcid' ? $id : null ),
+                       ( $type === 'revid' ? $id : null ),
+                       ( $type === 'logid' ? $id : null ),
+                       null,
+                       $params['reason'],
+                       $this->getUser() );
+
+               if ( !$status->isOK() ) {
+                       if ( $status->hasWarning( 'actionthrottledtext' ) ) {
+                               $idResult['status'] = 'skipped';
+                       } else {
+                               $idResult['status'] = 'failure';
+                               $ret['errors'] = $result->convertStatusToArray( $status, 'error' );
+                       }
+               } else {
+                       $idResult['status'] = 'success';
+                       if ( is_null( $status->value->logId ) ) {
+                               $idResult['noop'] = '';
+                       } else {
+                               $idResult['actionlogid'] = $status->value->logId;
+                               $idResult['added'] = $status->value->addedTags;
+                               $result->setIndexedTagName( $idResult['added'], 't' );
+                               $idResult['removed'] = $status->value->removedTags;
+                               $result->setIndexedTagName( $idResult['removed'], 't' );
+                       }
+               }
+               return $idResult;
+       }
+
+       public function mustBePosted() {
+               return true;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function getAllowedParams() {
+               return array(
+                       'rcid' => array(
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_ISMULTI => true,
+                       ),
+                       'revid' => array(
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_ISMULTI => true,
+                       ),
+                       'logid' => array(
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_ISMULTI => true,
+                       ),
+                       'add' => array(
+                               ApiBase::PARAM_TYPE => $this->getAvailableTags(),
+                               ApiBase::PARAM_ISMULTI => true,
+                       ),
+                       'remove' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_ISMULTI => true,
+                       ),
+                       'reason' => array(
+                               ApiBase::PARAM_DFLT => '',
+                       ),
+               );
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       protected function getExamplesMessages() {
+               return array(
+                       'action=tag&revid=123&add=vandalism&token=123ABC'
+                               => 'apihelp-tag-example-rev',
+                       'action=tag&logid=123&remove=spam&reason=Wrongly+applied&token=123ABC'
+                               => 'apihelp-tag-example-log',
+               );
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Tag';
+       }
+}
index d1d408f..4055386 100644 (file)
@@ -85,6 +85,7 @@
        "apihelp-edit-param-sectiontitle": "The title for a new section.",
        "apihelp-edit-param-text": "Page content.",
        "apihelp-edit-param-summary": "Edit summary. Also section title when $1section=new and $1sectiontitle is not set.",
+       "apihelp-edit-param-tags": "Change tags to apply to the revision.",
        "apihelp-edit-param-minor": "Minor edit.",
        "apihelp-edit-param-notminor": "Non-minor edit.",
        "apihelp-edit-param-bot": "Mark this edit as bot.",
        "apihelp-setnotificationtimestamp-example-pagetimestamp": "Set the notification timestamp for <kbd>Main page</kbd> so all edits since 1 January 2012 are unviewed.",
        "apihelp-setnotificationtimestamp-example-allpages": "Reset the notification status for pages in the <kbd>{{ns:user}}</kbd> namespace.",
 
+       "apihelp-tag-description": "Add or remove change tags from individual revisions or log entries.",
+       "apihelp-tag-param-rcid": "One or more recent changes IDs from which to add or remove the tag.",
+       "apihelp-tag-param-revid": "One or more revision IDs from which to add or remove the tag.",
+       "apihelp-tag-param-logid": "One or more log entry IDs from which to add or remove the tag.",
+       "apihelp-tag-param-add": "Tags to add. Only manually defined tags can be added.",
+       "apihelp-tag-param-remove": "Tags to remove. Only tags that are either manually defined or completely undefined can be removed.",
+       "apihelp-tag-param-reason": "Reason for the change.",
+       "apihelp-tag-example-rev": "Add the <kbd>vandalism</kbd> tag from revision ID 123 without specifying a reason",
+       "apihelp-tag-example-log": "Remove the <kbd>spam</kbd> tag from log entry ID 123 with the reason <kbd>Wrongly applied</kbd>",
+
        "apihelp-tokens-description": "Get tokens for data-modifying actions.\n\nThis module is deprecated in favor of [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
        "apihelp-tokens-param-type": "Types of token to request.",
        "apihelp-tokens-example-edit": "Retrieve an edit token (the default).",
index 5ef5b3d..2d09844 100644 (file)
@@ -81,6 +81,7 @@
        "apihelp-edit-param-sectiontitle": "{{doc-apihelp-param|edit|sectiontitle}}",
        "apihelp-edit-param-text": "{{doc-apihelp-param|edit|text}}",
        "apihelp-edit-param-summary": "{{doc-apihelp-param|edit|summary}}",
+       "apihelp-edit-param-tags": "{{doc-apihelp-param|edit|tags}}",
        "apihelp-edit-param-minor": "{{doc-apihelp-param|edit|minor}}\n{{Identical|Minor edit}}",
        "apihelp-edit-param-notminor": "{{doc-apihelp-param|edit|notminor}}",
        "apihelp-edit-param-bot": "{{doc-apihelp-param|edit|bot}}",
        "apihelp-setnotificationtimestamp-example-page": "{{doc-apihelp-example|setnotificationtimestamp}}",
        "apihelp-setnotificationtimestamp-example-pagetimestamp": "{{doc-apihelp-example|setnotificationtimestamp}}",
        "apihelp-setnotificationtimestamp-example-allpages": "{{doc-apihelp-example|setnotificationtimestamp}}",
+       "apihelp-tag-description": "{{doc-apihelp-description|tag}}",
+       "apihelp-tag-param-rcid": "{{doc-apihelp-param|tag|rcid}}",
+       "apihelp-tag-param-revid": "{{doc-apihelp-param|tag|revid}}",
+       "apihelp-tag-param-logid": "{{doc-apihelp-param|tag|logid}}",
+       "apihelp-tag-param-add": "{{doc-apihelp-param|tag|add}}",
+       "apihelp-tag-param-remove": "{{doc-apihelp-param|tag|remove}}",
+       "apihelp-tag-param-reason": "{{doc-apihelp-param|tag|reason}}",
+       "apihelp-tag-example-rev": "{{doc-apihelp-example|tag}}",
+       "apihelp-tag-example-log": "{{doc-apihelp-example|tag}}",
        "apihelp-tokens-description": "{{doc-apihelp-description|tokens}}",
        "apihelp-tokens-param-type": "{{doc-apihelp-param|tokens|type}}",
        "apihelp-tokens-example-edit": "{{doc-apihelp-example|tokens}}",
index cf9fb53..6571888 100644 (file)
@@ -540,8 +540,8 @@ class LogFormatter {
         *     * title-link: The value is a page title,
         *                   returns link to this page
         *     * number: Format value as number
-        * @param string $value The parameter value that should
-        *                      be formated
+        *     * list: Format value as a comma-separated list
+        * @param mixed $value The parameter value that should be formatted
         * @return string|array Formated value
         * @since 1.21
         */
@@ -552,6 +552,9 @@ class LogFormatter {
                        case 'raw':
                                $value = Message::rawParam( $value );
                                break;
+                       case 'list':
+                               $value = $this->context->getLanguage()->commaList( $value );
+                               break;
                        case 'msg':
                                $value = $this->msg( $value )->text();
                                break;
diff --git a/includes/logging/TagLogFormatter.php b/includes/logging/TagLogFormatter.php
new file mode 100644 (file)
index 0000000..5a58c33
--- /dev/null
@@ -0,0 +1,49 @@
+<?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
+ */
+
+/**
+ * This class formats tag log entries.
+ *
+ * Parameters (one-based indexes):
+ * 4::revid
+ * 5::logid
+ * 6:list:tagsAdded
+ * 7:number:tagsAddedCount
+ * 8:list:tagsRemoved
+ * 9:number:tagsRemovedCount
+ *
+ * @since 1.25
+ */
+class TagLogFormatter extends LogFormatter {
+       protected function getMessageKey() {
+               $key = parent::getMessageKey();
+               $params = $this->getMessageParameters();
+
+               $add = ( isset( $params[6] ) && isset( $params[6]['num'] ) && $params[6]['num'] );
+               $remove = ( isset( $params[8] ) && isset( $params[8]['num'] ) && $params[8]['num'] );
+               $key .= ( $remove ? ( $add ? '' : '-remove' ) : '-add' );
+
+               if ( isset( $params[4] ) && $params[4] ) {
+                       $key .= '-logentry';
+               } else {
+                       $key .= '-revision';
+               }
+
+               return $key;
+       }
+}
index bbd9455..b9d4dbc 100644 (file)
        "right-sendemail": "Send email to other users",
        "right-passwordreset": "View password reset emails",
        "right-managechangetags": "Create and delete [[Special:Tags|tags]] from the database",
+       "right-applychangetags": "Apply [[Special:Tags|tags]] along with one's changes",
+       "right-changetags": "Add and remove arbitrary [[Special:Tags|tags]] on individual revisions and log entries",
        "newuserlogpage": "User creation log",
        "newuserlogpagetext": "This is a log of user creations.",
        "rightslog": "User rights log",
        "action-editmyprivateinfo": "edit your private information",
        "action-editcontentmodel": "edit the content model of a page",
        "action-managechangetags": "create and delete tags from the database",
+       "action-applychangetags": "apply tags along with your changes",
+       "action-changetags": "add and remove arbitrary tags on individual revisions and log entries",
        "nchanges": "$1 {{PLURAL:$1|change|changes}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|since last visit}}",
        "enhancedrc-history": "history",
        "patrol-log-page": "Patrol log",
        "patrol-log-header": "This is a log of patrolled revisions.",
        "log-show-hide-patrol": "$1 patrol log",
+       "log-show-hide-tag": "$1 tag log",
        "deletedrevision": "Deleted old revision $1",
        "filedeleteerror-short": "Error deleting file: $1",
        "filedeleteerror-long": "Errors were encountered while deleting the file:\n\n$1",
        "tags-deactivate-reason": "Reason:",
        "tags-deactivate-not-allowed": "It is not possible to deactivate the tag \"$1\".",
        "tags-deactivate-submit": "Deactivate",
+       "tags-apply-no-permission": "You do not have permission to apply change tags along with your changes.",
+       "tags-apply-not-allowed-one": "The tag \"$1\" is not allowed to be manually applied.",
+       "tags-apply-not-allowed-multi": "The following {{PLURAL:$2|tag is|tags are}} not allowed to be manually applied: $1",
+       "tags-update-no-permission": "You do not have permission to add or remove change tags from individual revisions or log entries.",
+       "tags-update-add-not-allowed-one": "The tag \"$1\" is not allowed to be manually added.",
+       "tags-update-add-not-allowed-multi": "The following {{PLURAL:$2|tag is|tags are}} not allowed to be manually added: $1",
+       "tags-update-remove-not-allowed-one": "The tag \"$1\" is not allowed to be removed.",
+       "tags-update-remove-not-allowed-multi": "The following {{PLURAL:$2|tag is|tags are}} not allowed to be manually removed: $1",
        "comparepages": "Compare pages",
        "comparepages-summary": "",
        "compare-page1": "Page 1",
        "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",
+       "log-name-tag": "Tag log",
+       "log-description-tag": "This page shows when users have added or removed [[Special:Tags|tags]] from individual revisions or log entries. The log does not list tagging actions when they occur as part of an edit, deletion, or similar action.",
+       "logentry-tag-update-add-revision": "$1 {{GENDER:$2|added}} the {{PLURAL:$7|tag|tags}} $6 to revision $4 of page $3",
+       "logentry-tag-update-add-logentry": "$1 {{GENDER:$2|added}} the {{PLURAL:$7|tag|tags}} $6 to log entry $5 of page $3",
+       "logentry-tag-update-remove-revision": "$1 {{GENDER:$2|removed}} the {{PLURAL:$9|tag|tags}} $8 from revision $4 of page $3",
+       "logentry-tag-update-remove-logentry": "$1 {{GENDER:$2|removed}} the {{PLURAL:$9|tag|tags}} $8 from log entry $5 of page $3",
+       "logentry-tag-update-revision": "$1 {{GENDER:$2|updated}} tags on revision $4 of page $3 ({{PLURAL:$7|added}} $6; {{PLURAL:$9|removed}} $8)",
+       "logentry-tag-update-logentry": "$1 {{GENDER:$2|updated}} tags on log entry $5 of page $3 ({{PLURAL:$7|added}} $6; {{PLURAL:$9|removed}} $8)",
        "rightsnone": "(none)",
        "revdelete-logentry": "changed revision visibility of \"[[$1]]\"",
        "logdelete-logentry": "changed event visibility of \"[[$1]]\"",
index f9903bd..ba56da6 100644 (file)
        "right-sendemail": "{{doc-right|sendemail}}",
        "right-passwordreset": "{{doc-right|passwordreset}}",
        "right-managechangetags": "{{doc-right|managechangetags}}",
+       "right-applychangetags": "{{doc-right|applychangetags}}",
+       "right-changetags": "{{doc-right|changetags}}",
        "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-editmyprivateinfo": "{{doc-action|editmyprivateinfo}}",
        "action-editcontentmodel": "{{doc-action|editcontentmodel}}",
        "action-managechangetags": "{{doc-action|managechangetags}}",
+       "action-applychangetags": "{{doc-action|applychangetags}}",
+       "action-changetags": "{{doc-action|changetags}}",
        "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}}",
        "patrol-log-page": "{{doc-logpage}}",
        "patrol-log-header": "Text that appears above the log entries on the [[Special:log|patrol log]].",
        "log-show-hide-patrol": "Used in [[Special:Log]]. Parameters:\n* $1 - link text; one of {{msg-mw|Show}} or {{msg-mw|Hide}}\n{{Related|Log-show-hide}}",
+       "log-show-hide-tag": "Used in [[Special:Log]]. Parameters:\n* $1 - link text; one of {{msg-mw|Show}} or {{msg-mw|Hide}}\n{{Related|Log-show-hide}}",
        "deletedrevision": "Used as log comment. Parameters:\n* $1 - archive name of old image",
        "filedeleteerror-short": "Used as error message. Parameters:\n* $1 – There are two uses: 1) filename or 2) more specific error message like {{msg-mw|Backend-fail-internal}}.\nSee also:\n* {{msg-mw|Filedeleteerror-long}}",
        "filedeleteerror-long": "Used as error message. Parameters:\n* $1 - ...\nSee also:\n* {{msg-mw|Filedeleteerror-short}}",
        "tags-deactivate-reason": "{{Identical|Reason}}",
        "tags-deactivate-not-allowed": "Error message on [[Special:Tags]]",
        "tags-deactivate-submit": "The label of the form \"submit\" button when the user is about to deactivate a tag.\n{{Identical|Deactivate}}",
+       "tags-apply-no-permission": "Error message seen via the API when a user lacks the permission to apply change tags.",
+       "tags-apply-not-allowed-one": "Error message seen via the API when a user tries to apply a single tag that is not properly defined. This message is only ever used in the case of 1 tag.\n\nParameters:\n* $1 - tag name",
+       "tags-apply-not-allowed-multi": "Error message seen via the API when a user tries to apply more than one tag that is not properly defined.\n\nParameters:\n* $1 - comma-separated list of tag names\n* $2 - number of tags",
+       "tags-update-no-permission": "Error message seen via the API when a user lacks the permission to add or remove change tags after the fact.",
+       "tags-update-add-not-allowed-one": "Error message seen via the API when a user tries to add a single tag that is not properly defined. This message is only ever used in the case of 1 tag.\n\nParameters:\n* $1 - tag name",
+       "tags-update-add-not-allowed-multi": "Error message seen via the API when a user tries to add more than one tag that is not properly defined.\n\nParameters:\n* $1 - comma-separated list of tag names\n* $2 - number of tags",
+       "tags-update-remove-not-allowed-one": "Error message seen via the API when a user tries to remove a single tag that is not properly defined. This message is only ever used in the case of 1 tag.\n\nParameters:\n* $1 - tag name",
+       "tags-update-remove-not-allowed-multi": "Error message seen via the API when a user tries to remove more than one tag that is not properly defined.\n\nParameters:\n* $1 - comma-separated list of tag names\n* $2 - number of 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-name-managetags": "The title of a log which contains entries related to the management of change tags. This includes creation and deletion of the tags themselves. \"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",
+       "log-name-tag": "The title of a log which contains entries related to applying and removing change tags from individual revisions or log entries. \"Tag\" here refers to the same thing as {{msg-mw|tags-tag}}.",
+       "log-description-tag": "The description of the tag log. \"Tag\" here refers to the same thing as {{msg-mw|tags-tag}}.",
+       "logentry-tag-update-add-revision": "{{Logentry|[[Special:Log/tag]]}}\n*$4 - revision ID\n* $6 - list of tags that were added, separated by {{msg-mw|Comma-separator}}\n* $7 - number of added tags",
+       "logentry-tag-update-add-logentry": "{{Logentry|[[Special:Log/tag]]}}\n*$5 - log entry ID\n* $6 - list of tags that were added, separated by {{msg-mw|Comma-separator}}\n* $7 - number of added tags",
+       "logentry-tag-update-remove-revision": "{{Logentry|[[Special:Log/tag]]}}\n*$4 - revision ID\n* $8 - list of tags that were removed, separated by {{msg-mw|Comma-separator}}\n* $9 - number of removed tags",
+       "logentry-tag-update-remove-logentry": "{{Logentry|[[Special:Log/tag]]}}\n*$5 - log entry ID\n* $8 - list of tags that were removed, separated by {{msg-mw|Comma-separator}}\n* $9 - number of removed tags",
+       "logentry-tag-update-revision": "{{Logentry|[[Special:Log/tag]]}}\n*$4 - revision ID\n* $6 - list of tags that were added, separated by {{msg-mw|Comma-separator}}\n* $7 - number of added tags\n* $8 - list of tags that were removed, separated by {{msg-mw|Comma-separator}}\n* $9 - number of removed tags",
+       "logentry-tag-update-logentry": "{{Logentry|[[Special:Log/tag]]}}\n*$5 - log entry ID\n* $6 - list of tags that were added, separated by {{msg-mw|Comma-separator}}\n* $7 - number of added tags\n* $8 - list of tags that were removed, separated by {{msg-mw|Comma-separator}}\n* $9 - number of removed tags",
        "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}}",