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

Change subject: Allow users to add, remove and apply change tags using the API
......................................................................


Allow users to add, remove and apply change tags using the API

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
(cherry picked from commit ae3ab9eef0379e3e0a6cd9408f153648297e0853)
---
M autoload.php
M includes/ChangeTags.php
M includes/DefaultSettings.php
M includes/EditPage.php
M includes/User.php
M includes/api/ApiBase.php
M includes/api/ApiEditPage.php
M includes/api/ApiMain.php
A includes/api/ApiTag.php
M includes/api/i18n/en.json
M includes/api/i18n/qqq.json
M includes/logging/LogFormatter.php
A includes/logging/TagLogFormatter.php
M languages/i18n/en.json
M languages/i18n/qqq.json
15 files changed, 748 insertions(+), 74 deletions(-)

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



diff --git a/autoload.php b/autoload.php
index b480096..2fe805f 100644
--- a/autoload.php
+++ b/autoload.php
@@ -124,6 +124,7 @@
        '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 @@
        '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',
diff --git a/includes/ChangeTags.php b/includes/ChangeTags.php
index 52c665c..5506cb4 100644
--- a/includes/ChangeTags.php
+++ b/includes/ChangeTags.php
@@ -91,21 +91,50 @@
         *
         * @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 @@
                        );
                }
 
+               // 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,40 +259,274 @@
                $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,
+               ) );
        }
 
        /**
@@ -344,8 +681,8 @@
         * 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 @@
                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 @@
                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 @@
                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 @@
                        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 @@
                }
 
                // 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;
        }
diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php
index 01cae72..90f2ba9 100644
--- a/includes/DefaultSettings.php
+++ b/includes/DefaultSettings.php
@@ -4563,6 +4563,8 @@
 $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;
@@ -5043,7 +5045,11 @@
                'newbie' => null,
                'ip' => null,
                'subnet' => null,
-       )
+       ),
+       'changetag' => array( // adding or removing change tags
+               'user' => null,
+               'newbie' => null,
+       ),
 );
 
 /**
@@ -6573,6 +6579,7 @@
        'patrol',
        'merge',
        'suppress',
+       'tag',
        'managetags',
 );
 
@@ -6610,7 +6617,8 @@
  * for the link text.
  */
 $wgFilterLogTypes = array(
-       'patrol' => true
+       'patrol' => true,
+       'tag' => true,
 );
 
 /**
@@ -6695,6 +6703,7 @@
        'upload/overwrite' => 'LogFormatter',
        'upload/revert' => 'LogFormatter',
        'merge/merge' => 'MergeLogFormatter',
+       'tag/update' => 'TagLogFormatter',
        'managetags/create' => 'LogFormatter',
        'managetags/delete' => 'LogFormatter',
        'managetags/activate' => 'LogFormatter',
diff --git a/includes/EditPage.php b/includes/EditPage.php
index e113426..8d27eac 100644
--- a/includes/EditPage.php
+++ b/includes/EditPage.php
@@ -157,6 +157,12 @@
        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
         */
        const AS_PARSE_ERROR = 240;
@@ -350,6 +356,9 @@
 
        /** @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 @@
 
                        $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 @@
                        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 @@
                        $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;
        }
 
diff --git a/includes/User.php b/includes/User.php
index f23d7dd..3cd69fd 100644
--- a/includes/User.php
+++ b/includes/User.php
@@ -102,6 +102,7 @@
         */
        protected static $mCoreRights = array(
                'apihighlimits',
+               'applychangetags',
                'autoconfirmed',
                'autopatrol',
                'bigdelete',
@@ -109,6 +110,7 @@
                'blockemail',
                'bot',
                'browsearchive',
+               'changetags',
                'createaccount',
                'createpage',
                'createtalk',
diff --git a/includes/api/ApiBase.php b/includes/api/ApiBase.php
index 74e51c8..0023819 100644
--- a/includes/api/ApiBase.php
+++ b/includes/api/ApiBase.php
@@ -1663,6 +1663,10 @@
                        '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\""
diff --git a/includes/api/ApiEditPage.php b/includes/api/ApiEditPage.php
index ef8957e..8c7d31d 100644
--- a/includes/api/ApiEditPage.php
+++ b/includes/api/ApiEditPage.php
@@ -331,6 +331,15 @@
                        $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 @@
                        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 @@
                        ),
                        'text' => null,
                        'summary' => null,
