Anomie has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/204342

Change subject: UI for adding and removing change tags on revisions and log 
entries
......................................................................

UI for adding and removing change tags on revisions and log entries

There is a new special page, Special:EditTags, which is very similar to
Special:RevisionDelete in a lot of ways. In fact, the SpecialEditTags class
started off as a copy-paste of SpecialRevisiondelete.

You invoke this special page by going to an article history page, checking
some revisions, and clicking "Edit tags of selected revisions". Then you
pick the modifications you want to make and click "Apply". Very much like
the revision deletion workflow.

I had to restructure some of the Action routing code, which was only
designed to handle revision deletion. Also removing some code from
SpecialRevisiondelete which didn't work as advertised in the first place,
and definitely doesn't work now.

Change-Id: I7d3ef927b5686f6211bc5817776286ead19d916b
(cherry picked from commit 5c4681012e78a8d5004eea917eba90d448d7e0f3)
---
M autoload.php
M includes/ChangeTags.php
M includes/DefaultSettings.php
M includes/RevisionList.php
M includes/actions/Action.php
M includes/actions/HistoryAction.php
M includes/actions/RevisiondeleteAction.php
A includes/actions/SpecialPageAction.php
A includes/changetags/ChangeTagsList.php
A includes/changetags/ChangeTagsLogItem.php
A includes/changetags/ChangeTagsLogList.php
A includes/changetags/ChangeTagsRevisionItem.php
A includes/changetags/ChangeTagsRevisionList.php
M includes/specialpage/SpecialPageFactory.php
A includes/specials/SpecialEditTags.php
M includes/specials/SpecialLog.php
M includes/specials/SpecialRevisiondelete.php
M languages/i18n/en.json
M languages/i18n/qqq.json
M resources/Resources.php
M resources/src/mediawiki.action/mediawiki.action.history.js
M resources/src/mediawiki.legacy/shared.css
A resources/src/mediawiki.special/mediawiki.special.edittags.css
A resources/src/mediawiki.special/mediawiki.special.edittags.js
M tests/phpunit/includes/actions/ActionTest.php
25 files changed, 1,165 insertions(+), 49 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core 
refs/changes/42/204342/1

diff --git a/autoload.php b/autoload.php
index 2fe805f..92d6014 100644
--- a/autoload.php
+++ b/autoload.php
@@ -200,6 +200,11 @@
        'CgzCopyTransaction' => __DIR__ . 
'/maintenance/storage/recompressTracked.php',
        'ChangePassword' => __DIR__ . '/maintenance/changePassword.php',
        'ChangeTags' => __DIR__ . '/includes/ChangeTags.php',
+       'ChangeTagsList' => __DIR__ . '/includes/changetags/ChangeTagsList.php',
+       'ChangeTagsLogItem' => __DIR__ . 
'/includes/changetags/ChangeTagsLogItem.php',
+       'ChangeTagsLogList' => __DIR__ . 
'/includes/changetags/ChangeTagsLogList.php',
+       'ChangeTagsRevisionItem' => __DIR__ . 
'/includes/changetags/ChangeTagsRevisionItem.php',
+       'ChangeTagsRevisionList' => __DIR__ . 
'/includes/changetags/ChangeTagsRevisionList.php',
        'ChangesFeed' => __DIR__ . '/includes/changes/ChangesFeed.php',
        'ChangesList' => __DIR__ . '/includes/changes/ChangesList.php',
        'ChangesListSpecialPage' => __DIR__ . 
'/includes/specialpage/ChangesListSpecialPage.php',
@@ -1103,6 +1108,7 @@
        'SpecialContributions' => __DIR__ . 
'/includes/specials/SpecialContributions.php',
        'SpecialCreateAccount' => __DIR__ . 
'/includes/specials/SpecialCreateAccount.php',
        'SpecialDiff' => __DIR__ . '/includes/specials/SpecialDiff.php',
+       'SpecialEditTags' => __DIR__ . '/includes/specials/SpecialEditTags.php',
        'SpecialEditWatchlist' => __DIR__ . 
'/includes/specials/SpecialEditWatchlist.php',
        'SpecialEmailUser' => __DIR__ . 
'/includes/specials/SpecialEmailuser.php',
        'SpecialExpandTemplates' => __DIR__ . 
'/includes/specials/SpecialExpandTemplates.php',
@@ -1126,6 +1132,7 @@
        'SpecialNewFiles' => __DIR__ . 
'/includes/specials/SpecialNewimages.php',
        'SpecialNewpages' => __DIR__ . '/includes/specials/SpecialNewpages.php',
        'SpecialPage' => __DIR__ . '/includes/specialpage/SpecialPage.php',
+       'SpecialPageAction' => __DIR__ . 
'/includes/actions/SpecialPageAction.php',
        'SpecialPageFactory' => __DIR__ . 
'/includes/specialpage/SpecialPageFactory.php',
        'SpecialPageLanguage' => __DIR__ . 
'/includes/specials/SpecialPageLanguage.php',
        'SpecialPagesWithProp' => __DIR__ . 
'/includes/specials/SpecialPagesWithProp.php',
diff --git a/includes/ChangeTags.php b/includes/ChangeTags.php
index 5506cb4..71771ba 100644
--- a/includes/ChangeTags.php
+++ b/includes/ChangeTags.php
@@ -18,6 +18,7 @@
  * http://www.gnu.org/copyleft/gpl.html
  *
  * @file
