Add Special:ChangeContentModel
authorKunal Mehta <legoktm@gmail.com>
Sun, 29 Mar 2015 03:36:01 +0000 (20:36 -0700)
committerKunal Mehta <legoktm@gmail.com>
Tue, 30 Jun 2015 20:14:50 +0000 (13:14 -0700)
Special:ChangeContentModel allows for users with the 'editcontentmodel'
right to change the content model of a page.

Visiting Special:ChangeContentModel will contain an input field for a
page title. The user will then be sent to
Special:ChangeContentModel?pagetitle=<input> where the page title is
read only, with a content model selector and optional reason field.

The special page only allows converting between content models that
extend TextContent for simplicity. Advanced conversions should be done
via the API.

All content model changes via the special page or API generate a null
revision in the page history and a log entry at
Special:Log/contentmodel. The log entry has a revert link for
convenience (like the move log).

Bug: T72592
Co-Authored-By: Lewis Cawte <lewis@lewiscawte.me>
Change-Id: I296a67c09fcbc880c8c3a648eb5086580725ea46

autoload.php
includes/DefaultSettings.php
includes/EditPage.php
includes/content/ContentHandler.php
includes/logging/ContentModelLogFormatter.php [new file with mode: 0644]
includes/specialpage/SpecialPageFactory.php
includes/specials/SpecialChangeContentModel.php [new file with mode: 0644]
languages/i18n/en.json
languages/i18n/qqq.json

