From: This, that and the other Date: Wed, 15 Apr 2015 01:33:08 +0000 (+1000) Subject: Allow users to add, remove and apply change tags using the API X-Git-Tag: 1.31.0-rc.0~11705 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=ae3ab9eef0379e3e0a6cd9408f153648297e0853 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 --- diff --git a/autoload.php b/autoload.php index b4800960a8..2fe805f287 100644 --- a/autoload.php +++ b/autoload.php @@ -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', diff --git a/includes/ChangeTags.php b/includes/ChangeTags.php index 754c0f810f..a097cd6fab 100644 --- a/includes/ChangeTags.php +++ b/includes/ChangeTags.php @@ -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; } diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index dc16ae3eaa..5c0bcff227 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -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', diff --git a/includes/EditPage.php b/includes/EditPage.php index e11342603b..8d27eac833 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -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; } diff --git a/includes/User.php b/includes/User.php index 9168c33710..3c2939fb52 100644 --- a/includes/User.php +++ b/includes/User.php @@ -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', diff --git a/includes/api/ApiBase.php b/includes/api/ApiBase.php index 6e289dcc4f..f4f2c8c1e3 100644 --- a/includes/api/ApiBase.php +++ b/includes/api/ApiBase.php @@ -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\"" diff --git a/includes/api/ApiEditPage.php b/includes/api/ApiEditPage.php index ef8957eee5..8c7d31d4bd 100644 --- a/includes/api/ApiEditPage.php +++ b/includes/api/ApiEditPage.php @@ -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, diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index 297845361d..ee1cfa6945 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -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 index 0000000000..fcf0ac1b41 --- /dev/null +++ b/includes/api/ApiTag.php @@ -0,0 +1,178 @@ +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 d1d408fbf4..40553864d7 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 Main page so all edits since 1 January 2012 are unviewed.", "apihelp-setnotificationtimestamp-example-allpages": "Reset the notification status for pages in the {{ns:user}} 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 vandalism tag from revision ID 123 without specifying a reason", + "apihelp-tag-example-log": "Remove the spam tag from log entry ID 123 with the reason Wrongly applied", + "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 5ef5b3dac3..2d0984420d 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 cf9fb530d6..6571888cfe 100644 --- a/includes/logging/LogFormatter.php +++ b/includes/logging/LogFormatter.php @@ -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 index 0000000000..5a58c3317e --- /dev/null +++ b/includes/logging/TagLogFormatter.php @@ -0,0 +1,49 @@ +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 bbd9455d36..b9d4dbc518 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", @@ -2595,6 +2599,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", @@ -3419,6 +3424,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", @@ -3504,6 +3517,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 f9903bd452..ba56da640b 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1322,6 +1322,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]]", @@ -1369,6 +1371,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}}", @@ -2763,6 +2767,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}}", @@ -3587,6 +3592,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}}", @@ -3666,12 +3679,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}}",