+ * @ingroup Change tagging
  */
 
 class ChangeTags {
diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php
index 90f2ba9..11096be 100644
--- a/includes/DefaultSettings.php
+++ b/includes/DefaultSettings.php
@@ -6781,6 +6781,7 @@
        'credits' => true,
        'delete' => true,
        'edit' => true,
+       'editchangetags' => 'SpecialPageAction',
        'history' => true,
        'info' => true,
        'markpatrolled' => true,
@@ -6789,7 +6790,7 @@
        'raw' => true,
        'render' => true,
        'revert' => true,
-       'revisiondelete' => true,
+       'revisiondelete' => 'SpecialPageAction',
        'rollback' => true,
        'submit' => true,
        'unprotect' => true,
diff --git a/includes/RevisionList.php b/includes/RevisionList.php
index d10b541..6844dad 100644
--- a/includes/RevisionList.php
+++ b/includes/RevisionList.php
@@ -317,7 +317,7 @@
        }
 
        public function getAuthorNameField() {
-               return 'user_name'; // see Revision::selectUserFields()
+               return 'rev_user_text';
        }
 
        public function canView() {
@@ -334,15 +334,18 @@
 
        /**
         * Get the HTML link to the revision text.
-        * Overridden by RevDelArchiveItem.
+        * @todo Essentially a copy of RevDelRevisionItem::getRevisionLink. 
That class
+        * should inherit from this one, and implement an appropriate interface 
instead
+        * of extending RevDelItem
         * @return string
         */
        protected function getRevisionLink() {
                $date = $this->list->getLanguage()->timeanddate( 
$this->revision->getTimestamp(), true );
+
                if ( $this->isDeleted() && !$this->canViewContent() ) {
                        return $date;
                }
-               return Linker::link(
+               return Linker::linkKnown(
                        $this->list->title,
                        $date,
                        array(),
@@ -355,30 +358,34 @@
 
        /**
         * Get the HTML link to the diff.
-        * Overridden by RevDelArchiveItem
+        * @todo Essentially a copy of RevDelRevisionItem::getDiffLink. That 
class
+        * should inherit from this one, and implement an appropriate interface 
instead
+        * of extending RevDelItem
         * @return string
         */
        protected function getDiffLink() {
                if ( $this->isDeleted() && !$this->canViewContent() ) {
                        return $this->context->msg( 'diff' )->escaped();
                } else {
-                       return Linker::link(
+                       return Linker::linkKnown(
                                        $this->list->title,
-                                       $this->context->msg( 'diff' 
)->escaped(),
+                                       $this->list->msg( 'diff' )->escaped(),
                                        array(),
                                        array(
                                                'diff' => 
$this->revision->getId(),
                                                'oldid' => 'prev',
                                                'unhide' => 1
-                                       ),
-                                       array(
-                                               'known',
-                                               'noclasses'
                                        )
                                );
                }
        }
 
+       /**
+        * @todo Essentially a copy of RevDelRevisionItem::getHTML. That class
+        * should inherit from this one, and implement an appropriate interface 
instead
+        * of extending RevDelItem
+        * @return string
+        */
        public function getHTML() {
                $difflink = $this->context->msg( 'parentheses' )
                        ->rawParams( $this->getDiffLink() )->escaped();
diff --git a/includes/actions/Action.php b/includes/actions/Action.php
index 8d11d90..aca4363 100644
--- a/includes/actions/Action.php
+++ b/includes/actions/Action.php
@@ -132,6 +132,8 @@
                if ( $actionName === 'historysubmit' ) {
                        if ( $request->getBool( 'revisiondelete' ) ) {
                                $actionName = 'revisiondelete';
+                       } elseif ( $request->getBool( 'editchangetags' ) ) {
+                               $actionName = 'editchangetags';
                        } else {
                                $actionName = 'view';
                        }
diff --git a/includes/actions/HistoryAction.php 
b/includes/actions/HistoryAction.php
index 7189372..f38bc50 100644
--- a/includes/actions/HistoryAction.php
+++ b/includes/actions/HistoryAction.php
@@ -479,6 +479,7 @@
                        'id' => 'mw-history-compare' ) ) . "\n";
                $s .= Html::hidden( 'title', 
$this->getTitle()->getPrefixedDBkey() ) . "\n";
                $s .= Html::hidden( 'action', 'historysubmit' ) . "\n";
+               $s .= Html::hidden( 'type', 'revision' ) . "\n";
 
                // Button container stored in $this->buttons for re-use in 
getEndBody()
                $this->buttons = '<div>';
@@ -489,8 +490,17 @@
                        $attrs
                ) . "\n";
 
-               if ( $this->getUser()->isAllowed( 'deleterevision' ) ) {
-                       $this->buttons .= $this->getRevisionButton( 
'revisiondelete', 'showhideselectedversions' );
+               $user = $this->getUser();
+               $actionButtons = '';
+               if ( $user->isAllowed( 'deleterevision' ) ) {
+                       $actionButtons .= $this->getRevisionButton( 
'revisiondelete', 'showhideselectedversions' );
+               }
+               if ( $user->isAllowed( 'changetags' ) ) {
+                       $actionButtons .= $this->getRevisionButton( 
'editchangetags', 'history-edit-tags' );
+               }
+               if ( $actionButtons ) {
+                       $this->buttons .= Xml::tags( 'div', array( 'class' =>
+                               'mw-history-revisionactions' ), $actionButtons 
);
                }
                $this->buttons .= '</div>';
 
@@ -606,11 +616,15 @@
 
                $del = '';
                $user = $this->getUser();
-               // Show checkboxes for each revision
-               if ( $user->isAllowed( 'deleterevision' ) ) {
+               $canRevDelete = $user->isAllowed( 'deleterevision' );
+               $canModifyTags = $user->isAllowed( 'changetags' );
+               // Show checkboxes for each revision, to allow for revision 
deletion and
+               // change tags
+               if ( $canRevDelete || $canModifyTags ) {
                        $this->preventClickjacking();
-                       // If revision was hidden from sysops, disable the 
checkbox
-                       if ( !$rev->userCan( Revision::DELETED_RESTRICTED, 
$user ) ) {
+                       // If revision was hidden from sysops and we don't need 
the checkbox
+                       // for anything else, disable it
+                       if ( !$canModifyTags && !$rev->userCan( 
Revision::DELETED_RESTRICTED, $user ) ) {
                                $del = Xml::check( 'deleterevisions', false, 
array( 'disabled' => 'disabled' ) );
                        // Otherwise, enable the checkbox...
                        } else {
diff --git a/includes/actions/RevisiondeleteAction.php 
b/includes/actions/RevisiondeleteAction.php
index b6eeb7b..dbcb848 100644
--- a/includes/actions/RevisiondeleteAction.php
+++ b/includes/actions/RevisiondeleteAction.php
@@ -27,8 +27,14 @@
  * An action that just pass the request to Special:RevisionDelete
  *
  * @ingroup Actions
+ * @deprecated since 1.25 This class has been replaced by SpecialPageAction, 
but
+ * you really shouldn't have been using it outside core in the first place
  */
 class RevisiondeleteAction extends FormlessAction {
+       public function __construct( Page $page, IContextSource $context = null 
) {
+               wfDeprecated( 'RevisiondeleteAction class', '1.25' );
+               parent::__construct( $page, $context );
+       }
 
        public function getName() {
                return 'revisiondelete';
diff --git a/includes/actions/SpecialPageAction.php 
b/includes/actions/SpecialPageAction.php
new file mode 100644
index 0000000..9b72163
--- /dev/null
+++ b/includes/actions/SpecialPageAction.php
@@ -0,0 +1,79 @@
+<?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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * An action that just passes the request to the relevant special page
+ *
+ * @ingroup Actions
+ * @since 1.25
+ */
+class SpecialPageAction extends FormlessAction {
+
+       /**
+        * @var array A mapping of action names to special page names.
+        */
+       public static $actionToSpecialPageMapping = array(
+               'revisiondelete' => 'Revisiondelete',
+               'editchangetags' => 'EditTags',
+       );
+
+       public function getName() {
+               $request = $this->getRequest();
+               $actionName = $request->getVal( 'action', 'view' );
+               // TODO: Shouldn't need to copy-paste this code from 
Action::getActionName!
+               if ( $actionName === 'historysubmit' ) {
+                       if ( $request->getBool( 'revisiondelete' ) ) {
+                               $actionName = 'revisiondelete';
+                       } elseif ( $request->getBool( 'editchangetags' ) ) {
+                               $actionName = 'editchangetags';
+                       }
+               }
+
+               if ( isset( self::$actionToSpecialPageMapping[$actionName] ) ) {
+                       return $actionName;
+               }
+               return 'nosuchaction';
+       }
+
+       public function requiresUnblock() {
+               return false;
+       }
+
+       public function getDescription() {
+               return '';
+       }
+
+       public function onView() {
+               return '';
+       }
+
+       public function show() {
+               $action = self::getName();
+               if ( $action === 'nosuchaction' ) {
+                       throw new ErrorPageError( $this->msg( 'nosuchaction' ), 
$this->msg( 'nosuchactiontext' ) );
+               }
+
+               // map actions to (whitelisted) special pages
+               $special = SpecialPageFactory::getPage( 
self::$actionToSpecialPageMapping[$action] );
+               $special->setContext( $this->getContext() );
+               $special->getContext()->setTitle( $special->getPageTitle() );
+               $special->run( '' );
+       }
+}
diff --git a/includes/changetags/ChangeTagsList.php 
b/includes/changetags/ChangeTagsList.php
new file mode 100644
index 0000000..dd8bab9
--- /dev/null
+++ b/includes/changetags/ChangeTagsList.php
@@ -0,0 +1,77 @@
+<?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 Change tagging
+ */
+
+/**
+ * Generic list for change tagging.
+ */
+abstract class ChangeTagsList extends RevisionListBase {
+       function __construct( IContextSource $context, Title $title, array $ids 
) {
+               parent::__construct( $context, $title );
+               $this->ids = $ids;
+       }
+
+       /**
+        * Creates a ChangeTags*List of the requested type.
+        *
+        * @param string $typeName 'revision' or 'logentry'
+        * @param IContextSource $context
+        * @param Title $title
+        * @param array $ids
+        * @return ChangeTagsList An instance of the requested subclass
+        * @throws Exception If you give an unknown $typeName
+        */
+       public static function factory( $typeName, IContextSource $context,
+               Title $title, array $ids ) {
+
+               switch ( $typeName ) {
+                       case 'revision':
+                               $className = 'ChangeTagsRevisionList';
+                               break;
+                       case 'logentry':
+                               $className = 'ChangeTagsLogList';
+                               break;
+                       default:
+                               throw new Exception( "Class $className 
requested, but does not exist" );
+               }
+               return new $className( $context, $title, $ids );
+       }
+
+       /**
+        * Reload the list data from the master DB.
+        */
+       function reloadFromMaster() {
+               $dbw = wfGetDB( DB_MASTER );
+               $this->res = $this->doQuery( $dbw );
+       }
+
+       /**
+        * Add/remove change tags from all the items in the list.
+        *
+        * @param array $tagsToAdd
+        * @param array $tagsToRemove
+        * @param array $params
+        * @param string $reason
+        * @param User $user
+        * @return Status
+        */
+       abstract function updateChangeTagsOnAll( $tagsToAdd, $tagsToRemove, 
$params,
+               $reason, $user );
+}
diff --git a/includes/changetags/ChangeTagsLogItem.php 
b/includes/changetags/ChangeTagsLogItem.php
new file mode 100644
index 0000000..565d159
--- /dev/null
+++ b/includes/changetags/ChangeTagsLogItem.php
@@ -0,0 +1,100 @@
+<?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 Change tagging
+ */
+
+/**
+ * Item class for a logging table row with its associated change tags.
+ * @todo Abstract out a base class for this and RevDelLogItem, similar to the
+ * RevisionItem class but specifically for log items.
+ * @since 1.25
+ */
+class ChangeTagsLogItem extends RevisionItemBase {
+       public function getIdField() {
+               return 'log_id';
+       }
+
+       public function getTimestampField() {
+               return 'log_timestamp';
+       }
+
+       public function getAuthorIdField() {
+               return 'log_user';
+       }
+
+       public function getAuthorNameField() {
+               return 'log_user_text';
+       }
+
+       public function canView() {
+               return LogEventsList::userCan( $this->row, 
Revision::DELETED_RESTRICTED, $this->list->getUser() );
+       }
+
+       public function canViewContent() {
+               return true; // none
+       }
+
+       /**
+        * @return string Comma-separated list of tags
+        */
+       public function getTags() {
+               return $this->row->ts_tags;
+       }
+
+       /**
+        * @return string A HTML <li> element representing this revision, 
showing
+        * change tags and everything
+        */
+       public function getHTML() {
+               $date = htmlspecialchars( 
$this->list->getLanguage()->userTimeAndDate(
+                       $this->row->log_timestamp, $this->list->getUser() ) );
+               $title = Title::makeTitle( $this->row->log_namespace, 
$this->row->log_title );
+               $formatter = LogFormatter::newFromRow( $this->row );
+               $formatter->setContext( $this->list->getContext() );
+               $formatter->setAudience( LogFormatter::FOR_THIS_USER );
+
+               // Log link for this page
+               $loglink = Linker::link(
+                       SpecialPage::getTitleFor( 'Log' ),
+                       $this->list->msg( 'log' )->escaped(),
+                       array(),
+                       array( 'page' => $title->getPrefixedText() )
+               );
+               $loglink = $this->list->msg( 'parentheses' )->rawParams( 
$loglink )->escaped();
+               // User links and action text
+               $action = $formatter->getActionText();
+               // Comment
+               $comment = $this->list->getLanguage()->getDirMark() .
+                       $formatter->getComment();
+
+               if ( LogEventsList::isDeleted( $this->row, 
LogPage::DELETED_COMMENT ) ) {
+                       $comment = '<span class="history-deleted">' . $comment 
. '</span>';
+               }
+
+               $content = "$loglink $date $action $comment";
+               $attribs = array();
+               $tags = $this->getTags();
+               if ( $tags ) {
+                       list( $tagSummary, $classes ) = 
ChangeTags::formatSummaryRow( $tags, 'edittags' );
+                       $content .= " $tagSummary";
+                       $attribs['class'] = implode( ' ', $classes );
+               }
+               return Xml::tags( 'li', $attribs, $content );
+       }
+}
diff --git a/includes/changetags/ChangeTagsLogList.php 
b/includes/changetags/ChangeTagsLogList.php
new file mode 100644
index 0000000..fe80695
--- /dev/null
+++ b/includes/changetags/ChangeTagsLogList.php
@@ -0,0 +1,89 @@
+<?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 Change tagging
+ */
+
+/**
+ * Stores a list of taggable log entries.
+ * @since 1.25
+ */
+class ChangeTagsLogList extends ChangeTagsList {
+       public function getType() {
+               return 'logentry';
+       }
+
+       /**
+        * @param DatabaseBase $db
+        * @return mixed
+        */
+       public function doQuery( $db ) {
+               $ids = array_map( 'intval', $this->ids );
+               $queryInfo = DatabaseLogEntry::getSelectQueryData();
+               $queryInfo['conds'] += array( 'log_id' => $ids );
+               $queryInfo['options'] += array( 'ORDER BY' => 'log_id DESC' );
+               ChangeTags::modifyDisplayQuery(
+                       $queryInfo['tables'],
+                       $queryInfo['fields'],
+                       $queryInfo['conds'],
+                       $queryInfo['join_conds'],
+                       $queryInfo['options']
+               );
+               return $db->select(
+                       $queryInfo['tables'],
+                       $queryInfo['fields'],
+                       $queryInfo['conds'],
+                       __METHOD__,
+                       $queryInfo['options'],
+                       $queryInfo['join_conds']
+               );
+       }
+
+       public function newItem( $row ) {
+               return new ChangeTagsLogItem( $this, $row );
+       }
+
+       /**
+        * Add/remove change tags from all the log entries in the list.
+        *
+        * @param array $tagsToAdd
+        * @param array $tagsToRemove
+        * @param array $params
+        * @param string $reason
+        * @param User $user
+        * @return Status
+        */
+       public function updateChangeTagsOnAll( $tagsToAdd, $tagsToRemove, 
$params,
+               $reason, $user ) {
+
+               // @codingStandardsIgnoreStart 
Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+               for ( $this->reset(); $this->current(); $this->next() ) {
+                       // @codingStandardsIgnoreEnd
+                       $item = $this->current();
+                       $status = ChangeTags::updateTagsWithChecks( $tagsToAdd, 
$tagsToRemove,
+                               null, null, $item->getId(), $params, $reason, 
$user );
+                       // Should only fail on second and subsequent times if 
the user trips
+                       // the rate limiter
+                       if ( !$status->isOK() ) {
+                               break;
+                       }
+               }
+
+               return $status;
+       }
+}
diff --git a/includes/changetags/ChangeTagsRevisionItem.php 
b/includes/changetags/ChangeTagsRevisionItem.php
new file mode 100644
index 0000000..e90a1b4
--- /dev/null
+++ b/includes/changetags/ChangeTagsRevisionItem.php
@@ -0,0 +1,58 @@
+<?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 Change tagging
+ */
+
+/**
+ * Item class for a live revision table row with its associated change tags.
+ * @since 1.25
+ */
+class ChangeTagsRevisionItem extends RevisionItem {
+       /**
+        * @return string Comma-separated list of tags
+        */
+       public function getTags() {
+               return $this->row->ts_tags;
+       }
+
+       /**
+        * @return string A HTML <li> element representing this revision, 
showing
+        * change tags and everything
+        */
+       public function getHTML() {
+               $difflink = $this->list->msg( 'parentheses' )
+                       ->rawParams( $this->getDiffLink() )->escaped();
+               $revlink = $this->getRevisionLink();
+               $userlink = Linker::revUserLink( $this->revision );
+               $comment = Linker::revComment( $this->revision );
+               if ( $this->isDeleted() ) {
+                       $revlink = "<span 
class=\"history-deleted\">$revlink</span>";
+               }
+
+               $content = "$difflink $revlink $userlink $comment";
+               $attribs = array();
+               $tags = $this->getTags();
+               if ( $tags ) {
+                       list( $tagSummary, $classes ) = 
ChangeTags::formatSummaryRow( $tags, 'edittags' );
+                       $content .= " $tagSummary";
+                       $attribs['class'] = implode( ' ', $classes );
+               }
+               return Xml::tags( 'li', $attribs, $content );
+       }
+}
diff --git a/includes/changetags/ChangeTagsRevisionList.php 
b/includes/changetags/ChangeTagsRevisionList.php
new file mode 100644
index 0000000..842d327
--- /dev/null
+++ b/includes/changetags/ChangeTagsRevisionList.php
@@ -0,0 +1,99 @@
+<?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 Change tagging
+ */
+
+/**
+ * Stores a list of taggable revisions.
+ * @since 1.25
+ */
+class ChangeTagsRevisionList extends ChangeTagsList {
+       public function getType() {
+               return 'revision';
+       }
+
+       /**
+        * @param DatabaseBase $db
+        * @return mixed
+        */
+       public function doQuery( $db ) {
+               $ids = array_map( 'intval', $this->ids );
+               $queryInfo = array(
+                       'tables' => array( 'revision', 'user' ),
+                       'fields' => array_merge( Revision::selectFields(), 
Revision::selectUserFields() ),
+                       'conds' => array(
+                               'rev_page' => $this->title->getArticleID(),
+                               'rev_id' => $ids,
+                       ),
+                       'options' => array( 'ORDER BY' => 'rev_id DESC' ),
+                       'join_conds' => array(
+                               'page' => Revision::pageJoinCond(),
+                               'user' => Revision::userJoinCond(),
+                       ),
+               );
+               ChangeTags::modifyDisplayQuery(
+                       $queryInfo['tables'],
+                       $queryInfo['fields'],
+                       $queryInfo['conds'],
+                       $queryInfo['join_conds'],
+                       $queryInfo['options']
+               );
+               return $db->select(
+                       $queryInfo['tables'],
+                       $queryInfo['fields'],
+                       $queryInfo['conds'],
+                       __METHOD__,
+                       $queryInfo['options'],
+                       $queryInfo['join_conds']
+               );
+       }
+
+       public function newItem( $row ) {
+               return new ChangeTagsRevisionItem( $this, $row );
+       }
+
+       /**
+        * Add/remove change tags from all the revisions in the list.
+        *
+        * @param array $tagsToAdd
+        * @param array $tagsToRemove
+        * @param array $params
+        * @param string $reason
+        * @param User $user
+        * @return Status
+        */
+       public function updateChangeTagsOnAll( $tagsToAdd, $tagsToRemove, 
$params,
+               $reason, $user ) {
+
+               // @codingStandardsIgnoreStart 
Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+               for ( $this->reset(); $this->current(); $this->next() ) {
+                       // @codingStandardsIgnoreEnd
+                       $item = $this->current();
+                       $status = ChangeTags::updateTagsWithChecks( $tagsToAdd, 
$tagsToRemove,
+                               null, $item->getId(), null, $params, $reason, 
$user );
+                       // Should only fail on second and subsequent times if 
the user trips
+                       // the rate limiter
+                       if ( !$status->isOK() ) {
+                               break;
+                       }
+               }
+
+               return $status;
+       }
+}
diff --git a/includes/specialpage/SpecialPageFactory.php 
b/includes/specialpage/SpecialPageFactory.php
index c262519..dedfcb6 100644
--- a/includes/specialpage/SpecialPageFactory.php
+++ b/includes/specialpage/SpecialPageFactory.php
@@ -159,6 +159,7 @@
                'ApiHelp' => 'SpecialApiHelp',
                'Blankpage' => 'SpecialBlankpage',
                'Diff' => 'SpecialDiff',
+               'EditTags' => 'SpecialEditTags',
                'Emailuser' => 'SpecialEmailUser',
                'Movepage' => 'MovePageForm',
                'Mycontributions' => 'SpecialMycontributions',
diff --git a/includes/specials/SpecialEditTags.php 
b/includes/specials/SpecialEditTags.php
new file mode 100644
index 0000000..14850ff
--- /dev/null
+++ b/includes/specials/SpecialEditTags.php
@@ -0,0 +1,459 @@
+<?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 SpecialPage
+ */
+
+/**
+ * Special page for adding and removing change tags to individual revisions.
+ * A lot of this is copied out of SpecialRevisiondelete.
+ *
+ * @ingroup SpecialPage
+ * @since 1.25
+ */
+class SpecialEditTags extends UnlistedSpecialPage {
+       /** @var bool Was the DB modified in this request */
+       protected $wasSaved = false;
+
+       /** @var bool True if the submit button was clicked, and the form was 
posted */
+       private $submitClicked;
+
+       /** @var array Target ID list */
+       private $ids;
+
+       /** @var Title Title object for target parameter */
+       private $targetObj;
+
+       /** @var string Deletion type, may be revision or logentry */
+       private $typeName;
+
+       /** @var ChangeTagsList Storing the list of items to be tagged */
+       private $revList;
+
+       /** @var bool Whether user is allowed to perform the action */
+       private $isAllowed;
+
+       /** @var string */
+       private $reason;
+
+       public function __construct() {
+               parent::__construct( 'EditTags', 'changetags' );
+       }
+
+       public function execute( $par ) {
+               $this->checkPermissions();
+               $this->checkReadOnly();
+
+               $output = $this->getOutput();
+               $user = $this->getUser();
+               $request = $this->getRequest();
+
+               $this->setHeaders();
+               $this->outputHeader();
+
+               $this->getOutput()->addModules( array( 
'mediawiki.special.edittags',
+                       'mediawiki.special.edittags.styles' ) );
+
+               $this->submitClicked = $request->wasPosted() && 
$request->getBool( 'wpSubmit' );
+
+               // Handle our many different possible input types
+               $ids = $request->getVal( 'ids' );
+               if ( !is_null( $ids ) ) {
+                       // Allow CSV from the form hidden field, or a single ID 
for show/hide links
+                       $this->ids = explode( ',', $ids );
+               } else {
+                       // Array input
+                       $this->ids = array_keys( $request->getArray( 'ids', 
array() ) );
+               }
+               $this->ids = array_unique( array_filter( $this->ids ) );
+
+               // No targets?
+               if ( count( $this->ids ) == 0 ) {
+                       throw new ErrorPageError( 'tags-edit-nooldid-title', 
'tags-edit-nooldid-text' );
+               }
+
+               $this->typeName = $request->getVal( 'type' );
+               $this->targetObj = Title::newFromText( $request->getText( 
'target' ) );
+
+               // sanity check of parameter
+               switch ( $this->typeName ) {
+                       case 'logentry':
+                       case 'logging':
+                               $this->typeName = 'logentry';
+                               break;
+                       default:
+                               $this->typeName = 'revision';
+                               break;
+               }
+
+               // Allow the list type to adjust the passed target
+               // Yuck! Copied straight out of SpecialRevisiondelete, but it 
does exactly
+               // what we want
+               $this->targetObj = RevisionDeleter::suggestTarget(
+                       $this->typeName === 'revision' ? 'revision' : 'logging',
+                       $this->targetObj,
+                       $this->ids
+               );
+
+               $this->isAllowed = $user->isAllowed( 'changetags' );
+
+               $this->reason = $request->getVal( 'wpReason' );
+               // We need a target page!
+               if ( is_null( $this->targetObj ) ) {
+                       $output->addWikiMsg( 'undelete-header' );
+                       return;
+               }
+               // Give a link to the logs/hist for this page
+               $this->showConvenienceLinks();
+
+               // Either submit or create our form
+               if ( $this->isAllowed && $this->submitClicked ) {
+                       $this->submit( $request );
+               } else {
+                       $this->showForm();
+               }
+
+               // Show relevant lines from the tag log
+               $tagLogPage = new LogPage( 'tag' );
+               $output->addHTML( "<h2>" . $tagLogPage->getName()->escaped() . 
"</h2>\n" );
+               LogEventsList::showLogExtract(
+                       $output,
+                       'tag',
+                       $this->targetObj,
+                       '', /* user */
+                       array( 'lim' => 25, 'conds' => array(), 'useMaster' => 
$this->wasSaved )
+               );
+       }
+
+       /**
+        * Show some useful links in the subtitle
+        */
+       protected function showConvenienceLinks() {
+               // Give a link to the logs/hist for this page
+               if ( $this->targetObj ) {
+                       // Also set header tabs to be for the target.
+                       $this->getSkin()->setRelevantTitle( $this->targetObj );
+
+                       $links = array();
+                       $links[] = Linker::linkKnown(
+                               SpecialPage::getTitleFor( 'Log' ),
+                               $this->msg( 'viewpagelogs' )->escaped(),
+                               array(),
+                               array(
+                                       'page' => 
$this->targetObj->getPrefixedText(),
+                                       'hide_tag_log' => '0',
+                               )
+                       );
+                       if ( !$this->targetObj->isSpecialPage() ) {
+                               // Give a link to the page history
+                               $links[] = Linker::linkKnown(
+                                       $this->targetObj,
+                                       $this->msg( 'pagehist' )->escaped(),
+                                       array(),
+                                       array( 'action' => 'history' )
+                               );
+                       }
+                       // Link to Special:Tags
+                       $links[] = Linker::linkKnown(
+                               SpecialPage::getTitleFor( 'Tags' ),
+                               $this->msg( 'tags-edit-manage-link' )->escaped()
+                       );
+                       // Logs themselves don't have histories or archived 
revisions
+                       $this->getOutput()->addSubtitle( 
$this->getLanguage()->pipeList( $links ) );
+               }
+       }
+
+       /**
+        * Get the list object for this request
+        * @return ChangeTagsList
+        */
+       protected function getList() {
+               if ( is_null( $this->revList ) ) {
+                       $this->revList = ChangeTagsList::factory( 
$this->typeName, $this->getContext(),
+                               $this->targetObj, $this->ids );
+               }
+
+               return $this->revList;
+       }
+
+       /**
+        * Show a list of items that we will operate on, and show a form which 
allows
+        * the user to modify the tags applied to those items.
+        */
+       protected function showForm() {
+               $userAllowed = true;
+
+               $out = $this->getOutput();
+               // Messages: tags-edit-revision-selected, 
tags-edit-logentry-selected
+               $out->wrapWikiMsg( "<strong>$1</strong>", array(
+                       "tags-edit-{$this->typeName}-selected",
+                       $this->getLanguage()->formatNum( count( $this->ids ) ),
+                       $this->targetObj->getPrefixedText()
+               ) );
+
+               $out->addHelpLink( 'Help:Tags' );
+               $out->addHTML( "<ul>" );
+
+               $numRevisions = 0;
+               // Live revisions...
+               $list = $this->getList();
+               // @codingStandardsIgnoreStart 
Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+               for ( $list->reset(); $list->current(); $list->next() ) {
+                       // @codingStandardsIgnoreEnd
+                       $item = $list->current();
+                       $numRevisions++;
+                       $out->addHTML( $item->getHTML() );
+               }
+
+               if ( !$numRevisions ) {
+                       throw new ErrorPageError( 'tags-edit-nooldid-title', 
'tags-edit-nooldid-text' );
+               }
+
+               $out->addHTML( "</ul>" );
+               // Explanation text
+               $out->wrapWikiMsg( '<p>$1</p>', 
"tags-edit-{$this->typeName}-explanation" );
+
+               // Show form if the user can submit
+               if ( $this->isAllowed ) {
+                       $form = Xml::openElement( 'form', array( 'method' => 
'post',
+                                       'action' => 
$this->getPageTitle()->getLocalURL( array( 'action' => 'submit' ) ),
+                                       'id' => 'mw-revdel-form-revisions' ) ) .
+                               Xml::fieldset( $this->msg( 
"tags-edit-{$this->typeName}-legend",
+                                       count( $this->ids ) )->text() ) .
+                               $this->buildCheckBoxes() .
+                               Xml::openElement( 'table' ) .
+                               "<tr>\n" .
+                                       '<td class="mw-label">' .
+                                               Xml::label( $this->msg( 
'tags-edit-reason' )->text(), 'wpReason' ) .
+                                       '</td>' .
+                                       '<td class="mw-input">' .
+                                               Xml::input(
+                                                       'wpReason',
+                                                       60,
+                                                       $this->reason,
+                                                       array( 'id' => 
'wpReason', 'maxlength' => 100 )
+                                               ) .
+                                       '</td>' .
+                               "</tr><tr>\n" .
+                                       '<td></td>' .
+                                       '<td class="mw-submit">' .
+                                               Xml::submitButton( $this->msg( 
"tags-edit-{$this->typeName}-submit",
+                                                       $numRevisions 
)->text(), array( 'name' => 'wpSubmit' ) ) .
+                                       '</td>' .
+                               "</tr>\n" .
+                               Xml::closeElement( 'table' ) .
+                               Html::hidden( 'wpEditToken', 
$this->getUser()->getEditToken() ) .
+                               Html::hidden( 'target', 
$this->targetObj->getPrefixedText() ) .
+                               Html::hidden( 'type', $this->typeName ) .
+                               Html::hidden( 'ids', implode( ',', $this->ids ) 
) .
+                               Xml::closeElement( 'fieldset' ) . "\n" .
+                               Xml::closeElement( 'form' ) . "\n";
+               } else {
+                       $form = '';
+               }
+               $out->addHTML( $form );
+       }
+
+       /**
+        * @return string HTML
+        */
+       protected function buildCheckBoxes() {
+               // If there is just one item, provide the user with a 
multi-select field
+               $list = $this->getList();
+               if ( $list->length() == 1 ) {
+                       $list->reset();
+                       $tags = $list->current()->getTags();
+                       if ( $tags ) {
+                               $tags = explode( ',', $tags );
+                       } else {
+                               $tags = array();
+                       }
+
+                       $html = '<table id="mw-edittags-tags-selector">';
+                       $html .= '<tr><td>' . $this->msg( 
'tags-edit-existing-tags' )->escaped() .
+                               '</td><td>';
+                       if ( $tags ) {
+                               $html .= $this->getLanguage()->commaList( 
array_map( 'htmlspecialchars', $tags ) );
+                       } else {
+                               $html .= $this->msg( 
'tags-edit-existing-tags-none' )->parse();
+                       }
+                       $html .= '</td></tr>';
+                       $tagSelect = $this->getTagSelect( $tags, $this->msg( 
'tags-edit-new-tags' )->plain() );
+                       $html .= '<tr><td>' . $tagSelect[0] . '</td><td>' . 
$tagSelect[1];
+                       // also output the tags currently applied as a hidden 
form field, so we
+                       // know what to remove from the revision/log entry when 
the form is submitted
+                       $html .= Html::hidden( 'wpExistingTags', implode( ',', 
$tags ) );
+                       $html .= '</td></tr></table>';
+               } else {
+                       // Otherwise, use a multi-select field for adding tags, 
and a list of
+                       // checkboxes for removing them
+                       $tags = array();
+
+                       // @codingStandardsIgnoreStart 
Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+                       for ( $list->reset(); $list->current(); $list->next() ) 
{
+                               // @codingStandardsIgnoreEnd
+                               $currentTags = $list->current()->getTags();
+                               if ( $currentTags ) {
+                                       $tags = array_merge( $tags, explode( 
',', $currentTags ) );
+                               }
+                       }
+                       $tags = array_unique( $tags );
+
+                       $html = '<table 
id="mw-edittags-tags-selector-multi"><tr><td>';
+                       $tagSelect = $this->getTagSelect( array(), $this->msg( 
'tags-edit-add' )->plain() );
+                       $html .= '<p>' . $tagSelect[0] . '</p>' . $tagSelect[1] 
. '</td><td>';
+                       $html .= Xml::element( 'p', null, $this->msg( 
'tags-edit-remove' )->plain() );
+                       $html .= Xml::checkLabel( $this->msg( 
'tags-edit-remove-all-tags' )->plain(),
+                               'wpRemoveAllTags', 'mw-edittags-remove-all' );
+                       $i = 0; // used for generating checkbox IDs only
+                       foreach ( $tags as $tag ) {
+                               $html .= Xml::element( 'br' ) . "\n" . 
Xml::checkLabel( $tag,
+                                       'wpTagsToRemove[]', 
'mw-edittags-remove-' . $i++, false, array(
+                                               'value' => $tag,
+                                               'class' => 
'mw-edittags-remove-checkbox',
+                                       ) );
+                       }
+                       $html .= '</td></tr></table>';
+               }
+
+               return $html;
+       }
+
+       /**
+        * Returns a <select multiple> element with a list of change tags that 
can be
+        * applied by users.
+        *
+        * @param array $selectedTags The tags that should be preselected in the
+        * list. Any tags in this list, but not in the list returned by
+        * ChangeTags::listExplicitlyDefinedTags, will be appended to the 
<select>
+        * element.
+        * @param string $label The text of a <label> to precede the <select>
+        * @return array HTML <label> element at index 0, HTML <select> element 
at
+        * index 1
+        */
+       protected function getTagSelect( $selectedTags, $label ) {
+               $result = array();
+               $result[0] = Xml::label( $label, 'mw-edittags-tag-list' );
+               $result[1] = Xml::openElement( 'select', array(
+                       'name' => 'wpTagList[]',
+                       'id' => 'mw-edittags-tag-list',
+                       'multiple' => 'multiple',
+                       'size' => '8',
+               ) );
+
+               $tags = ChangeTags::listExplicitlyDefinedTags();
+               $tags = array_unique( array_merge( $tags, $selectedTags ) );
+               foreach ( $tags as $tag ) {
+                       $result[1] .= Xml::option( $tag, $tag, in_array( $tag, 
$selectedTags ) );
+               }
+
+               $result[1] .= Xml::closeElement( 'select' );
+               return $result;
+       }
+
+       /**
+        * UI entry point for form submission.
+        * @throws PermissionsError
+        * @return bool
+        */
+       protected function submit() {
+               // Check edit token on submission
+               $request = $this->getRequest();
+               $token = $request->getVal( 'wpEditToken' );
+               if ( $this->submitClicked && !$this->getUser()->matchEditToken( 
$token ) ) {
+                       $this->getOutput()->addWikiMsg( 'sessionfailure' );
+                       return false;
+               }
+
+               // Evaluate incoming request data
+               $tagList = $request->getArray( 'wpTagList' );
+               if ( is_null( $tagList ) ) {
+                       $tagList = array();
+               }
+               $existingTags = $request->getVal( 'wpExistingTags' );
+               if ( is_null( $existingTags ) || $existingTags === '' ) {
+                       $existingTags = array();
+               } else {
+                       $existingTags = explode( ',', $existingTags );
+               }
+
+               if ( count( $this->ids ) > 1 ) {
+                       // multiple revisions selected
+                       $tagsToAdd = $tagList;
+                       if ( $request->getBool( 'wpRemoveAllTags' ) ) {
+                               $tagsToRemove = $existingTags;
+                       } else {
+                               $tagsToRemove = $request->getArray( 
'wpTagsToRemove' );
+                       }
+               } else {
+                       // single revision selected
+                       // The user tells us which tags they want associated to 
the revision.
+                       // We have to figure out which ones to add, and which 
to remove.
+                       $tagsToAdd = array_diff( $tagList, $existingTags );
+                       $tagsToRemove = array_diff( $existingTags, $tagList );
+               }
+
+               //var_dump( array( 'add' => $tagsToAdd, 'remove' => 
$tagsToRemove ) );
+
+               if ( !$tagsToAdd && !$tagsToRemove ) {
+                       $status = Status::newFatal( 'tags-edit-none-selected' );
+               } else {
+                       $status = $this->getList()->updateChangeTagsOnAll( 
$tagsToAdd,
+                               $tagsToRemove, null, $this->reason, 
$this->getUser() );
+               }
+
+               if ( $status->isGood() ) {
+                       $this->success();
+                       return true;
+               } else {
+                       $this->failure( $status );
+                       return false;
+               }
+       }
+
+       /**
+        * Report that the submit operation succeeded
+        */
+       protected function success() {
+               $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' 
) );
+               $this->getOutput()->wrapWikiMsg( "<span 
class=\"success\">\n$1\n</span>",
+                       'tags-edit-success' );
+               $this->wasSaved = true;
+               $this->revList->reloadFromMaster();
+               $this->reason = ''; // no need to spew the reason back at the 
user
+               $this->showForm();
+       }
+
+       /**
+        * Report that the submit operation failed
+        * @param Status $status
+        */
+       protected function failure( $status ) {
+               $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) 
);
+               $this->getOutput()->addWikiText( $status->getWikiText( 
'tags-edit-failure' ) );
+               $this->showForm();
+       }
+
+       public function getDescription() {
+               return $this->msg( 'tags-edit-title' )->text();
+       }
+
+       protected function getGroupName() {
+               return 'pagetools';
+       }
+}
diff --git a/includes/specials/SpecialLog.php b/includes/specials/SpecialLog.php
index 88184f9..f16e5ba 100644
--- a/includes/specials/SpecialLog.php
+++ b/includes/specials/SpecialLog.php
@@ -203,7 +203,7 @@
                if ( $logBody ) {
                        $this->getOutput()->addHTML(
                                $pager->getNavigationBar() .
-                                       $this->getRevisionButton(
+                                       $this->getActionButtons(
                                                $loglist->beginLogEventsList() .
                                                        $logBody .
                                                        
$loglist->endLogEventsList()
@@ -215,30 +215,50 @@
                }
        }
 
-       private function getRevisionButton( $formcontents ) {
-               # If the user doesn't have the ability to delete log entries,
-               # don't bother showing them the button.
-               if ( !$this->getUser()->isAllowedAll( 'deletedhistory', 
'deletelogentry' ) ) {
+       private function getActionButtons( $formcontents ) {
+               $user = $this->getUser();
+               $canRevDelete = $user->isAllowedAll( 'deletedhistory', 
'deletelogentry' );
+               $canModifyTags = $user->isAllowed( 'changetags' );
+               # If the user doesn't have the ability to delete log entries 
nor edit tags,
+               # don't bother showing them the button(s).
+               if ( !$canRevDelete && !$canModifyTags ) {
                        return $formcontents;
                }
 
-               # Show button to hide log entries
+               # Show button to hide log entries and/or edit change tags
                $s = Html::openElement(
                        'form',
                        array( 'action' => wfScript(), 'id' => 
'mw-log-deleterevision-submit' )
                ) . "\n";
-               $s .= Html::hidden( 'title', SpecialPage::getTitleFor( 
'Revisiondelete' ) ) . "\n";
-               $s .= Html::hidden( 'target', SpecialPage::getTitleFor( 'Log' ) 
) . "\n";
+               $s .= Html::hidden( 'action', 'historysubmit' ) . "\n";
                $s .= Html::hidden( 'type', 'logging' ) . "\n";
-               $button = Html::element(
-                       'button',
-                       array(
-                               'type' => 'submit',
-                               'class' => "deleterevision-log-submit 
mw-log-deleterevision-button"
-                       ),
-                       $this->msg( 'showhideselectedlogentries' )->text()
-               ) . "\n";
-               $s .= $button . $formcontents . $button;
+
+               $buttons = '';
+               if ( $canRevDelete ) {
+                       $buttons .= Html::element(
+                               'button',
+                               array(
+                                       'type' => 'submit',
+                                       'name' => 'revisiondelete',
+                                       'value' => '1',
+                                       'class' => "deleterevision-log-submit 
mw-log-deleterevision-button"
+                               ),
+                               $this->msg( 'showhideselectedlogentries' 
)->text()
+                       ) . "\n";
+               }
+               if ( $canModifyTags ) {
+                       $buttons .= Html::element(
+                               'button',
+                               array(
+                                       'type' => 'submit',
+                                       'name' => 'editchangetags',
+                                       'value' => '1',
+                                       'class' => "editchangetags-log-submit 
mw-log-editchangetags-button"
+                               ),
+                               $this->msg( 'log-edit-tags' )->text()
+                       ) . "\n";
+               }
+               $s .= $buttons . $formcontents . $buttons;
                $s .= Html::closeElement( 'form' );
 
                return $s;
diff --git a/includes/specials/SpecialRevisiondelete.php 
b/includes/specials/SpecialRevisiondelete.php
index e0a964e..bdfe911 100644
--- a/includes/specials/SpecialRevisiondelete.php
+++ b/includes/specials/SpecialRevisiondelete.php
@@ -132,18 +132,8 @@
                // $this->ids = array_map( 'intval', $this->ids );
                $this->ids = array_unique( array_filter( $this->ids ) );
 
-               if ( $request->getVal( 'action' ) == 'historysubmit'
-                       || $request->getVal( 'action' ) == 'revisiondelete'
-               ) {
-                       // For show/hide form submission from history page
-                       // Since we are access through 
index.php?title=XXX&action=historysubmit
-                       // getFullTitle() will contain the target title and not 
our title
-                       $this->targetObj = $this->getFullTitle();
-                       $this->typeName = 'revision';
-               } else {
-                       $this->typeName = $request->getVal( 'type' );
-                       $this->targetObj = Title::newFromText( 
$request->getText( 'target' ) );
-               }
+               $this->typeName = $request->getVal( 'type' );
+               $this->targetObj = Title::newFromText( $request->getText( 
'target' ) );
 
                # For reviewing deleted files...
                $this->archiveName = $request->getVal( 'file' );
diff --git a/languages/i18n/en.json b/languages/i18n/en.json
index 003ee91..e56aad7 100644
--- a/languages/i18n/en.json
+++ b/languages/i18n/en.json
@@ -765,6 +765,7 @@
        "history-feed-description": "Revision history for this page on the 
wiki",
        "history-feed-item-nocomment": "$1 at $2",
        "history-feed-empty": "The requested page does not exist.\nIt may have 
been deleted from the wiki, or renamed.\nTry [[Special:Search|searching on the 
wiki]] for relevant new pages.",
+       "history-edit-tags": "Edit tags of selected revisions",
        "rev-deleted-comment": "(edit summary removed)",
        "rev-deleted-user": "(username removed)",
        "rev-deleted-event": "(log details removed)",
@@ -1721,6 +1722,7 @@
        "logempty": "No matching items in log.",
        "log-title-wildcard": "Search titles starting with this text",
        "showhideselectedlogentries": "Change visibility of selected log 
entries",
+       "log-edit-tags": "Edit tags of selected log entries",
        "allpages": "All pages",
        "allpages-summary": "",
        "nextpage": "Next page ($1)",
@@ -3431,6 +3433,30 @@
        "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",
+       "tags-edit-title": "Edit tags",
+       "tags-edit-manage-link": "Manage tags",
+       "tags-edit-revision-selected": "{{PLURAL:$1|Selected revision|Selected 
revisions}} of [[:$2]]:",
+       "tags-edit-logentry-selected": "{{PLURAL:$1|Selected log event|Selected 
log events}}:",
+       "tags-edit-revision-explanation": "",
+       "tags-edit-logentry-explanation": "",
+       "tags-edit-revision-legend": "Add or remove tags from {{PLURAL:$1|this 
revision|all $1 revisions}}",
+       "tags-edit-logentry-legend": "Add or remove tags from {{PLURAL:$1|this 
log entry|all $1 log entries}}",
+       "tags-edit-existing-tags": "Existing tags:",
+       "tags-edit-existing-tags-none": "''None''",
+       "tags-edit-new-tags": "New tags:",
+       "tags-edit-add": "Add these tags:",
+       "tags-edit-remove": "Remove these tags:",
+       "tags-edit-remove-all-tags": "(remove all tags)",
+       "tags-edit-chosen-placeholder": "Select some tags",
+       "tags-edit-chosen-no-results": "No tags found that match",
+       "tags-edit-reason": "Reason:",
+       "tags-edit-revision-submit": "Apply changes to {{PLURAL:$1|this 
revision|$1 revisions}}",
+       "tags-edit-logentry-submit": "Apply changes to {{PLURAL:$1|this log 
entry|$1 log entries}}",
+       "tags-edit-success": "<strong>The changes were successfully 
applied.</strong>",
+       "tags-edit-failure": "<strong>The changes could not be 
applied:</strong>\n$1",
+       "tags-edit-nooldid-title": "Invalid target revision",
+       "tags-edit-nooldid-text": "You have either not specified a target 
revision(s) to perform this function, or the specified revision does not 
exist.",
+       "tags-edit-none-selected": "Please select at least one tag to add or 
remove.",
        "comparepages": "Compare pages",
        "comparepages-summary": "",
        "compare-page1": "Page 1",
diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json
index 39dc6ca..db30115 100644
--- a/languages/i18n/qqq.json
+++ b/languages/i18n/qqq.json
@@ -931,6 +931,7 @@
        "history-feed-description": "Used as subtitle (description) of the 
RSS/Atom feed for a page history. See 
[{{canonicalurl:Main_Page|feed=atom&action=history}} example].",
        "history-feed-item-nocomment": "Title for each revision when viewing 
the RSS/Atom feed for a page history.\n\nParameters:\n* $1 - username\n* $2 - 
date/time\n* $3 - (Optional) date\n* $4 - (Optional) time",
        "history-feed-empty": "Used as summary of the RSS/Atom feed for a page 
history when the feed is empty.\nSee 
[{{canonicalurl:x|feed=atom&action=history}} example].",
+       "history-edit-tags": "Text of button used to access change tagging 
interface. For more information on tags see [[mw:Manual:Tags]].",
        "rev-deleted-comment": "Apparently this can also be about the reason of 
a log action, not only an edit summary. See 
also:\n*{{msg-mw|revdelete-hide-comment}}",
        "rev-deleted-user": "See also:\n* {{msg-mw|Rev-deleted-event}}",
        "rev-deleted-event": "See also:\n* {{msg-mw|Rev-deleted-user}}",
