From fa31bf1675af7255431aeffd9757066e0ce572e2 Mon Sep 17 00:00:00 2001 From: Kunal Mehta Date: Sat, 28 Mar 2015 20:36:01 -0700 Subject: [PATCH] Add Special:ChangeContentModel 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= 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 Change-Id: I296a67c09fcbc880c8c3a648eb5086580725ea46 --- autoload.php | 2 + includes/DefaultSettings.php | 2 + includes/EditPage.php | 34 +++ includes/content/ContentHandler.php | 6 +- includes/logging/ContentModelLogFormatter.php | 34 +++ includes/specialpage/SpecialPageFactory.php | 5 +- .../specials/SpecialChangeContentModel.php | 234 ++++++++++++++++++ languages/i18n/en.json | 15 ++ languages/i18n/qqq.json | 15 ++ 9 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 includes/logging/ContentModelLogFormatter.php create mode 100644 includes/specials/SpecialChangeContentModel.php diff --git a/autoload.php b/autoload.php index 284da46b37..926677622f 100644 --- a/autoload.php +++ b/autoload.php @@ -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', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 6f2f5b9ba4..a755029da5 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -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', ); /** diff --git a/includes/EditPage.php b/includes/EditPage.php index 3600fb221a..bf322aeb1b 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -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 */ diff --git a/includes/content/ContentHandler.php b/includes/content/ContentHandler.php index 468c7e9581..bf91a4f02e 100644 --- a/includes/content/ContentHandler.php +++ b/includes/content/ContentHandler.php @@ -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 index 0000000000..982fcc308c --- /dev/null +++ b/includes/logging/ContentModelLogFormatter.php @@ -0,0 +1,34 @@ +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(); + } +} diff --git a/includes/specialpage/SpecialPageFactory.php b/includes/specialpage/SpecialPageFactory.php index 8080b41626..e794a5dfdc 100644 --- a/includes/specialpage/SpecialPageFactory.php +++ b/includes/specialpage/SpecialPageFactory.php @@ -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 index 0000000000..7647999813 --- /dev/null +++ b/includes/specials/SpecialChangeContentModel.php @@ -0,0 +1,234 @@ +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 ); + } +} diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 41a12d93c3..c899844731 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1941,6 +1941,21 @@ "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]]\"", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index aec2308d7c..3bcab84e57 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -2111,6 +2111,21 @@ "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.", -- 2.20.1