+                       'tags' => array(
+                               ApiBase::PARAM_TYPE => 
ChangeTags::listExplicitlyDefinedTags(),
+                               ApiBase::PARAM_ISMULTI => true,
+                       ),
                        'minor' => false,
                        'notminor' => false,
                        'bot' => false,
diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php
index 2978453..ee1cfa6 100644
--- a/includes/api/ApiMain.php
+++ b/includes/api/ApiMain.php
@@ -89,6 +89,7 @@
                'imagerotate' => 'ApiImageRotate',
                'revisiondelete' => 'ApiRevisionDelete',
                'managetags' => 'ApiManageTags',
+               'tag' => 'ApiTag',
        );
 
        /**
diff --git a/includes/api/ApiTag.php b/includes/api/ApiTag.php
new file mode 100644
index 0000000..fcf0ac1
--- /dev/null
+++ b/includes/api/ApiTag.php
@@ -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';
+       }
+}
diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json
index 9d0663c..a5269cd 100644
--- a/includes/api/i18n/en.json
+++ b/includes/api/i18n/en.json
@@ -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.",
@@ -1010,6 +1011,16 @@
        "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).",
diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json
index 05b7722..9d05f30 100644
--- a/includes/api/i18n/qqq.json
+++ b/includes/api/i18n/qqq.json
@@ -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}}",
@@ -922,6 +923,15 @@
        "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}}",
diff --git a/includes/logging/LogFormatter.php 
b/includes/logging/LogFormatter.php
index cf9fb53..6571888 100644
--- a/includes/logging/LogFormatter.php
+++ b/includes/logging/LogFormatter.php
@@ -540,8 +540,8 @@
         *     * 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 @@
                        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
index 0000000..5a58c33
--- /dev/null
+++ b/includes/logging/TagLogFormatter.php
@@ -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;
+       }
+}
diff --git a/languages/i18n/en.json b/languages/i18n/en.json
index 1190c40..003ee91 100644
--- a/languages/i18n/en.json
+++ b/languages/i18n/en.json
@@ -1154,6 +1154,8 @@
        "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",
@@ -1201,6 +1203,8 @@
        "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",
@@ -2594,6 +2598,7 @@
        "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",
@@ -3418,6 +3423,14 @@
        "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",
@@ -3503,6 +3516,14 @@
        "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]]\"",
diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json
index f0ba941..39dc6ca 100644
--- a/languages/i18n/qqq.json
+++ b/languages/i18n/qqq.json
@@ -1320,6 +1320,8 @@
        "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]]",
@@ -1367,6 +1369,8 @@
        "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}}",
@@ -2760,6 +2764,7 @@
        "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}}",
@@ -3584,6 +3589,14 @@
        "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}}",
@@ -3663,12 +3676,20 @@
        "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}}",

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

Gerrit-MessageType: merged
Gerrit-Change-Id: I37275e0f73fa3127f55da0c320b892551b61ee80
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/core
Gerrit-Branch: REL1_25
Gerrit-Owner: Anomie <bjor...@wikimedia.org>
Gerrit-Reviewer: Chad <ch...@wikimedia.org>
Gerrit-Reviewer: Jackmcbarn <jackmcb...@gmail.com>
Gerrit-Reviewer: Siebrand <siebr...@kitano.nl>
Gerrit-Reviewer: TTO <at.li...@live.com.au>
Gerrit-Reviewer: Tpt <thoma...@hotmail.fr>
Gerrit-Reviewer: jenkins-bot <>

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

Reply via email to