@@ -1887,6 +1888,7 @@
        "logempty": "Used as warning when there are no items to show.",
        "log-title-wildcard": "* Appears in: [[Special:Log]]\n* Description: A 
check box to enable prefix search option",
        "showhideselectedlogentries": "Text of the button which brings up the 
[[mw:RevisionDelete|RevisionDelete]] menu on [[Special:Log]].",
+       "log-edit-tags": "Text of button used to access change tagging 
interface. For more information on tags see [[mw:Manual:Tags]].",        
"allpages": "{{doc-special|AllPages}}\nFirst part of the navigation bar for the 
special page [[Special:AllPages]] and [[Special:PrefixIndex]].\nThe other parts 
are {{msg-mw|Prevpage}} and {{msg-mw|Nextpage}}.\n{{Identical|All pages}}",
        "allpages": "{{doc-special|AllPages}}\nFirst part of the navigation bar 
for the special page [[Special:AllPages]] and [[Special:PrefixIndex]].\nThe 
other parts are {{msg-mw|Prevpage}} and {{msg-mw|Nextpage}}.\n{{Identical|All 
pages}}",
        "allpages-summary": "{{doc-specialpagesummary|allpages}}",
        "nextpage": "Third part of the navigation bar for the special page 
[[Special:AllPages]] and [[Special:PrefixIndex]]. $1 is a page title. The other 
parts are {{msg-mw|Allpages}} and {{msg-mw|Prevpage}}.\n\n{{Identical|Next 
page}}",
@@ -3597,6 +3599,30 @@
        "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",