index 284da46..9266776 100644 (file)
@@ -258,6 +258,7 @@ $wgAutoloadLocalClasses = array(
        'ConstantDependency' => __DIR__ . '/includes/cache/CacheDependency.php',
        'Content' => __DIR__ . '/includes/content/Content.php',
        'ContentHandler' => __DIR__ . '/includes/content/ContentHandler.php',
+       'ContentModelLogFormatter' => __DIR__ . '/includes/logging/ContentModelLogFormatter.php',
        'ContextSource' => __DIR__ . '/includes/context/ContextSource.php',
        'ContribsPager' => __DIR__ . '/includes/specials/SpecialContributions.php',
        'ConvertExtensionToRegistration' => __DIR__ . '/maintenance/convertExtensionToRegistration.php',
@@ -1110,6 +1111,7 @@ $wgAutoloadLocalClasses = array(
        'SpecialBookSources' => __DIR__ . '/includes/specials/SpecialBooksources.php',
        'SpecialCachedPage' => __DIR__ . '/includes/specials/SpecialCachedPage.php',
        'SpecialCategories' => __DIR__ . '/includes/specials/SpecialCategories.php',
+       'SpecialChangeContentModel' => __DIR__ . '/includes/specials/SpecialChangeContentModel.php',
        'SpecialChangeEmail' => __DIR__ . '/includes/specials/SpecialChangeEmail.php',
        'SpecialChangePassword' => __DIR__ . '/includes/specials/SpecialChangePassword.php',
        'SpecialComparePages' => __DIR__ . '/includes/specials/SpecialComparePages.php',
index 6f2f5b9..a755029 100644 (file)
@@ -6810,6 +6810,7 @@ $wgLogTypes = array(
        'suppress',
        'tag',
        'managetags',
+       'contentmodel',
 );
 
 /**
@@ -6944,6 +6945,7 @@ $wgLogActionsHandlers = array(
        'suppress/reblock' => 'BlockLogFormatter',
        'import/upload' => 'LogFormatter',
        'import/interwiki' => 'LogFormatter',
+       'contentmodel/change' => 'ContentModelLogFormatter',
 );
 
 /**
index 3600fb2..bf322ae 100644 (file)
@@ -1671,6 +1671,7 @@ class EditPage {
                        }
                }
 
+               $changingContentModel = false;
                if ( $this->contentModel !== $this->mTitle->getContentModel() ) {
                        if ( !$wgContentHandlerUseDB ) {
                                $status->fatal( 'editpage-cannot-use-custom-model' );
@@ -1679,7 +1680,10 @@ class EditPage {
                        } elseif ( !$wgUser->isAllowed( 'editcontentmodel' ) ) {
                                $status->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL );
                                return $status;
+
                        }
+                       $changingContentModel = true;
+                       $oldContentModel = $this->mTitle->getContentModel();
                }
 
                if ( $this->changeTags ) {
@@ -1978,9 +1982,39 @@ class EditPage {
                        } );
                }
 
+               // If the content model changed, add a log entry
+               if ( $changingContentModel ) {
+                       $this->addContentModelChangeLogEntry(
+                               $wgUser,
+                               $oldContentModel,
+                               $this->contentModel,
+                               $this->summary
+                       );
+               }
+
                return $status;
        }
 
+       /**
+        * @param Title $title
+        * @param string $oldModel
+        * @param string $newModel
+        * @param string $reason
+        */
+       protected function addContentModelChangeLogEntry( User $user, $oldModel, $newModel, $reason ) {
+               $log = new ManualLogEntry( 'contentmodel', 'change' );
+               $log->setPerformer( $user );
+               $log->setTarget( $this->mTitle );
+               $log->setComment( $reason );
+               $log->setParameters( array(
+                       '4::oldmodel' => $oldModel,
+                       '5::newmodel' => $newModel
+               ) );
+               $logid = $log->insert();
+               $log->publish( $logid );
+       }
+
+
        /**
         * Register the change of watch status
         */
index 468c7e9..bf91a4f 100644 (file)
@@ -355,16 +355,20 @@ abstract class ContentHandler {
         *
         * @param string $name The content model ID, as given by a CONTENT_MODEL_XXX
         *    constant or returned by Revision::getContentModel().
+        * @param Language|null $lang The language to parse the message in (since 1.26)
         *
         * @throws MWException If the model ID isn't known.
         * @return string The content model's localized name.
         */
-       public static function getLocalizedName( $name ) {
+       public static function getLocalizedName( $name, Language $lang = null ) {
                // Messages: content-model-wikitext, content-model-text,
                // content-model-javascript, content-model-css
                $key = "content-model-$name";
 
                $msg = wfMessage( $key );
+               if ( $lang ) {
+                       $msg->inLanguage( $lang );
+               }
 
                return $msg->exists() ? $msg->plain() : $name;
        }
diff --git a/includes/logging/ContentModelLogFormatter.php b/includes/logging/ContentModelLogFormatter.php
new file mode 100644 (file)
index 0000000..982fcc3
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+class ContentModelLogFormatter extends LogFormatter {
+       protected function getMessageParameters() {
+               $lang = $this->context->getLanguage();
+               $params = parent::getMessageParameters();
+               $params[3] = ContentHandler::getLocalizedName( $params[3], $lang );
+               $params[4] = ContentHandler::getLocalizedName( $params[4], $lang );
+               return $params;
+       }
+
+       public function getActionLinks() {
+               if ( $this->entry->isDeleted( LogPage::DELETED_ACTION ) // Action is hidden
+                       || $this->entry->getSubtype() !== 'change'
+                       || !$this->context->getUser()->isAllowed( 'editcontentmodel' )
+               ) {
+                       return '';
+               }
+
+               $params = $this->extractParameters();
+               $revert = Linker::linkKnown(
+                       SpecialPage::getTitleFor( 'ChangeContentModel' ),
+                       $this->msg( 'logentry-contentmodel-change-revertlink' )->escaped(),
+                       array(),
+                       array(
+                               'pagetitle' => $this->entry->getTarget()->getPrefixedText(),
+                               'model' => $params[3],
+                               'reason' => $this->msg( 'logentry-contentmodel-change-revert' )->inContentLanguage()->text(),
+                       )
+               );
+
+               return $this->msg( 'parentheses' )->rawParams( $revert )->escaped();
+       }
+}
index 8080b41..e794a5d 100644 (file)
@@ -218,7 +218,7 @@ class SpecialPageFactory {
                global $wgSpecialPages;
                global $wgDisableInternalSearch, $wgEmailAuthentication;
                global $wgEnableEmail, $wgEnableJavaScriptTest;
-               global $wgPageLanguageUseDB;
+               global $wgPageLanguageUseDB, $wgContentHandlerUseDB;
 
                if ( !is_array( self::$list ) ) {
 
@@ -244,6 +244,9 @@ class SpecialPageFactory {
                        if ( $wgPageLanguageUseDB ) {
                                self::$list['PageLanguage'] = 'SpecialPageLanguage';
                        }
+                       if ( $wgContentHandlerUseDB ) {
+                               self::$list['ChangeContentModel'] = 'SpecialChangeContentModel';
+                       }
 
                        self::$list['Activeusers'] = 'SpecialActiveUsers';
 
diff --git a/includes/specials/SpecialChangeContentModel.php b/includes/specials/SpecialChangeContentModel.php
new file mode 100644 (file)
index 0000000..7647999
--- /dev/null
@@ -0,0 +1,234 @@
+<?php
+
+class SpecialChangeContentModel extends FormSpecialPage {
+
+       public function __construct() {
+               parent::__construct( 'ChangeContentModel', 'editcontentmodel' );
+       }
+
+       /**
+        * @var Title|null
+        */
+       private $title;
+
+       /**
+        * @var Revision|bool|null
+        *
+        * A Revision object, false if no revision exists, null if not loaded yet
+        */
+       private $oldRevision;
+
+       protected function setParameter( $par ) {
+               $par = $this->getRequest()->getVal( 'pagetitle', $par );
+               $title = Title::newFromText( $par );
+               if ( $title ) {
+                       $this->title = $title;
+                       $this->par = $title->getPrefixedText();
+               } else {
+                       $this->par = '';
+               }
+       }
+
+       protected function getDisplayFormat() {
+               return 'ooui';
+       }
+
+       protected function alterForm( HTMLForm $form ) {
+               if ( !$this->title ) {
+                       $form->setMethod( 'GET' );
+               }
+       }
+
+       public function validateTitle( $title ) {
+               if ( !$title ) {
+                       // No form input yet
+                       return true;
+               }
+               try {
+                       $titleObj = Title::newFromTextThrow( $title );
+               } catch ( MalformedTitleException $e ) {
+                       $msg = $this->msg( $e->getErrorMessage() );
+                       $params = $e->getErrorMessageParameters();
+                       if ( $params ) {
+                               $msg->params( $params );
+                       }
+                       return $msg->parse();
+               }
+               if ( !$titleObj->canExist() ) {
+                       return $this->msg(
+                               'changecontentmodel-title-cantexist',
+                               $titleObj->getPrefixedText()
+                       )->escaped();
+               }
+
+               $this->oldRevision = Revision::newFromTitle( $titleObj ) ?: false;
+
+               if ( $this->oldRevision ) {
+                       $oldContent = $this->oldRevision->getContent();
+                       if ( !$oldContent->getContentHandler()->supportsDirectEditing() ) {
+                               return $this->msg( 'changecontentmodel-nodirectediting' )
+                                       ->params( ContentHandler::getLocalizedName( $oldContent->getModel() ) )
+                                       ->escaped();
+                       }
+               }
+
+               return true;
+       }
+
+       protected function getFormFields() {
+               $that = $this;
+               $fields = array(
+                       'pagetitle' => array(
+                               'type' => 'text',
+                               'name' => 'pagetitle',
+                               'default' => $this->par,
+                               'label-message' => 'changecontentmodel-title-label',
+                               'validation-callback' => array( $this, 'validateTitle' ),
+                       ),
+               );
+               if ( $this->title ) {
+                       $fields['pagetitle']['readonly'] = true;
+                       $fields += array(
+                               'model' => array(
+                                       'type' => 'select',
+                                       'name' => 'model',
+                                       'options' => $this->getOptionsForTitle( $this->title ),
+                                       'label-message' => 'changecontentmodel-model-label'
+                               ),
+                               'reason' => array(
+                                       'type' => 'text',
+                                       'name' => 'reason',
+                                       'validation-callback' => function( $reason ) use ( $that ) {
+                                               $match = EditPage::matchSummarySpamRegex( $reason );
+                                               if ( $match ) {
+                                                       return $that->msg( 'spamprotectionmatch', $match )->parse();
+                                               }
+
+                                               return true;
+                                       },
+                                       'label-message' => 'changecontentmodel-reason-label',
+                               ),
+                       );
+               }
+
+               return $fields;
+       }
+
+       private function getOptionsForTitle( Title $title = null ) {
+               $models = ContentHandler::getContentModels();
+               $options = array();
+               foreach ( $models as $model ) {
+                       $handler = ContentHandler::getForModelID( $model );
+                       if ( !$handler->supportsDirectEditing() ) {
+                               continue;
+                       }
+                       if ( $title ) {
+                               if ( $title->getContentModel() === $model ) {
+                                       continue;
+                               }
+                               if ( !$handler->canBeUsedOn( $title ) ) {
+                                       continue;
+                               }
+                       }
+                       $options[ContentHandler::getLocalizedName( $model )] = $model;
+               }
+
+               return $options;
+       }
+
+       public function onSubmit( array $data ) {
+               global $wgContLang;
+
+               if ( $data['pagetitle'] === '' ) {
+                       // Initial form view of special page, pass
+                       return false;
+               }
+
+               // At this point, it has to be a POST request. This is enforced by HTMLForm,
+               // but lets be safe verify that.
+               if ( !$this->getRequest()->wasPosted() ) {
+                       throw new RuntimeException( "Form submission was not POSTed" );
+               }
+
+               $this->title = Title::newFromText( $data['pagetitle' ] );
+               $user = $this->getUser();
+               // Check permissions and make sure the user has permission to edit the specific page
+               $errors = $this->title->getUserPermissionsErrors( 'editcontentmodel', $user );
+               $errors = wfMergeErrorArrays( $errors, $this->title->getUserPermissionsErrors( 'edit', $user ) );
+               if ( $errors ) {
+                       $out = $this->getOutput();
+                       $wikitext = $out->formatPermissionsErrorMessage( $errors );
+                       // Hack to get our wikitext parsed
+                       return Status::newFatal( new RawMessage( '$1', array( $wikitext ) ) );
+               }
+
+               $page = WikiPage::factory( $this->title );
+               if ( $this->oldRevision === null ) {
+                       $this->oldRevision = $page->getRevision() ?: false;
+               }
+               $oldModel = $this->title->getContentModel();
+               if ( $this->oldRevision ) {
+                       $oldContent = $this->oldRevision->getContent();
+                       try {
+                               $newContent = ContentHandler::makeContent(
+                                       $oldContent->getNativeData(), $this->title, $data['model']
+                               );
+                       } catch ( MWException $e ) {
+                               return Status::newFatal(
+                                       $this->msg( 'changecontentmodel-cannot-convert' )
+                                               ->params(
+                                                       $this->title->getPrefixedText(),
+                                                       ContentHandler::getLocalizedName( $data['model'] )
+                                               )
+                               );
+                       }
+               } else {
+                       // Page doesn't exist, create an empty content object
+                       $newContent = ContentHandler::getForModelID( $data['model'] )->makeEmptyContent();
+               }
+               $flags = $this->oldRevision ? EDIT_UPDATE : EDIT_NEW;
+               if ( $user->isAllowed( 'bot' ) ) {
+                       $flags |= EDIT_FORCE_BOT;
+               }
+
+               $log = new ManualLogEntry( 'contentmodel', 'change' );
+               $log->setPerformer( $user );
+               $log->setTarget( $this->title );
+               $log->setComment( $data['reason'] );
+               $log->setParameters( array(
+                       '4::oldmodel' => $oldModel,
+                       '5::newmodel' => $data['model']
+               ) );
+
+               $formatter = LogFormatter::newFromEntry( $log );
+               $formatter->setContext( RequestContext::newExtraneousContext( $this->title ) );
+               $reason = $formatter->getPlainActionText();
+               if ( $data['reason'] !== '' ) {
+                       $reason .= $this->msg( 'colon-separator' )->inContentLanguage()->text() . $data['reason'];
+               }
+               # Truncate for whole multibyte characters.
+               $reason = $wgContLang->truncate( $reason, 255 );
+
+               $status = $page->doEditContent(
+                       $newContent,
+                       $reason,
+                       $flags,
+                       $this->oldRevision ? $this->oldRevision->getId() : false,
+                       $user
+               );
+               if ( !$status->isOK() ) {
+                       return $status;
+               }
+
+               $logid = $log->insert();
+               $log->publish( $logid );
+
+               return $status;
+       }
+
+       public function onSuccess() {
+               $out = $this->getOutput();
+               $out->setPageTitle( $this->msg( 'changecontentmodel-success-title' ) );
+               $out->addWikiMsg( 'changecontentmodel-success-text', $this->title );
+       }
+}
index 41a12d9..c899844 100644 (file)
        "rollback-success": "Reverted edits by $1;\nchanged back to last revision by $2.",
        "sessionfailure-title": "Session failure",
        "sessionfailure": "There seems to be a problem with your login session;\nthis action has been canceled as a precaution against session hijacking.\nGo back to the previous page, reload that page and then try again.",
+       "changecontentmodel" : "Change content model of a page",
+       "changecontentmodel-legend": "Change content model",
+       "changecontentmodel-title-label": "Page title",
+       "changecontentmodel-model-label": "New content model",
+       "changecontentmodel-reason-label": "Reason:",
+       "changecontentmodel-success-title": "The content model was changed",
+       "changecontentmodel-success-text": "The content type of [[:$1]] has been changed.",
+       "changecontentmodel-cannot-convert": "The content on [[:$1]] cannot be converted to a type of $2.",
+       "changecontentmodel-title-cantexist": "It is not possible to have a page at $1.",
+       "changecontentmodel-nodirectediting": "The $1 content model does not support direct editing",
+       "log-name-contentmodel": "Content model change log",
+       "log-description-contentmodel": "Events related to the content models of a page",
+       "logentry-contentmodel-change": "$1 changed the content model of the page $3 from \"$4\" to \"$5\"",
+       "logentry-contentmodel-change-revertlink": "revert",
+       "logentry-contentmodel-change-revert": "revert",
        "protectlogpage": "Protection log",
        "protectlogtext": "Below is a list of changes to page protections.\nSee the [[Special:ProtectedPages|protected pages list]] for the list of currently operational page protections.",
        "protectedarticle": "protected \"[[$1]]\"",
index aec2308..3bcab84 100644 (file)
        "rollback-success": "This message shows up on screen after successful revert (generally visible only to admins). $1 describes user whose changes have been reverted, $2 describes user which produced version, which replaces reverted version.\n{{Identical|Revert}}\n{{Identical|Rollback}}",
        "sessionfailure-title": "Used as title of the error message {{msg-mw|Sessionfailure}}.",
        "sessionfailure": "Used as error message.\n\nThe title for this error message is {{msg-mw|Sessionfailure-title}}.",
+       "changecontentmodel" : "Title of the change content model special page",
+       "changecontentmodel-legend": "Legend of the fieldset on the change content model special page",
+       "changecontentmodel-title-label": "Label for the input field where the target page title should be entered",
+       "changecontentmodel-model-label": "Label of the dropdown listing available content model types the user can change a page to",
+       "changecontentmodel-reason-label": "{{Identical|Reason}}",
+       "changecontentmodel-success-title": "Title of the success page of the change content model special page",
+       "changecontentmodel-success-text": "Message telling user that their change has been successfully done.\n* $1 - Target page title",
+       "changecontentmodel-cannot-convert": "Error message shown if the content model cannot be changed to the specified type. $1 is the page title, $2 is the localized content model name.",
+       "changecontentmodel-title-cantexist": "Error message shown if the page the user provided is a special page",
+       "changecontentmodel-nodirectediting": "Error message shown if the content model does not allow for direct editing. $1 is the localized name of the content model.",
+       "log-name-contentmodel": "{{doc-logpage}}\n\nTitle of [[Special:Log/contentmodel]].",
+       "log-description-contentmodel": "Text in [[Special:Log/contentmodel]].",
+       "logentry-contentmodel-change": "{{Logentry}}\n$4 is the original content model.\n$5 is the new content model.",
+       "logentry-contentmodel-change-revertlink": "Text on a link that reverts the content model change. {{identical|revertmove}}.",
+       "logentry-contentmodel-change-revert": "Prefilled edit summary when reverting a content model change. {{identical|revertmove}}",
        "protectlogpage": "{{doc-logpage}}\n\nTitle of [[Special:Log/protect]].",
        "protectlogtext": "Text in [[Special:Log/protect]].",
        "protectedarticle": "Text describing an action on [[Special:Log]]. $1 is a page title.",