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