+       "tags-edit-title": "The title of a page where tags can be added or 
removed from selected revisions or log entries.\nFor more information on tags 
see [[mw:Manual:Tags]].",
+       "tags-edit-manage-link": "Text of a link to [[Special:Tags]], in 
imperative mood. Refers to the same thing as {{msg-mw|log-name-managetags}}.",
+       "tags-edit-revision-selected": 
"{{Identical|revdelete-selected-text}}\n\nSee also:\n* 
{{msg-mw|tags-edit-logentry-selected}}",
+       "tags-edit-logentry-selected": "{{Identical|logdelete-selected}}\n\nSee 
also:\n* {{msg-mw|tags-edit-revision-selected}}",
+       "tags-edit-revision-explanation": "Leave blank.\n\nSee also:\n* 
{{msg-mw|tags-edit-logentry-explanation}}",
+       "tags-edit-logentry-explanation": "Leave blank.\n\nSee also:\n* 
{{msg-mw|tags-edit-revision-explanation}}",
+       "tags-edit-revision-legend": "Form legend.\n\nSee also:\n* 
{{msg-mw|tags-edit-logentry-legend}}",
+       "tags-edit-logentry-legend": "Form legend.\n\nSee also:\n* 
{{msg-mw|tags-edit-revision-legend}}",
+       "tags-edit-existing-tags": "Heading beneath which a list of tags 
already applied to the revision or log entry is presented.",
+       "tags-edit-existing-tags-none": "Shown when no tags are applied. Should 
be formatted differently (italicised or parenthesised).",
+       "tags-edit-new-tags": "Heading beneath which the user chooses which 
tags should be attached to the revision or log entry. They may add or remove 
tags.",
+       "tags-edit-add": "Heading beneath which the user picks which tags to 
add to the revision or log entry.",
+       "tags-edit-remove": "Heading beneath which the user picks which tags to 
remove from the revision or log entry.",
+       "tags-edit-remove-all-tags": "Check box label that the user selects 
when they want to remove all the tags from the revision or log entry.",
+       "tags-edit-reason": "{{Identical|Reason}}",
+       "tags-edit-revision-submit": "Text of the submission button of the edit 
tag form for revisions.\n\nSee also:\n* {{msg-mw|tags-edit-logentry-submit}}",
+       "tags-edit-logentry-submit": "Text of the submission button of the edit 
tag form for log entries.\n\nSee also:\n* {{msg-mw|tags-edit-revision-submit}}",
+       "tags-edit-success": "Success message for the edit tag form.",
+       "tags-edit-failure": "Error message wrapper for the edit tag 
form.\n\nParameters:\n* $1 - additional error messages",
+       "tags-edit-nooldid-title": "Title for an error message 
({{msg-mw|tags-edit-nooldid-text}}) for the edit tag form.",
+       "tags-edit-nooldid-text": "Error message for the edit tag form.\n\nSee 
also:\n* {{msg-mw|tags-edit-nooldid-title}}",
+       "tags-edit-none-selected": "Error message for the edit tag form.",
+       "tags-edit-chosen-placeholder": "Placeholder text on the jQuery Chosen 
input box where users can select zero or more tags.",
+       "tags-edit-chosen-no-results": "Message displayed by the jQuery Chosen 
input box when the user enters a string which doesn't match a known tag.\n\nDue 
to technical limitations, the user's input is not passed as a parameter to this 
message. The string the user entered is wrapped in quotation marks (\") and 
appended to the end of this string.",
        "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}}",
diff --git a/resources/Resources.php b/resources/Resources.php
index e56d557..a17b678 100644
--- a/resources/Resources.php
+++ b/resources/Resources.php
@@ -1401,6 +1401,20 @@
        'mediawiki.special.changeslist.enhanced' => array(
                'styles' => 
'resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css',
        ),
+       'mediawiki.special.edittags' => array(
+               'scripts' => 
'resources/src/mediawiki.special/mediawiki.special.edittags.js',
+               'dependencies' => array(
+                       'jquery.chosen',
+               ),
+               'messages' => array(
+                       'tags-edit-chosen-placeholder',
+                       'tags-edit-chosen-no-results',
+               ),
+       ),
+       'mediawiki.special.edittags.styles' => array(
+               'styles' => 
'resources/src/mediawiki.special/mediawiki.special.edittags.css',
+               'position' => 'top',
+       ),
        'mediawiki.special.import' => array(
                'scripts' => 
'resources/src/mediawiki.special/mediawiki.special.import.js',
        ),
diff --git a/resources/src/mediawiki.action/mediawiki.action.history.js 
b/resources/src/mediawiki.action/mediawiki.action.history.js
index ac48c59..2ebfe92 100644
--- a/resources/src/mediawiki.action/mediawiki.action.history.js
+++ b/resources/src/mediawiki.action/mediawiki.action.history.js
@@ -85,7 +85,8 @@
                                $copyForm.find( 'input[name^="ids["]:checked' 
).prop( 'checked', false );
 
                        // Remove diff=&oldid=, change action=historysubmit to 
revisiondelete, remove revisiondelete
-                       } else if ( $historySubmitter.hasClass( 
'mw-history-revisiondelete-button' ) ) {
+                       } else if ( $historySubmitter.hasClass( 
'mw-history-revisiondelete-button' ) ||
+                                       $historySubmitter.hasClass( 
'mw-history-editchangetags-button' ) ) {
                                $copyRadios.remove();
                                $copyAction.val( $historySubmitter.attr( 'name' 
) );
                                $copyForm.find( ':submit' ).remove();
diff --git a/resources/src/mediawiki.legacy/shared.css 
b/resources/src/mediawiki.legacy/shared.css
index e526d47..3657b12 100644
--- a/resources/src/mediawiki.legacy/shared.css
+++ b/resources/src/mediawiki.legacy/shared.css
@@ -426,7 +426,7 @@
        border: 1px dashed #aaa;
 }
 
