', true, /* interface */true );
+ }
+
+ if ( $content === false ) {
+ $content = $this->getOriginalContent( $user );
+ }
+ }
+ }
+
+ return $content;
+ }
+
+ /**
+ * Get the content of the wanted revision, without section extraction.
+ *
+ * The result of this function can be used to compare user's input with
+ * section replaced in its context (using WikiPage::replaceSectionAtRev())
+ * to the original text of the edit.
+ *
+ * This differs from Article::getContent() that when a missing revision is
+ * encountered the result will be null and not the
+ * 'missing-revision' message.
+ *
+ * @since 1.19
+ * @param User $user The user to get the revision for
+ * @return Content|null
+ */
+ private function getOriginalContent( User $user ) {
+ if ( $this->section == 'new' ) {
+ return $this->getCurrentContent();
+ }
+ $revision = $this->mArticle->getRevisionFetched();
+ if ( $revision === null ) {
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+ return $handler->makeEmptyContent();
+ }
+ $content = $revision->getContent( Revision::FOR_THIS_USER, $user );
+ return $content;
+ }
+
+ /**
+ * Get the edit's parent revision ID
+ *
+ * The "parent" revision is the ancestor that should be recorded in this
+ * page's revision history. It is either the revision ID of the in-memory
+ * article content, or in the case of a 3-way merge in order to rebase
+ * across a recoverable edit conflict, the ID of the newer revision to
+ * which we have rebased this page.
+ *
+ * @since 1.27
+ * @return int Revision ID
+ */
+ public function getParentRevId() {
+ if ( $this->parentRevId ) {
+ return $this->parentRevId;
+ } else {
+ return $this->mArticle->getRevIdFetched();
+ }
+ }
+
+ /**
+ * Get the current content of the page. This is basically similar to
+ * WikiPage::getContent( Revision::RAW ) except that when the page doesn't exist an empty
+ * content object is returned instead of null.
+ *
+ * @since 1.21
+ * @return Content
+ */
+ protected function getCurrentContent() {
+ $rev = $this->page->getRevision();
+ $content = $rev ? $rev->getContent( Revision::RAW ) : null;
+
+ if ( $content === false || $content === null ) {
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+ return $handler->makeEmptyContent();
+ } elseif ( !$this->undidRev ) {
+ // Content models should always be the same since we error
+ // out if they are different before this point (in ->edit()).
+ // The exception being, during an undo, the current revision might
+ // differ from the prior revision.
+ $logger = LoggerFactory::getInstance( 'editpage' );
+ if ( $this->contentModel !== $rev->getContentModel() ) {
+ $logger->warning( "Overriding content model from current edit {prev} to {new}", [
+ 'prev' => $this->contentModel,
+ 'new' => $rev->getContentModel(),
+ 'title' => $this->getTitle()->getPrefixedDBkey(),
+ 'method' => __METHOD__
+ ] );
+ $this->contentModel = $rev->getContentModel();
+ }
+
+ // Given that the content models should match, the current selected
+ // format should be supported.
+ if ( !$content->isSupportedFormat( $this->contentFormat ) ) {
+ $logger->warning( "Current revision content format unsupported. Overriding {prev} to {new}", [
+
+ 'prev' => $this->contentFormat,
+ 'new' => $rev->getContentFormat(),
+ 'title' => $this->getTitle()->getPrefixedDBkey(),
+ 'method' => __METHOD__
+ ] );
+ $this->contentFormat = $rev->getContentFormat();
+ }
+ }
+ return $content;
+ }
+
+ /**
+ * Use this method before edit() to preload some content into the edit box
+ *
+ * @param Content $content
+ *
+ * @since 1.21
+ */
+ public function setPreloadedContent( Content $content ) {
+ $this->mPreloadContent = $content;
+ }
+
+ /**
+ * Get the contents to be preloaded into the box, either set by
+ * an earlier setPreloadText() or by loading the given page.
+ *
+ * @param string $preload Representing the title to preload from.
+ * @param array $params Parameters to use (interface-message style) in the preloaded text
+ *
+ * @return Content
+ *
+ * @since 1.21
+ */
+ protected function getPreloadedContent( $preload, $params = [] ) {
+ if ( !empty( $this->mPreloadContent ) ) {
+ return $this->mPreloadContent;
+ }
+
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+
+ if ( $preload === '' ) {
+ return $handler->makeEmptyContent();
+ }
+
+ $user = $this->context->getUser();
+ $title = Title::newFromText( $preload );
+ # Check for existence to avoid getting MediaWiki:Noarticletext
+ if ( $title === null || !$title->exists() || !$title->userCan( 'read', $user ) ) {
+ // TODO: somehow show a warning to the user!
+ return $handler->makeEmptyContent();
+ }
+
+ $page = WikiPage::factory( $title );
+ if ( $page->isRedirect() ) {
+ $title = $page->getRedirectTarget();
+ # Same as before
+ if ( $title === null || !$title->exists() || !$title->userCan( 'read', $user ) ) {
+ // TODO: somehow show a warning to the user!
+ return $handler->makeEmptyContent();
+ }
+ $page = WikiPage::factory( $title );
+ }
+
+ $parserOptions = ParserOptions::newFromUser( $user );
+ $content = $page->getContent( Revision::RAW );
+
+ if ( !$content ) {
+ // TODO: somehow show a warning to the user!
+ return $handler->makeEmptyContent();
+ }
+
+ if ( $content->getModel() !== $handler->getModelID() ) {
+ $converted = $content->convert( $handler->getModelID() );
+
+ if ( !$converted ) {
+ // TODO: somehow show a warning to the user!
+ wfDebug( "Attempt to preload incompatible content: " .
+ "can't convert " . $content->getModel() .
+ " to " . $handler->getModelID() );
+
+ return $handler->makeEmptyContent();
+ }
+
+ $content = $converted;
+ }
+
+ return $content->preloadTransform( $title, $parserOptions, $params );
+ }
+
+ /**
+ * Make sure the form isn't faking a user's credentials.
+ *
+ * @param WebRequest &$request
+ * @return bool
+ * @private
+ */
+ public function tokenOk( &$request ) {
+ $token = $request->getVal( 'wpEditToken' );
+ $user = $this->context->getUser();
+ $this->mTokenOk = $user->matchEditToken( $token );
+ $this->mTokenOkExceptSuffix = $user->matchEditTokenNoSuffix( $token );
+ return $this->mTokenOk;
+ }
+
+ /**
+ * Sets post-edit cookie indicating the user just saved a particular revision.
+ *
+ * This uses a temporary cookie for each revision ID so separate saves will never
+ * interfere with each other.
+ *
+ * Article::view deletes the cookie on server-side after the redirect and
+ * converts the value to the global JavaScript variable wgPostEdit.
+ *
+ * If the variable were set on the server, it would be cached, which is unwanted
+ * since the post-edit state should only apply to the load right after the save.
+ *
+ * @param int $statusValue The status value (to check for new article status)
+ */
+ protected function setPostEditCookie( $statusValue ) {
+ $revisionId = $this->page->getLatest();
+ $postEditKey = self::POST_EDIT_COOKIE_KEY_PREFIX . $revisionId;
+
+ $val = 'saved';
+ if ( $statusValue == self::AS_SUCCESS_NEW_ARTICLE ) {
+ $val = 'created';
+ } elseif ( $this->oldid ) {
+ $val = 'restored';
+ }
+
+ $response = $this->context->getRequest()->response();
+ $response->setCookie( $postEditKey, $val, time() + self::POST_EDIT_COOKIE_DURATION );
+ }
+
+ /**
+ * Attempt submission
+ * @param array|bool &$resultDetails See docs for $result in internalAttemptSave
+ * @throws UserBlockedError|ReadOnlyError|ThrottledError|PermissionsError
+ * @return Status The resulting status object.
+ */
+ public function attemptSave( &$resultDetails = false ) {
+ # Allow bots to exempt some edits from bot flagging
+ $bot = $this->context->getUser()->isAllowed( 'bot' ) && $this->bot;
+ $status = $this->internalAttemptSave( $resultDetails, $bot );
+
+ Hooks::run( 'EditPage::attemptSave:after', [ $this, $status, $resultDetails ] );
+
+ return $status;
+ }
+
+ /**
+ * Log when a page was successfully saved after the edit conflict view
+ */
+ private function incrementResolvedConflicts() {
+ if ( $this->context->getRequest()->getText( 'mode' ) !== 'conflict' ) {
+ return;
+ }
+
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $stats->increment( 'edit.failures.conflict.resolved' );
+ }
+
+ /**
+ * Handle status, such as after attempt save
+ *
+ * @param Status $status
+ * @param array|bool $resultDetails
+ *
+ * @throws ErrorPageError
+ * @return bool False, if output is done, true if rest of the form should be displayed
+ */
+ private function handleStatus( Status $status, $resultDetails ) {
+ /**
+ * @todo FIXME: once the interface for internalAttemptSave() is made
+ * nicer, this should use the message in $status
+ */
+ if ( $status->value == self::AS_SUCCESS_UPDATE
+ || $status->value == self::AS_SUCCESS_NEW_ARTICLE
+ ) {
+ $this->incrementResolvedConflicts();
+
+ $this->didSave = true;
+ if ( !$resultDetails['nullEdit'] ) {
+ $this->setPostEditCookie( $status->value );
+ }
+ }
+
+ $out = $this->context->getOutput();
+
+ // "wpExtraQueryRedirect" is a hidden input to modify
+ // after save URL and is not used by actual edit form
+ $request = $this->context->getRequest();
+ $extraQueryRedirect = $request->getVal( 'wpExtraQueryRedirect' );
+
+ switch ( $status->value ) {
+ case self::AS_HOOK_ERROR_EXPECTED:
+ case self::AS_CONTENT_TOO_BIG:
+ case self::AS_ARTICLE_WAS_DELETED:
+ case self::AS_CONFLICT_DETECTED:
+ case self::AS_SUMMARY_NEEDED:
+ case self::AS_TEXTBOX_EMPTY:
+ case self::AS_MAX_ARTICLE_SIZE_EXCEEDED:
+ case self::AS_END:
+ case self::AS_BLANK_ARTICLE:
+ case self::AS_SELF_REDIRECT:
+ return true;
+
+ case self::AS_HOOK_ERROR:
+ return false;
+
+ case self::AS_CANNOT_USE_CUSTOM_MODEL:
+ case self::AS_PARSE_ERROR:
+ case self::AS_UNICODE_NOT_SUPPORTED:
+ $out->addWikiText( '
' . "\n" . $status->getWikiText() . '
' );
+ return true;
+
+ case self::AS_SUCCESS_NEW_ARTICLE:
+ $query = $resultDetails['redirect'] ? 'redirect=no' : '';
+ if ( $extraQueryRedirect ) {
+ if ( $query === '' ) {
+ $query = $extraQueryRedirect;
+ } else {
+ $query = $query . '&' . $extraQueryRedirect;
+ }
+ }
+ $anchor = isset( $resultDetails['sectionanchor'] ) ? $resultDetails['sectionanchor'] : '';
+ $out->redirect( $this->mTitle->getFullURL( $query ) . $anchor );
+ return false;
+
+ case self::AS_SUCCESS_UPDATE:
+ $extraQuery = '';
+ $sectionanchor = $resultDetails['sectionanchor'];
+
+ // Give extensions a chance to modify URL query on update
+ Hooks::run(
+ 'ArticleUpdateBeforeRedirect',
+ [ $this->mArticle, &$sectionanchor, &$extraQuery ]
+ );
+
+ if ( $resultDetails['redirect'] ) {
+ if ( $extraQuery == '' ) {
+ $extraQuery = 'redirect=no';
+ } else {
+ $extraQuery = 'redirect=no&' . $extraQuery;
+ }
+ }
+ if ( $extraQueryRedirect ) {
+ if ( $extraQuery === '' ) {
+ $extraQuery = $extraQueryRedirect;
+ } else {
+ $extraQuery = $extraQuery . '&' . $extraQueryRedirect;
+ }
+ }
+
+ $out->redirect( $this->mTitle->getFullURL( $extraQuery ) . $sectionanchor );
+ return false;
+
+ case self::AS_SPAM_ERROR:
+ $this->spamPageWithContent( $resultDetails['spam'] );
+ return false;
+
+ case self::AS_BLOCKED_PAGE_FOR_USER:
+ throw new UserBlockedError( $this->context->getUser()->getBlock() );
+
+ case self::AS_IMAGE_REDIRECT_ANON:
+ case self::AS_IMAGE_REDIRECT_LOGGED:
+ throw new PermissionsError( 'upload' );
+
+ case self::AS_READ_ONLY_PAGE_ANON:
+ case self::AS_READ_ONLY_PAGE_LOGGED:
+ throw new PermissionsError( 'edit' );
+
+ case self::AS_READ_ONLY_PAGE:
+ throw new ReadOnlyError;
+
+ case self::AS_RATE_LIMITED:
+ throw new ThrottledError();
+
+ case self::AS_NO_CREATE_PERMISSION:
+ $permission = $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage';
+ throw new PermissionsError( $permission );
+
+ case self::AS_NO_CHANGE_CONTENT_MODEL:
+ throw new PermissionsError( 'editcontentmodel' );
+
+ default:
+ // We don't recognize $status->value. The only way that can happen
+ // is if an extension hook aborted from inside ArticleSave.
+ // Render the status object into $this->hookError
+ // FIXME this sucks, we should just use the Status object throughout
+ $this->hookError = '
' ."\n" . $status->getWikiText() .
+ '
';
+ return true;
+ }
+ }
+
+ /**
+ * Run hooks that can filter edits just before they get saved.
+ *
+ * @param Content $content The Content to filter.
+ * @param Status $status For reporting the outcome to the caller
+ * @param User $user The user performing the edit
+ *
+ * @return bool
+ */
+ protected function runPostMergeFilters( Content $content, Status $status, User $user ) {
+ // Run old style post-section-merge edit filter
+ if ( $this->hookError != '' ) {
+ # ...or the hook could be expecting us to produce an error
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR_EXPECTED;
+ return false;
+ }
+
+ // Run new style post-section-merge edit filter
+ if ( !Hooks::run( 'EditFilterMergedContent',
+ [ $this->context, $content, $status, $this->summary,
+ $user, $this->minoredit ] )
+ ) {
+ # Error messages etc. could be handled within the hook...
+ if ( $status->isGood() ) {
+ $status->fatal( 'hookaborted' );
+ // Not setting $this->hookError here is a hack to allow the hook
+ // to cause a return to the edit page without $this->hookError
+ // being set. This is used by ConfirmEdit to display a captcha
+ // without any error message cruft.
+ } else {
+ $this->hookError = $status->getWikiText();
+ }
+ // Use the existing $status->value if the hook set it
+ if ( !$status->value ) {
+ $status->value = self::AS_HOOK_ERROR;
+ }
+ return false;
+ } elseif ( !$status->isOK() ) {
+ # ...or the hook could be expecting us to produce an error
+ // FIXME this sucks, we should just use the Status object throughout
+ $this->hookError = $status->getWikiText();
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR_EXPECTED;
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Return the summary to be used for a new section.
+ *
+ * @param string $sectionanchor Set to the section anchor text
+ * @return string
+ */
+ private function newSectionSummary( &$sectionanchor = null ) {
+ global $wgParser;
+
+ if ( $this->sectiontitle !== '' ) {
+ $sectionanchor = $this->guessSectionName( $this->sectiontitle );
+ // If no edit summary was specified, create one automatically from the section
+ // title and have it link to the new section. Otherwise, respect the summary as
+ // passed.
+ if ( $this->summary === '' ) {
+ $cleanSectionTitle = $wgParser->stripSectionName( $this->sectiontitle );
+ return $this->context->msg( 'newsectionsummary' )
+ ->rawParams( $cleanSectionTitle )->inContentLanguage()->text();
+ }
+ } elseif ( $this->summary !== '' ) {
+ $sectionanchor = $this->guessSectionName( $this->summary );
+ # This is a new section, so create a link to the new section
+ # in the revision summary.
+ $cleanSummary = $wgParser->stripSectionName( $this->summary );
+ return $this->context->msg( 'newsectionsummary' )
+ ->rawParams( $cleanSummary )->inContentLanguage()->text();
+ }
+ return $this->summary;
+ }
+
+ /**
+ * Attempt submission (no UI)
+ *
+ * @param array &$result Array to add statuses to, currently with the
+ * possible keys:
+ * - spam (string): Spam string from content if any spam is detected by
+ * matchSpamRegex.
+ * - sectionanchor (string): Section anchor for a section save.
+ * - nullEdit (bool): Set if doEditContent is OK. True if null edit,
+ * false otherwise.
+ * - redirect (bool): Set if doEditContent is OK. True if resulting
+ * revision is a redirect.
+ * @param bool $bot True if edit is being made under the bot right.
+ *
+ * @return Status Status object, possibly with a message, but always with
+ * one of the AS_* constants in $status->value,
+ *
+ * @todo FIXME: This interface is TERRIBLE, but hard to get rid of due to
+ * various error display idiosyncrasies. There are also lots of cases
+ * where error metadata is set in the object and retrieved later instead
+ * of being returned, e.g. AS_CONTENT_TOO_BIG and
+ * AS_BLOCKED_PAGE_FOR_USER. All that stuff needs to be cleaned up some
+ * time.
+ */
+ public function internalAttemptSave( &$result, $bot = false ) {
+ $status = Status::newGood();
+ $user = $this->context->getUser();
+
+ if ( !Hooks::run( 'EditPage::attemptSave', [ $this ] ) ) {
+ wfDebug( "Hook 'EditPage::attemptSave' aborted article saving\n" );
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR;
+ return $status;
+ }
+
+ if ( $this->unicodeCheck !== self::UNICODE_CHECK ) {
+ $status->fatal( 'unicode-support-fail' );
+ $status->value = self::AS_UNICODE_NOT_SUPPORTED;
+ return $status;
+ }
+
+ $request = $this->context->getRequest();
+ $spam = $request->getText( 'wpAntispam' );
+ if ( $spam !== '' ) {
+ wfDebugLog(
+ 'SimpleAntiSpam',
+ $user->getName() .
+ ' editing "' .
+ $this->mTitle->getPrefixedText() .
+ '" submitted bogus field "' .
+ $spam .
+ '"'
+ );
+ $status->fatal( 'spamprotectionmatch', false );
+ $status->value = self::AS_SPAM_ERROR;
+ return $status;
+ }
+
+ try {
+ # Construct Content object
+ $textbox_content = $this->toEditContent( $this->textbox1 );
+ } catch ( MWContentSerializationException $ex ) {
+ $status->fatal(
+ 'content-failed-to-parse',
+ $this->contentModel,
+ $this->contentFormat,
+ $ex->getMessage()
+ );
+ $status->value = self::AS_PARSE_ERROR;
+ return $status;
+ }
+
+ # Check image redirect
+ if ( $this->mTitle->getNamespace() == NS_FILE &&
+ $textbox_content->isRedirect() &&
+ !$user->isAllowed( 'upload' )
+ ) {
+ $code = $user->isAnon() ? self::AS_IMAGE_REDIRECT_ANON : self::AS_IMAGE_REDIRECT_LOGGED;
+ $status->setResult( false, $code );
+
+ return $status;
+ }
+
+ # Check for spam
+ $match = self::matchSummarySpamRegex( $this->summary );
+ if ( $match === false && $this->section == 'new' ) {
+ # $wgSpamRegex is enforced on this new heading/summary because, unlike
+ # regular summaries, it is added to the actual wikitext.
+ if ( $this->sectiontitle !== '' ) {
+ # This branch is taken when the API is used with the 'sectiontitle' parameter.
+ $match = self::matchSpamRegex( $this->sectiontitle );
+ } else {
+ # This branch is taken when the "Add Topic" user interface is used, or the API
+ # is used with the 'summary' parameter.
+ $match = self::matchSpamRegex( $this->summary );
+ }
+ }
+ if ( $match === false ) {
+ $match = self::matchSpamRegex( $this->textbox1 );
+ }
+ if ( $match !== false ) {
+ $result['spam'] = $match;
+ $ip = $request->getIP();
+ $pdbk = $this->mTitle->getPrefixedDBkey();
+ $match = str_replace( "\n", '', $match );
+ wfDebugLog( 'SpamRegex', "$ip spam regex hit [[$pdbk]]: \"$match\"" );
+ $status->fatal( 'spamprotectionmatch', $match );
+ $status->value = self::AS_SPAM_ERROR;
+ return $status;
+ }
+ if ( !Hooks::run(
+ 'EditFilter',
+ [ $this, $this->textbox1, $this->section, &$this->hookError, $this->summary ] )
+ ) {
+ # Error messages etc. could be handled within the hook...
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR;
+ return $status;
+ } elseif ( $this->hookError != '' ) {
+ # ...or the hook could be expecting us to produce an error
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR_EXPECTED;
+ return $status;
+ }
+
+ if ( $user->isBlockedFrom( $this->mTitle, false ) ) {
+ // Auto-block user's IP if the account was "hard" blocked
+ if ( !wfReadOnly() ) {
+ $user->spreadAnyEditBlock();
+ }
+ # Check block state against master, thus 'false'.
+ $status->setResult( false, self::AS_BLOCKED_PAGE_FOR_USER );
+ return $status;
+ }
+
+ $this->contentLength = strlen( $this->textbox1 );
+ $config = $this->context->getConfig();
+ $maxArticleSize = $config->get( 'MaxArticleSize' );
+ if ( $this->contentLength > $maxArticleSize * 1024 ) {
+ // Error will be displayed by showEditForm()
+ $this->tooBig = true;
+ $status->setResult( false, self::AS_CONTENT_TOO_BIG );
+ return $status;
+ }
+
+ if ( !$user->isAllowed( 'edit' ) ) {
+ if ( $user->isAnon() ) {
+ $status->setResult( false, self::AS_READ_ONLY_PAGE_ANON );
+ return $status;
+ } else {
+ $status->fatal( 'readonlytext' );
+ $status->value = self::AS_READ_ONLY_PAGE_LOGGED;
+ return $status;
+ }
+ }
+
+ $changingContentModel = false;
+ if ( $this->contentModel !== $this->mTitle->getContentModel() ) {
+ if ( !$config->get( 'ContentHandlerUseDB' ) ) {
+ $status->fatal( 'editpage-cannot-use-custom-model' );
+ $status->value = self::AS_CANNOT_USE_CUSTOM_MODEL;
+ return $status;
+ } elseif ( !$user->isAllowed( 'editcontentmodel' ) ) {
+ $status->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL );
+ return $status;
+ }
+ // Make sure the user can edit the page under the new content model too
+ $titleWithNewContentModel = clone $this->mTitle;
+ $titleWithNewContentModel->setContentModel( $this->contentModel );
+ if ( !$titleWithNewContentModel->userCan( 'editcontentmodel', $user )
+ || !$titleWithNewContentModel->userCan( 'edit', $user )
+ ) {
+ $status->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL );
+ return $status;
+ }
+
+ $changingContentModel = true;
+ $oldContentModel = $this->mTitle->getContentModel();
+ }
+
+ if ( $this->changeTags ) {
+ $changeTagsStatus = ChangeTags::canAddTagsAccompanyingChange(
+ $this->changeTags, $user );
+ if ( !$changeTagsStatus->isOK() ) {
+ $changeTagsStatus->value = self::AS_CHANGE_TAG_ERROR;
+ return $changeTagsStatus;
+ }
+ }
+
+ if ( wfReadOnly() ) {
+ $status->fatal( 'readonlytext' );
+ $status->value = self::AS_READ_ONLY_PAGE;
+ return $status;
+ }
+ if ( $user->pingLimiter() || $user->pingLimiter( 'linkpurge', 0 )
+ || ( $changingContentModel && $user->pingLimiter( 'editcontentmodel' ) )
+ ) {
+ $status->fatal( 'actionthrottledtext' );
+ $status->value = self::AS_RATE_LIMITED;
+ return $status;
+ }
+
+ # If the article has been deleted while editing, don't save it without
+ # confirmation
+ if ( $this->wasDeletedSinceLastEdit() && !$this->recreate ) {
+ $status->setResult( false, self::AS_ARTICLE_WAS_DELETED );
+ return $status;
+ }
+
+ # Load the page data from the master. If anything changes in the meantime,
+ # we detect it by using page_latest like a token in a 1 try compare-and-swap.
+ $this->page->loadPageData( 'fromdbmaster' );
+ $new = !$this->page->exists();
+
+ if ( $new ) {
+ // Late check for create permission, just in case *PARANOIA*
+ if ( !$this->mTitle->userCan( 'create', $user ) ) {
+ $status->fatal( 'nocreatetext' );
+ $status->value = self::AS_NO_CREATE_PERMISSION;
+ wfDebug( __METHOD__ . ": no create permission\n" );
+ return $status;
+ }
+
+ // Don't save a new page if it's blank or if it's a MediaWiki:
+ // message with content equivalent to default (allow empty pages
+ // in this case to disable messages, see T52124)
+ $defaultMessageText = $this->mTitle->getDefaultMessageText();
+ if ( $this->mTitle->getNamespace() === NS_MEDIAWIKI && $defaultMessageText !== false ) {
+ $defaultText = $defaultMessageText;
+ } else {
+ $defaultText = '';
+ }
+
+ if ( !$this->allowBlankArticle && $this->textbox1 === $defaultText ) {
+ $this->blankArticle = true;
+ $status->fatal( 'blankarticle' );
+ $status->setResult( false, self::AS_BLANK_ARTICLE );
+ return $status;
+ }
+
+ if ( !$this->runPostMergeFilters( $textbox_content, $status, $user ) ) {
+ return $status;
+ }
+
+ $content = $textbox_content;
+
+ $result['sectionanchor'] = '';
+ if ( $this->section == 'new' ) {
+ if ( $this->sectiontitle !== '' ) {
+ // Insert the section title above the content.
+ $content = $content->addSectionHeader( $this->sectiontitle );
+ } elseif ( $this->summary !== '' ) {
+ // Insert the section title above the content.
+ $content = $content->addSectionHeader( $this->summary );
+ }
+ $this->summary = $this->newSectionSummary( $result['sectionanchor'] );
+ }
+
+ $status->value = self::AS_SUCCESS_NEW_ARTICLE;
+
+ } else { # not $new
+
+ # Article exists. Check for edit conflict.
+
+ $this->page->clear(); # Force reload of dates, etc.
+ $timestamp = $this->page->getTimestamp();
+ $latest = $this->page->getLatest();
+
+ wfDebug( "timestamp: {$timestamp}, edittime: {$this->edittime}\n" );
+
+ // Check editRevId if set, which handles same-second timestamp collisions
+ if ( $timestamp != $this->edittime
+ || ( $this->editRevId !== null && $this->editRevId != $latest )
+ ) {
+ $this->isConflict = true;
+ if ( $this->section == 'new' ) {
+ if ( $this->page->getUserText() == $user->getName() &&
+ $this->page->getComment() == $this->newSectionSummary()
+ ) {
+ // Probably a duplicate submission of a new comment.
+ // This can happen when CDN resends a request after
+ // a timeout but the first one actually went through.
+ wfDebug( __METHOD__
+ . ": duplicate new section submission; trigger edit conflict!\n" );
+ } else {
+ // New comment; suppress conflict.
+ $this->isConflict = false;
+ wfDebug( __METHOD__ . ": conflict suppressed; new section\n" );
+ }
+ } elseif ( $this->section == ''
+ && Revision::userWasLastToEdit(
+ DB_MASTER, $this->mTitle->getArticleID(),
+ $user->getId(), $this->edittime
+ )
+ ) {
+ # Suppress edit conflict with self, except for section edits where merging is required.
+ wfDebug( __METHOD__ . ": Suppressing edit conflict, same user.\n" );
+ $this->isConflict = false;
+ }
+ }
+
+ // If sectiontitle is set, use it, otherwise use the summary as the section title.
+ if ( $this->sectiontitle !== '' ) {
+ $sectionTitle = $this->sectiontitle;
+ } else {
+ $sectionTitle = $this->summary;
+ }
+
+ $content = null;
+
+ if ( $this->isConflict ) {
+ wfDebug( __METHOD__
+ . ": conflict! getting section '{$this->section}' for time '{$this->edittime}'"
+ . " (id '{$this->editRevId}') (article time '{$timestamp}')\n" );
+ // @TODO: replaceSectionAtRev() with base ID (not prior current) for ?oldid=X case
+ // ...or disable section editing for non-current revisions (not exposed anyway).
+ if ( $this->editRevId !== null ) {
+ $content = $this->page->replaceSectionAtRev(
+ $this->section,
+ $textbox_content,
+ $sectionTitle,
+ $this->editRevId
+ );
+ } else {
+ $content = $this->page->replaceSectionContent(
+ $this->section,
+ $textbox_content,
+ $sectionTitle,
+ $this->edittime
+ );
+ }
+ } else {
+ wfDebug( __METHOD__ . ": getting section '{$this->section}'\n" );
+ $content = $this->page->replaceSectionContent(
+ $this->section,
+ $textbox_content,
+ $sectionTitle
+ );
+ }
+
+ if ( is_null( $content ) ) {
+ wfDebug( __METHOD__ . ": activating conflict; section replace failed.\n" );
+ $this->isConflict = true;
+ $content = $textbox_content; // do not try to merge here!
+ } elseif ( $this->isConflict ) {
+ # Attempt merge
+ if ( $this->mergeChangesIntoContent( $content ) ) {
+ // Successful merge! Maybe we should tell the user the good news?
+ $this->isConflict = false;
+ wfDebug( __METHOD__ . ": Suppressing edit conflict, successful merge.\n" );
+ } else {
+ $this->section = '';
+ $this->textbox1 = ContentHandler::getContentText( $content );
+ wfDebug( __METHOD__ . ": Keeping edit conflict, failed merge.\n" );
+ }
+ }
+
+ if ( $this->isConflict ) {
+ $status->setResult( false, self::AS_CONFLICT_DETECTED );
+ return $status;
+ }
+
+ if ( !$this->runPostMergeFilters( $content, $status, $user ) ) {
+ return $status;
+ }
+
+ if ( $this->section == 'new' ) {
+ // Handle the user preference to force summaries here
+ if ( !$this->allowBlankSummary && trim( $this->summary ) == '' ) {
+ $this->missingSummary = true;
+ $status->fatal( 'missingsummary' ); // or 'missingcommentheader' if $section == 'new'. Blegh
+ $status->value = self::AS_SUMMARY_NEEDED;
+ return $status;
+ }
+
+ // Do not allow the user to post an empty comment
+ if ( $this->textbox1 == '' ) {
+ $this->missingComment = true;
+ $status->fatal( 'missingcommenttext' );
+ $status->value = self::AS_TEXTBOX_EMPTY;
+ return $status;
+ }
+ } elseif ( !$this->allowBlankSummary
+ && !$content->equals( $this->getOriginalContent( $user ) )
+ && !$content->isRedirect()
+ && md5( $this->summary ) == $this->autoSumm
+ ) {
+ $this->missingSummary = true;
+ $status->fatal( 'missingsummary' );
+ $status->value = self::AS_SUMMARY_NEEDED;
+ return $status;
+ }
+
+ # All's well
+ $sectionanchor = '';
+ if ( $this->section == 'new' ) {
+ $this->summary = $this->newSectionSummary( $sectionanchor );
+ } elseif ( $this->section != '' ) {
+ # Try to get a section anchor from the section source, redirect
+ # to edited section if header found.
+ # XXX: Might be better to integrate this into Article::replaceSectionAtRev
+ # for duplicate heading checking and maybe parsing.
+ $hasmatch = preg_match( "/^ *([=]{1,6})(.*?)(\\1) *\\n/i", $this->textbox1, $matches );
+ # We can't deal with anchors, includes, html etc in the header for now,
+ # headline would need to be parsed to improve this.
+ if ( $hasmatch && strlen( $matches[2] ) > 0 ) {
+ $sectionanchor = $this->guessSectionName( $matches[2] );
+ }
+ }
+ $result['sectionanchor'] = $sectionanchor;
+
+ // Save errors may fall down to the edit form, but we've now
+ // merged the section into full text. Clear the section field
+ // so that later submission of conflict forms won't try to
+ // replace that into a duplicated mess.
+ $this->textbox1 = $this->toEditText( $content );
+ $this->section = '';
+
+ $status->value = self::AS_SUCCESS_UPDATE;
+ }
+
+ if ( !$this->allowSelfRedirect
+ && $content->isRedirect()
+ && $content->getRedirectTarget()->equals( $this->getTitle() )
+ ) {
+ // If the page already redirects to itself, don't warn.
+ $currentTarget = $this->getCurrentContent()->getRedirectTarget();
+ if ( !$currentTarget || !$currentTarget->equals( $this->getTitle() ) ) {
+ $this->selfRedirect = true;
+ $status->fatal( 'selfredirect' );
+ $status->value = self::AS_SELF_REDIRECT;
+ return $status;
+ }
+ }
+
+ // Check for length errors again now that the section is merged in
+ $this->contentLength = strlen( $this->toEditText( $content ) );
+ if ( $this->contentLength > $maxArticleSize * 1024 ) {
+ $this->tooBig = true;
+ $status->setResult( false, self::AS_MAX_ARTICLE_SIZE_EXCEEDED );
+ return $status;
+ }
+
+ $flags = EDIT_AUTOSUMMARY |
+ ( $new ? EDIT_NEW : EDIT_UPDATE ) |
+ ( ( $this->minoredit && !$this->isNew ) ? EDIT_MINOR : 0 ) |
+ ( $bot ? EDIT_FORCE_BOT : 0 );
+
+ $doEditStatus = $this->page->doEditContent(
+ $content,
+ $this->summary,
+ $flags,
+ false,
+ $user,
+ $content->getDefaultFormat(),
+ $this->changeTags,
+ $this->undidRev
+ );
+
+ if ( !$doEditStatus->isOK() ) {
+ // Failure from doEdit()
+ // Show the edit conflict page for certain recognized errors from doEdit(),
+ // but don't show it for errors from extension hooks
+ $errors = $doEditStatus->getErrorsArray();
+ if ( in_array( $errors[0][0],
+ [ 'edit-gone-missing', 'edit-conflict', 'edit-already-exists' ] )
+ ) {
+ $this->isConflict = true;
+ // Destroys data doEdit() put in $status->value but who cares
+ $doEditStatus->value = self::AS_END;
+ }
+ return $doEditStatus;
+ }
+
+ $result['nullEdit'] = $doEditStatus->hasMessage( 'edit-no-change' );
+ if ( $result['nullEdit'] ) {
+ // We don't know if it was a null edit until now, so increment here
+ $user->pingLimiter( 'linkpurge' );
+ }
+ $result['redirect'] = $content->isRedirect();
+
+ $this->updateWatchlist();
+
+ // If the content model changed, add a log entry
+ if ( $changingContentModel ) {
+ $this->addContentModelChangeLogEntry(
+ $user,
+ $new ? false : $oldContentModel,
+ $this->contentModel,
+ $this->summary
+ );
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param User $user
+ * @param string|false $oldModel false if the page is being newly created
+ * @param string $newModel
+ * @param string $reason
+ */
+ protected function addContentModelChangeLogEntry( User $user, $oldModel, $newModel, $reason ) {
+ $new = $oldModel === false;
+ $log = new ManualLogEntry( 'contentmodel', $new ? 'new' : 'change' );
+ $log->setPerformer( $user );
+ $log->setTarget( $this->mTitle );
+ $log->setComment( $reason );
+ $log->setParameters( [
+ '4::oldmodel' => $oldModel,
+ '5::newmodel' => $newModel
+ ] );
+ $logid = $log->insert();
+ $log->publish( $logid );
+ }
+
+ /**
+ * Register the change of watch status
+ */
+ protected function updateWatchlist() {
+ $user = $this->context->getUser();
+ if ( !$user->isLoggedIn() ) {
+ return;
+ }
+
+ $title = $this->mTitle;
+ $watch = $this->watchthis;
+ // Do this in its own transaction to reduce contention...
+ DeferredUpdates::addCallableUpdate( function () use ( $user, $title, $watch ) {
+ if ( $watch == $user->isWatched( $title, User::IGNORE_USER_RIGHTS ) ) {
+ return; // nothing to change
+ }
+ WatchAction::doWatchOrUnwatch( $watch, $title, $user );
+ } );
+ }
+
+ /**
+ * Attempts to do 3-way merge of edit content with a base revision
+ * and current content, in case of edit conflict, in whichever way appropriate
+ * for the content type.
+ *
+ * @since 1.21
+ *
+ * @param Content $editContent
+ *
+ * @return bool
+ */
+ private function mergeChangesIntoContent( &$editContent ) {
+ $db = wfGetDB( DB_MASTER );
+
+ // This is the revision the editor started from
+ $baseRevision = $this->getBaseRevision();
+ $baseContent = $baseRevision ? $baseRevision->getContent() : null;
+
+ if ( is_null( $baseContent ) ) {
+ return false;
+ }
+
+ // The current state, we want to merge updates into it
+ $currentRevision = Revision::loadFromTitle( $db, $this->mTitle );
+ $currentContent = $currentRevision ? $currentRevision->getContent() : null;
+
+ if ( is_null( $currentContent ) ) {
+ return false;
+ }
+
+ $handler = ContentHandler::getForModelID( $baseContent->getModel() );
+
+ $result = $handler->merge3( $baseContent, $editContent, $currentContent );
+
+ if ( $result ) {
+ $editContent = $result;
+ // Update parentRevId to what we just merged.
+ $this->parentRevId = $currentRevision->getId();
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @note: this method is very poorly named. If the user opened the form with ?oldid=X,
+ * one might think of X as the "base revision", which is NOT what this returns.
+ * @return Revision Current version when the edit was started
+ */
+ public function getBaseRevision() {
+ if ( !$this->mBaseRevision ) {
+ $db = wfGetDB( DB_MASTER );
+ $this->mBaseRevision = $this->editRevId
+ ? Revision::newFromId( $this->editRevId, Revision::READ_LATEST )
+ : Revision::loadFromTimestamp( $db, $this->mTitle, $this->edittime );
+ }
+ return $this->mBaseRevision;
+ }
+
+ /**
+ * Check given input text against $wgSpamRegex, and return the text of the first match.
+ *
+ * @param string $text
+ *
+ * @return string|bool Matching string or false
+ */
+ public static function matchSpamRegex( $text ) {
+ global $wgSpamRegex;
+ // For back compatibility, $wgSpamRegex may be a single string or an array of regexes.
+ $regexes = (array)$wgSpamRegex;
+ return self::matchSpamRegexInternal( $text, $regexes );
+ }
+
+ /**
+ * Check given input text against $wgSummarySpamRegex, and return the text of the first match.
+ *
+ * @param string $text
+ *
+ * @return string|bool Matching string or false
+ */
+ public static function matchSummarySpamRegex( $text ) {
+ global $wgSummarySpamRegex;
+ $regexes = (array)$wgSummarySpamRegex;
+ return self::matchSpamRegexInternal( $text, $regexes );
+ }
+
+ /**
+ * @param string $text
+ * @param array $regexes
+ * @return bool|string
+ */
+ protected static function matchSpamRegexInternal( $text, $regexes ) {
+ foreach ( $regexes as $regex ) {
+ $matches = [];
+ if ( preg_match( $regex, $text, $matches ) ) {
+ return $matches[0];
+ }
+ }
+ return false;
+ }
+
+ public function setHeaders() {
+ $out = $this->context->getOutput();
+
+ $out->addModules( 'mediawiki.action.edit' );
+ $out->addModuleStyles( 'mediawiki.action.edit.styles' );
+
+ $user = $this->context->getUser();
+ if ( $user->getOption( 'showtoolbar' ) ) {
+ // The addition of default buttons is handled by getEditToolbar() which
+ // has its own dependency on this module. The call here ensures the module
+ // is loaded in time (it has position "top") for other modules to register
+ // buttons (e.g. extensions, gadgets, user scripts).
+ $out->addModules( 'mediawiki.toolbar' );
+ }
+
+ if ( $user->getOption( 'uselivepreview' ) ) {
+ $out->addModules( 'mediawiki.action.edit.preview' );
+ }
+
+ if ( $user->getOption( 'useeditwarning' ) ) {
+ $out->addModules( 'mediawiki.action.edit.editWarning' );
+ }
+
+ # Enabled article-related sidebar, toplinks, etc.
+ $out->setArticleRelated( true );
+
+ $contextTitle = $this->getContextTitle();
+ if ( $this->isConflict ) {
+ $msg = 'editconflict';
+ } elseif ( $contextTitle->exists() && $this->section != '' ) {
+ $msg = $this->section == 'new' ? 'editingcomment' : 'editingsection';
+ } else {
+ $msg = $contextTitle->exists()
+ || ( $contextTitle->getNamespace() == NS_MEDIAWIKI
+ && $contextTitle->getDefaultMessageText() !== false
+ )
+ ? 'editing'
+ : 'creating';
+ }
+
+ # Use the title defined by DISPLAYTITLE magic word when present
+ # NOTE: getDisplayTitle() returns HTML while getPrefixedText() returns plain text.
+ # setPageTitle() treats the input as wikitext, which should be safe in either case.
+ $displayTitle = isset( $this->mParserOutput ) ? $this->mParserOutput->getDisplayTitle() : false;
+ if ( $displayTitle === false ) {
+ $displayTitle = $contextTitle->getPrefixedText();
+ }
+ $out->setPageTitle( $this->context->msg( $msg, $displayTitle ) );
+ # Transmit the name of the message to JavaScript for live preview
+ # Keep Resources.php/mediawiki.action.edit.preview in sync with the possible keys
+ $out->addJsConfigVars( [
+ 'wgEditMessage' => $msg,
+ 'wgAjaxEditStash' => $this->context->getConfig()->get( 'AjaxEditStash' ),
+ ] );
+ }
+
+ /**
+ * Show all applicable editing introductions
+ */
+ protected function showIntro() {
+ if ( $this->suppressIntro ) {
+ return;
+ }
+
+ $out = $this->context->getOutput();
+ $namespace = $this->mTitle->getNamespace();
+
+ if ( $namespace == NS_MEDIAWIKI ) {
+ # Show a warning if editing an interface message
+ $out->wrapWikiMsg( "
\n$1\n
", 'editinginterface' );
+ # If this is a default message (but not css or js),
+ # show a hint that it is translatable on translatewiki.net
+ if ( !$this->mTitle->hasContentModel( CONTENT_MODEL_CSS )
+ && !$this->mTitle->hasContentModel( CONTENT_MODEL_JAVASCRIPT )
+ ) {
+ $defaultMessageText = $this->mTitle->getDefaultMessageText();
+ if ( $defaultMessageText !== false ) {
+ $out->wrapWikiMsg( "
\n$1\n
",
+ 'translateinterface' );
+ }
+ }
+ } elseif ( $namespace == NS_FILE ) {
+ # Show a hint to shared repo
+ $file = wfFindFile( $this->mTitle );
+ if ( $file && !$file->isLocal() ) {
+ $descUrl = $file->getDescriptionUrl();
+ # there must be a description url to show a hint to shared repo
+ if ( $descUrl ) {
+ if ( !$this->mTitle->exists() ) {
+ $out->wrapWikiMsg( "
", [
+ 'sharedupload-desc-edit', $file->getRepo()->getDisplayName(), $descUrl
+ ] );
+ }
+ }
+ }
+ }
+
+ # Show a warning message when someone creates/edits a user (talk) page but the user does not exist
+ # Show log extract when the user is currently blocked
+ if ( $namespace == NS_USER || $namespace == NS_USER_TALK ) {
+ $username = explode( '/', $this->mTitle->getText(), 2 )[0];
+ $user = User::newFromName( $username, false /* allow IP users */ );
+ $ip = User::isIP( $username );
+ $block = Block::newFromTarget( $user, $user );
+ if ( !( $user && $user->isLoggedIn() ) && !$ip ) { # User does not exist
+ $out->wrapWikiMsg( "
\n$1\n
",
+ [ 'userpage-userdoesnotexist', wfEscapeWikiText( $username ) ] );
+ } elseif ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) {
+ # Show log extract if the user is currently blocked
+ LogEventsList::showLogExtract(
+ $out,
+ 'block',
+ MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget(),
+ '',
+ [
+ 'lim' => 1,
+ 'showIfEmpty' => false,
+ 'msgKey' => [
+ 'blocked-notice-logextract',
+ $user->getName() # Support GENDER in notice
+ ]
+ ]
+ );
+ }
+ }
+ # Try to add a custom edit intro, or use the standard one if this is not possible.
+ if ( !$this->showCustomIntro() && !$this->mTitle->exists() ) {
+ $helpLink = wfExpandUrl( Skin::makeInternalOrExternalUrl(
+ $this->context->msg( 'helppage' )->inContentLanguage()->text()
+ ) );
+ if ( $this->context->getUser()->isLoggedIn() ) {
+ $out->wrapWikiMsg(
+ // Suppress the external link icon, consider the help url an internal one
+ "
\n$1\n
",
+ [
+ 'newarticletext',
+ $helpLink
+ ]
+ );
+ } else {
+ $out->wrapWikiMsg(
+ // Suppress the external link icon, consider the help url an internal one
+ "
\n$1\n
",
+ [
+ 'newarticletextanon',
+ $helpLink
+ ]
+ );
+ }
+ }
+ # Give a notice if the user is editing a deleted/moved page...
+ if ( !$this->mTitle->exists() ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ LogEventsList::showLogExtract( $out, [ 'delete', 'move' ], $this->mTitle,
+ '',
+ [
+ 'lim' => 10,
+ 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ],
+ 'showIfEmpty' => false,
+ 'msgKey' => [ 'recreate-moveddeleted-warn' ]
+ ]
+ );
+ }
+ }
+
+ /**
+ * Attempt to show a custom editing introduction, if supplied
+ *
+ * @return bool
+ */
+ protected function showCustomIntro() {
+ if ( $this->editintro ) {
+ $title = Title::newFromText( $this->editintro );
+ if ( $title instanceof Title && $title->exists() && $title->userCan( 'read' ) ) {
+ // Added using template syntax, to take 's into account.
+ $this->context->getOutput()->addWikiTextTitleTidy(
+ '
{{:' . $title->getFullText() . '}}
',
+ $this->mTitle
+ );
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Gets an editable textual representation of $content.
+ * The textual representation can be turned by into a Content object by the
+ * toEditContent() method.
+ *
+ * If $content is null or false or a string, $content is returned unchanged.
+ *
+ * If the given Content object is not of a type that can be edited using
+ * the text base EditPage, an exception will be raised. Set
+ * $this->allowNonTextContent to true to allow editing of non-textual
+ * content.
+ *
+ * @param Content|null|bool|string $content
+ * @return string The editable text form of the content.
+ *
+ * @throws MWException If $content is not an instance of TextContent and
+ * $this->allowNonTextContent is not true.
+ */
+ protected function toEditText( $content ) {
+ if ( $content === null || $content === false || is_string( $content ) ) {
+ return $content;
+ }
+
+ if ( !$this->isSupportedContentModel( $content->getModel() ) ) {
+ throw new MWException( 'This content model is not supported: ' . $content->getModel() );
+ }
+
+ return $content->serialize( $this->contentFormat );
}
/**
- * Get the contents of a page from its title and remove includeonly tags
+ * Turns the given text into a Content object by unserializing it.
+ *
+ * If the resulting Content object is not of a type that can be edited using
+ * the text base EditPage, an exception will be raised. Set
+ * $this->allowNonTextContent to true to allow editing of non-textual
+ * content.
*
- * @param $preload String: the title of the page.
- * @return string The contents of the page.
+ * @param string|null|bool $text Text to unserialize
+ * @return Content|bool|null The content object created from $text. If $text was false
+ * or null, false resp. null will be returned instead.
+ *
+ * @throws MWException If unserializing the text results in a Content
+ * object that is not an instance of TextContent and
+ * $this->allowNonTextContent is not true.
*/
- private function getPreloadedText($preload) {
- if ( $preload === '' )
- return '';
- else {
- $preloadTitle = Title::newFromText( $preload );
- if ( isset( $preloadTitle ) && $preloadTitle->userCanRead() ) {
- $rev=Revision::newFromTitle($preloadTitle);
- if ( is_object( $rev ) ) {
- $text = $rev->getText();
- // TODO FIXME: AAAAAAAAAAA, this shouldn't be implementing
- // its own mini-parser! -ævar
- $text = preg_replace( '~?includeonly>~', '', $text );
- return $text;
- } else
- return '';
- }
- }
- }
-
- /**
- * This is the function that extracts metadata from the article body on the first view.
- * To turn the feature on, set $wgUseMetadataEdit = true ; in LocalSettings
- * and set $wgMetadataWhitelist to the *full* title of the template whitelist
- */
- function extractMetaDataFromArticle () {
- global $wgUseMetadataEdit , $wgMetadataWhitelist , $wgLang ;
- $this->mMetaData = '' ;
- if ( !$wgUseMetadataEdit ) return ;
- if ( $wgMetadataWhitelist == '' ) return ;
- $s = '' ;
- $t = $this->getContent();
-
- # MISSING : filtering
-
- # Categories and language links
- $t = explode ( "\n" , $t ) ;
- $catlow = strtolower ( $wgLang->getNsText ( NS_CATEGORY ) ) ;
- $cat = $ll = array() ;
- foreach ( $t AS $key => $x )
- {
- $y = trim ( strtolower ( $x ) ) ;
- while ( substr ( $y , 0 , 2 ) == '[[' )
- {
- $y = explode ( ']]' , trim ( $x ) ) ;
- $first = array_shift ( $y ) ;
- $first = explode ( ':' , $first ) ;
- $ns = array_shift ( $first ) ;
- $ns = trim ( str_replace ( '[' , '' , $ns ) ) ;
- if ( strlen ( $ns ) == 2 OR strtolower ( $ns ) == $catlow )
- {
- $add = '[[' . $ns . ':' . implode ( ':' , $first ) . ']]' ;
- if ( strtolower ( $ns ) == $catlow ) $cat[] = $add ;
- else $ll[] = $add ;
- $x = implode ( ']]' , $y ) ;
- $t[$key] = $x ;
- $y = trim ( strtolower ( $x ) ) ;
- }
- }
+ protected function toEditContent( $text ) {
+ if ( $text === false || $text === null ) {
+ return $text;
}
- if ( count ( $cat ) ) $s .= implode ( ' ' , $cat ) . "\n" ;
- if ( count ( $ll ) ) $s .= implode ( ' ' , $ll ) . "\n" ;
- $t = implode ( "\n" , $t ) ;
-
- # Load whitelist
- $sat = array () ; # stand-alone-templates; must be lowercase
- $wl_title = Title::newFromText ( $wgMetadataWhitelist ) ;
- $wl_article = new Article ( $wl_title ) ;
- $wl = explode ( "\n" , $wl_article->getContent() ) ;
- foreach ( $wl AS $x )
- {
- $isentry = false ;
- $x = trim ( $x ) ;
- while ( substr ( $x , 0 , 1 ) == '*' )
- {
- $isentry = true ;
- $x = trim ( substr ( $x , 1 ) ) ;
- }
- if ( $isentry )
- {
- $sat[] = strtolower ( $x ) ;
- }
-
- }
-
- # Templates, but only some
- $t = explode ( '{{' , $t ) ;
- $tl = array () ;
- foreach ( $t AS $key => $x )
- {
- $y = explode ( '}}' , $x , 2 ) ;
- if ( count ( $y ) == 2 )
- {
- $z = $y[0] ;
- $z = explode ( '|' , $z ) ;
- $tn = array_shift ( $z ) ;
- if ( in_array ( strtolower ( $tn ) , $sat ) )
- {
- $tl[] = '{{' . $y[0] . '}}' ;
- $t[$key] = $y[1] ;
- $y = explode ( '}}' , $y[1] , 2 ) ;
- }
- else $t[$key] = '{{' . $x ;
- }
- else if ( $key != 0 ) $t[$key] = '{{' . $x ;
- else $t[$key] = $x ;
- }
- if ( count ( $tl ) ) $s .= implode ( ' ' , $tl ) ;
- $t = implode ( '' , $t ) ;
- $t = str_replace ( "\n\n\n" , "\n" , $t ) ;
- $this->mArticle->mContent = $t ;
- $this->mMetaData = $s ;
- }
+ $content = ContentHandler::makeContent( $text, $this->getTitle(),
+ $this->contentModel, $this->contentFormat );
- function submit() {
- $this->edit();
+ if ( !$this->isSupportedContentModel( $content->getModel() ) ) {
+ throw new MWException( 'This content model is not supported: ' . $content->getModel() );
+ }
+
+ return $content;
}
/**
- * This is the function that gets called for "action=edit". It
- * sets up various member variables, then passes execution to
- * another function, usually showEditForm()
+ * Send the edit form and related headers to OutputPage
+ * @param callable|null $formCallback That takes an OutputPage parameter; will be called
+ * during form output near the top, for captchas and the like.
*
- * The edit form is self-submitting, so that when things like
- * preview and edit conflicts occur, we get the same form back
- * with the extra stuff added. Only when the final submission
- * is made and all is well do we actually save and redirect to
- * the newly-edited page.
+ * The $formCallback parameter is deprecated since MediaWiki 1.25. Please
+ * use the EditPage::showEditForm:fields hook instead.
*/
- function edit() {
- global $wgOut, $wgUser, $wgRequest, $wgTitle;
+ public function showEditForm( $formCallback = null ) {
+ # need to parse the preview early so that we know which templates are used,
+ # otherwise users with "show preview after edit box" will get a blank list
+ # we parse this near the beginning so that setHeaders can do the title
+ # setting work instead of leaving it in getPreviewText
+ $previewOutput = '';
+ if ( $this->formtype == 'preview' ) {
+ $previewOutput = $this->getPreviewText();
+ }
- if ( ! wfRunHooks( 'AlternateEdit', array( &$this ) ) )
- return;
+ $out = $this->context->getOutput();
- $fname = 'EditPage::edit';
- wfProfileIn( $fname );
- wfDebug( "$fname: enter\n" );
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ Hooks::run( 'EditPage::showEditForm:initial', [ &$editPage, &$out ] );
- // this is not an article
- $wgOut->setArticleFlag(false);
+ $this->setHeaders();
- $this->importFormData( $wgRequest );
- $this->firsttime = false;
+ $this->addTalkPageText();
+ $this->addEditNotices();
- if( $this->live ) {
- $this->livePreview();
- wfProfileOut( $fname );
+ if ( !$this->isConflict &&
+ $this->section != '' &&
+ !$this->isSectionEditSupported() ) {
+ // We use $this->section to much before this and getVal('wgSection') directly in other places
+ // at this point we can't reset $this->section to '' to fallback to non-section editing.
+ // Someone is welcome to try refactoring though
+ $out->showErrorPage( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
return;
}
- $permErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $wgUser);
- if( !$this->mTitle->exists() )
- $permErrors += $this->mTitle->getUserPermissionsErrors( 'create', $wgUser);
+ $this->showHeader();
- # Ignore some permissions errors.
- $remove = array();
- foreach( $permErrors as $error ) {
- if ($this->preview || $this->diff &&
- ($error[0] == 'blockedtext' || $error[0] == 'autoblockedtext'))
- {
- // Don't worry about blocks when previewing/diffing
- $remove[] = $error;
- }
+ $out->addHTML( $this->editFormPageTop );
- if ($error[0] == 'readonlytext')
- {
- if ($this->edit) {
- $this->formtype = 'preview';
- } elseif ($this->save || $this->preview || $this->diff) {
- $remove[] = $error;
- }
- }
+ $user = $this->context->getUser();
+ if ( $user->getOption( 'previewontop' ) ) {
+ $this->displayPreviewArea( $previewOutput, true );
}
- # array_diff returns elements in $permErrors that are not in $remove.
- $permErrors = array_diff( $permErrors, $remove );
- if ( !empty($permErrors) )
- {
- wfDebug( "$fname: User can't edit\n" );
- $wgOut->readOnlyPage( $this->getContent(), true, $permErrors );
- wfProfileOut( $fname );
- return;
- } else {
- if ( $this->save ) {
- $this->formtype = 'save';
- } else if ( $this->preview ) {
- $this->formtype = 'preview';
- } else if ( $this->diff ) {
- $this->formtype = 'diff';
- } else { # First time through
- $this->firsttime = true;
- if( $this->previewOnOpen() ) {
- $this->formtype = 'preview';
- } else {
- $this->extractMetaDataFromArticle () ;
- $this->formtype = 'initial';
- }
+ $out->addHTML( $this->editFormTextTop );
+
+ $showToolbar = true;
+ if ( $this->wasDeletedSinceLastEdit() ) {
+ if ( $this->formtype == 'save' ) {
+ // Hide the toolbar and edit area, user can click preview to get it back
+ // Add an confirmation checkbox and explanation.
+ $showToolbar = false;
+ } else {
+ $out->wrapWikiMsg( "
\n$1\n
",
+ 'deletedwhileediting' );
}
}
- wfProfileIn( "$fname-business-end" );
+ // @todo add EditForm plugin interface and use it here!
+ // search for textarea1 and textarea2, and allow EditForm to override all uses.
+ $out->addHTML( Html::openElement(
+ 'form',
+ [
+ 'class' => 'mw-editform',
+ 'id' => self::EDITFORM_ID,
+ 'name' => self::EDITFORM_ID,
+ 'method' => 'post',
+ 'action' => $this->getActionURL( $this->getContextTitle() ),
+ 'enctype' => 'multipart/form-data'
+ ]
+ ) );
+
+ if ( is_callable( $formCallback ) ) {
+ wfWarn( 'The $formCallback parameter to ' . __METHOD__ . 'is deprecated' );
+ call_user_func_array( $formCallback, [ &$out ] );
+ }
- $this->isConflict = false;
- // css / js subpages of user pages get a special treatment
- $this->isCssJsSubpage = $wgTitle->isCssJsSubpage();
- $this->isValidCssJsSubpage = $wgTitle->isValidCssJsSubpage();
+ // Add a check for Unicode support
+ $out->addHTML( Html::hidden( 'wpUnicodeCheck', self::UNICODE_CHECK ) );
- /* Notice that we can't use isDeleted, because it returns true if article is ever deleted
- * no matter it's current state
- */
- $this->deletedSinceEdit = false;
- if ( $this->edittime != '' ) {
- /* Note that we rely on logging table, which hasn't been always there,
- * but that doesn't matter, because this only applies to brand new
- * deletes. This is done on every preview and save request. Move it further down
- * to only perform it on saves
- */
- if ( $this->mTitle->isDeleted() ) {
- $this->lastDelete = $this->getLastDelete();
- if ( !is_null($this->lastDelete) ) {
- $deletetime = $this->lastDelete->log_timestamp;
- if ( ($deletetime - $this->starttime) > 0 ) {
- $this->deletedSinceEdit = true;
- }
- }
- }
+ // Add an empty field to trip up spambots
+ $out->addHTML(
+ Xml::openElement( 'div', [ 'id' => 'antispam-container', 'style' => 'display: none;' ] )
+ . Html::rawElement(
+ 'label',
+ [ 'for' => 'wpAntispam' ],
+ $this->context->msg( 'simpleantispam-label' )->parse()
+ )
+ . Xml::element(
+ 'input',
+ [
+ 'type' => 'text',
+ 'name' => 'wpAntispam',
+ 'id' => 'wpAntispam',
+ 'value' => ''
+ ]
+ )
+ . Xml::closeElement( 'div' )
+ );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ Hooks::run( 'EditPage::showEditForm:fields', [ &$editPage, &$out ] );
+
+ // Put these up at the top to ensure they aren't lost on early form submission
+ $this->showFormBeforeText();
+
+ if ( $this->wasDeletedSinceLastEdit() && 'save' == $this->formtype ) {
+ $username = $this->lastDelete->user_name;
+ $comment = CommentStore::newKey( 'log_comment' )->getComment( $this->lastDelete )->text;
+
+ // It is better to not parse the comment at all than to have templates expanded in the middle
+ // TODO: can the checkLabel be moved outside of the div so that wrapWikiMsg could be used?
+ $key = $comment === ''
+ ? 'confirmrecreate-noreason'
+ : 'confirmrecreate';
+ $out->addHTML(
+ '
' );
+ }
+
+ /**
+ * Show the header copyright warning.
+ */
+ protected function showHeaderCopyrightWarning() {
+ $msg = 'editpage-head-copy-warn';
+ if ( !$this->context->msg( $msg )->isDisabled() ) {
+ $this->context->getOutput()->wrapWikiMsg( "
\n$1\n
",
+ 'editpage-head-copy-warn' );
+ }
+ }
+
+ /**
+ * Give a chance for site and per-namespace customizations of
+ * terms of service summary link that might exist separately
+ * from the copyright notice.
+ *
+ * This will display between the save button and the edit tools,
+ * so should remain short!
+ */
+ protected function showTosSummary() {
+ $msg = 'editpage-tos-summary';
+ Hooks::run( 'EditPageTosSummary', [ $this->mTitle, &$msg ] );
+ if ( !$this->context->msg( $msg )->isDisabled() ) {
+ $out = $this->context->getOutput();
+ $out->addHTML( '
' );
}
+ }
+ /**
+ * Inserts optional text shown below edit and upload forms. Can be used to offer special
+ * characters not present on most keyboards for copying/pasting.
+ */
+ protected function showEditTools() {
+ $this->context->getOutput()->addHTML( '
' );
+ }
- $wgOut->addHTML( $this->editFormTextTop );
+ /**
+ * Get the copyright warning
+ *
+ * Renamed to getCopyrightWarning(), old name kept around for backwards compatibility
+ * @return string
+ */
+ protected function getCopywarn() {
+ return self::getCopyrightWarning( $this->mTitle );
+ }
- # if this is a comment, show a subject line at the top, which is also the edit summary.
- # Otherwise, show a summary field at the bottom
- $summarytext = htmlspecialchars( $wgContLang->recodeForEdit( $this->summary ) ); # FIXME
- if( $this->section == 'new' ) {
- $commentsubject="\n
\n" );
- /**
- * To make it harder for someone to slip a user a page
- * which submits an edit form to the wiki without their
- * knowledge, a random token is associated with the login
- * session. If it's not passed back with the submission,
- * we won't save the page, or render user JavaScript and
- * CSS previews.
- *
- * For anon editors, who may not have a session, we just
- * include the constant suffix to prevent editing from
- * broken text-mangling proxies.
- */
- $token = htmlspecialchars( $wgUser->editToken() );
- $wgOut->addHTML( "\n\n" );
+ Hooks::run( 'EditPage::showStandardInputs:options', [ $this, $out, &$tabindex ] );
+ $out->addHTML( "
\n" );
+ }
- # If a blank edit summary was previously provided, and the appropriate
- # user preference is active, pass a hidden tag here. This will stop the
- # user being bounced back more than once in the event that a summary
- # is not required.
- if( $this->missingSummary ) {
- $wgOut->addHTML( "\n" );
+ /**
+ * Show an edit conflict. textbox1 is already shown in showEditForm().
+ * If you want to use another entry point to this function, be careful.
+ */
+ protected function showConflict() {
+ $out = $this->context->getOutput();
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ if ( Hooks::run( 'EditPageBeforeConflictDiff', [ &$editPage, &$out ] ) ) {
+ $this->incrementConflictStats();
+
+ $out->wrapWikiMsg( '
\n";
+ $this->incrementEditFailureStats( 'session_loss' );
+ return $parsedNote;
}
- $parserOptions = ParserOptions::newFromUser( $wgUser );
- $parserOptions->setEditSection( false );
+ $note = '';
- global $wgRawHtml;
- if( $wgRawHtml && !$this->mTokenOk ) {
- // Could be an offsite preview attempt. This is very unsafe if
- // HTML is enabled, as it could be an attack.
- return $wgOut->parse( "