UI for adding and removing change tags on revisions and log entries
authorThis, that and the other <at.light@live.com.au>
Wed, 15 Apr 2015 01:31:53 +0000 (11:31 +1000)
committerAnomie <bjorsch@wikimedia.org>
Wed, 15 Apr 2015 18:31:12 +0000 (18:31 +0000)
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

25 files changed:
autoload.php
includes/ChangeTags.php
includes/DefaultSettings.php
includes/RevisionList.php
includes/actions/Action.php
includes/actions/HistoryAction.php
includes/actions/RevisiondeleteAction.php
includes/actions/SpecialPageAction.php [new file with mode: 0644]
includes/changetags/ChangeTagsList.php [new file with mode: 0644]
includes/changetags/ChangeTagsLogItem.php [new file with mode: 0644]
includes/changetags/ChangeTagsLogList.php [new file with mode: 0644]
includes/changetags/ChangeTagsRevisionItem.php [new file with mode: 0644]
includes/changetags/ChangeTagsRevisionList.php [new file with mode: 0644]
includes/specialpage/SpecialPageFactory.php
includes/specials/SpecialEditTags.php [new file with mode: 0644]
includes/specials/SpecialLog.php
includes/specials/SpecialRevisiondelete.php
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.action/mediawiki.action.history.js
resources/src/mediawiki.legacy/shared.css
resources/src/mediawiki.special/mediawiki.special.edittags.css [new file with mode: 0644]
resources/src/mediawiki.special/mediawiki.special.edittags.js [new file with mode: 0644]
tests/phpunit/includes/actions/ActionTest.php

index 2fe805f..92d6014 100644 (file)
@@ -200,6 +200,11 @@ $wgAutoloadLocalClasses = array(
        '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 @@ $wgAutoloadLocalClasses = array(
        '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 @@ $wgAutoloadLocalClasses = array(
        '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',
index a097cd6..3103edd 100644 (file)
@@ -18,6 +18,7 @@
  * http://www.gnu.org/copyleft/gpl.html
  *
  * @file
+ * @ingroup Change tagging
  */
 
 class ChangeTags {
index 5c0bcff..d5801cb 100644 (file)
@@ -6774,6 +6774,7 @@ $wgActions = array(
        'credits' => true,
        'delete' => true,
        'edit' => true,
+       'editchangetags' => 'SpecialPageAction',
        'history' => true,
        'info' => true,
        'markpatrolled' => true,
@@ -6782,7 +6783,7 @@ $wgActions = array(
        'raw' => true,
        'render' => true,
        'revert' => true,
-       'revisiondelete' => true,
+       'revisiondelete' => 'SpecialPageAction',
        'rollback' => true,
        'submit' => true,
        'unprotect' => true,
index 0f77111..1cb43f7 100644 (file)
@@ -317,7 +317,7 @@ class RevisionItem extends RevisionItemBase {
        }
 
        public function getAuthorNameField() {
-               return 'user_name'; // see Revision::selectUserFields()
+               return 'rev_user_text';
        }
 
        public function canView() {
@@ -334,16 +334,19 @@ class RevisionItem extends RevisionItemBase {
 
        /**
         * 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 = htmlspecialchars( $this->list->getLanguage()->userTimeAndDate(
                        $this->revision->getTimestamp(), $this->list->getUser() ) );
+
                if ( $this->isDeleted() && !$this->canViewContent() ) {
                        return $date;
                }
-               return Linker::link(
+               return Linker::linkKnown(
                        $this->list->title,
                        $date,
                        array(),
@@ -356,30 +359,34 @@ class RevisionItem extends RevisionItemBase {
 
        /**
         * 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();
index 8d11d90..aca4363 100644 (file)
@@ -132,6 +132,8 @@ abstract class Action {
                if ( $actionName === 'historysubmit' ) {
                        if ( $request->getBool( 'revisiondelete' ) ) {
                                $actionName = 'revisiondelete';
+                       } elseif ( $request->getBool( 'editchangetags' ) ) {
+                               $actionName = 'editchangetags';
                        } else {
                                $actionName = 'view';
                        }
index 85ff994..6693178 100644 (file)
@@ -484,6 +484,7 @@ class HistoryPager extends ReverseChronologicalPager {
                        '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>';
@@ -494,8 +495,17 @@ class HistoryPager extends ReverseChronologicalPager {
                        $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>';
 
@@ -616,11 +626,15 @@ class HistoryPager extends ReverseChronologicalPager {
 
                $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 {
index b6eeb7b..6c84bbd 100644 (file)
  * An action that just pass the request to Special:RevisionDelete
  *
  * @ingroup Actions
+ * @deprecated since 1.26 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.26' );
+               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 (file)
index 0000000..3c8a21e
--- /dev/null
@@ -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.26
+ */
+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 (file)
index 0000000..dd8bab9
--- /dev/null
@@ -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 (file)
index 0000000..b648ce0
--- /dev/null
@@ -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.26
+ */
+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 (file)
index 0000000..ad274d9
--- /dev/null
@@ -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.26
+ */
+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 (file)
index 0000000..8225be4
--- /dev/null
@@ -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.26
+ */
+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 (file)
index 0000000..c51d605
--- /dev/null
@@ -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.26
+ */
+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;
+       }
+}
index c262519..dedfcb6 100644 (file)
@@ -159,6 +159,7 @@ class SpecialPageFactory {
                '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 (file)
index 0000000..4b1fcae
--- /dev/null
@@ -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.26
+ */
+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';
+       }
+}
index 88184f9..f16e5ba 100644 (file)
@@ -203,7 +203,7 @@ class SpecialLog extends SpecialPage {
                if ( $logBody ) {
                        $this->getOutput()->addHTML(
                                $pager->getNavigationBar() .
-                                       $this->getRevisionButton(
+                                       $this->getActionButtons(
                                                $loglist->beginLogEventsList() .
                                                        $logBody .
                                                        $loglist->endLogEventsList()
@@ -215,30 +215,50 @@ class SpecialLog extends SpecialPage {
                }
        }
 
-       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;
index e0a964e..bdfe911 100644 (file)
@@ -132,18 +132,8 @@ class SpecialRevisionDelete extends UnlistedSpecialPage {
                // $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' );
index b9d4dbc..ff16dda 100644 (file)
        "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)",
        "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)",
        "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",
index ba56da6..11777bc 100644 (file)
        "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}}",
        "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}}",
        "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}}",
index e56d557..a17b678 100644 (file)
@@ -1401,6 +1401,20 @@ return array(
        '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',
        ),
index ac48c59..2ebfe92 100644 (file)
@@ -85,7 +85,8 @@ jQuery( function ( $ ) {
                                $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();
index e526d47..3657b12 100644 (file)
@@ -426,7 +426,7 @@ p.mw-upload-editlicenses {
        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 (file)
index 0000000..204009c
--- /dev/null
@@ -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 (file)
index 0000000..69a2a67
--- /dev/null
@@ -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 ) );
index 83f5922..3babb97 100644 (file)
@@ -19,7 +19,7 @@ class ActionTest extends MediaWikiTestCase {
                        'disabled' => false,
                        'view' => true,
                        'edit' => true,
-                       'revisiondelete' => true,
+                       'revisiondelete' => 'SpecialPageAction',
                        'dummy' => true,
                        'string' => 'NamedDummyAction',
                        'declared' => 'NonExistingClassName',