-.mw-history-revisiondelete-button, #mw-fileduplicatesearch-icon {
+.mw-history-revisionactions, #mw-fileduplicatesearch-icon {
        float: right;
 }
 
diff --git a/resources/src/mediawiki.special/mediawiki.special.edittags.css 
b/resources/src/mediawiki.special/mediawiki.special.edittags.css
new file mode 100644
index 0000000..204009c
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.edittags.css
@@ -0,0 +1,15 @@
+/*!
+ * Styling for Special:EditTags and action=editchangetags
+ */
+#mw-edittags-tags-selector td {
+       vertical-align: top;
+}
+
+#mw-edittags-tags-selector-multi td {
+       vertical-align: top;
+       padding-right: 1.5em;
+}
+
+#mw-edittags-tag-list {
+       min-width: 20em;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.edittags.js 
b/resources/src/mediawiki.special/mediawiki.special.edittags.js
new file mode 100644
index 0000000..69a2a67
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.edittags.js
@@ -0,0 +1,24 @@
+/*!
+ * JavaScript for Special:EditTags
+ */
+( function ( mw, $ ) {
+       $( function () {
+               var $tagList = $( '#mw-edittags-tag-list' );
+               if ( $tagList.length ) {
+                       $tagList.chosen( {
+                               /*jscs:disable 
requireCamelCaseOrUpperCaseIdentifiers */
+                               placeholder_text_multiple: mw.msg( 
'tags-edit-chosen-placeholder' ),
+                               no_results_text: mw.msg( 
'tags-edit-chosen-no-results' )
+                       } );
+               }
+
+               $( '#mw-edittags-remove-all' ).on( 'change', function ( e ) {
+                       $( '.mw-edittags-remove-checkbox' ).prop( 'checked', 
e.target.checked );
+               } );
+               $( '.mw-edittags-remove-checkbox' ).on( 'change', function ( e 
) {
+                       if ( !e.target.checked ) {
+                               $( '#mw-edittags-remove-all' ).prop( 'checked', 
false );
+                       }
+               } );
+       } );
+}( mediaWiki, jQuery ) );
diff --git a/tests/phpunit/includes/actions/ActionTest.php 
b/tests/phpunit/includes/actions/ActionTest.php
index 83f5922..3babb97 100644
--- a/tests/phpunit/includes/actions/ActionTest.php
+++ b/tests/phpunit/includes/actions/ActionTest.php
@@ -19,7 +19,7 @@
                        'disabled' => false,
                        'view' => true,
                        'edit' => true,
-                       'revisiondelete' => true,
+                       'revisiondelete' => 'SpecialPageAction',
                        'dummy' => true,
                        'string' => 'NamedDummyAction',
                        'declared' => 'NonExistingClassName',

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

Gerrit-MessageType: newchange
Gerrit-Change-Id: I7d3ef927b5686f6211bc5817776286ead19d916b
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/core
Gerrit-Branch: REL1_25
Gerrit-Owner: Anomie <[email protected]>
Gerrit-Reviewer: TTO <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to