+ * Initialise form fields in the object
+ * Called on the first invocation, e.g. when a user clicks an edit link
+ * @return bool If the requested section is valid
+ */
+ public function initialiseForm() {
+ $this->edittime = $this->page->getTimestamp();
+ $this->editRevId = $this->page->getLatest();
+
+ $content = $this->getContentObject( false ); # TODO: track content object?!
+ if ( $content === false ) {
+ return false;
+ }
+ $this->textbox1 = $this->toEditText( $content );
+
+ $user = $this->context->getUser();
+ // activate checkboxes if user wants them to be always active
+ # Sort out the "watch" checkbox
+ if ( $user->getOption( 'watchdefault' ) ) {
+ # Watch all edits
+ $this->watchthis = true;
+ } elseif ( $user->getOption( 'watchcreations' ) && !$this->mTitle->exists() ) {
+ # Watch creations
+ $this->watchthis = true;
+ } elseif ( $user->isWatched( $this->mTitle ) ) {
+ # Already watched
+ $this->watchthis = true;
+ }
+ if ( $user->getOption( 'minordefault' ) && !$this->isNew ) {
+ $this->minoredit = true;
+ }
+ if ( $this->textbox1 === false ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @param Content|null $def_content The default value to return
+ *
+ * @return Content|null Content on success, $def_content for invalid sections
+ *
+ * @since 1.21
+ */
+ protected function getContentObject( $def_content = null ) {
+ global $wgContLang;
+
+ $content = false;
+
+ $user = $this->context->getUser();
+ $request = $this->context->getRequest();
+ // For message page not locally set, use the i18n message.
+ // For other non-existent articles, use preload text if any.
+ if ( !$this->mTitle->exists() || $this->section == 'new' ) {
+ if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI && $this->section != 'new' ) {
+ # If this is a system message, get the default text.
+ $msg = $this->mTitle->getDefaultMessageText();
+
+ $content = $this->toEditContent( $msg );
+ }
+ if ( $content === false ) {
+ # If requested, preload some text.
+ $preload = $request->getVal( 'preload',
+ // Custom preload text for new sections
+ $this->section === 'new' ? 'MediaWiki:addsection-preload' : '' );
+ $params = $request->getArray( 'preloadparams', [] );
+
+ $content = $this->getPreloadedContent( $preload, $params );
+ }
+ // For existing pages, get text based on "undo" or section parameters.
+ } else {
+ if ( $this->section != '' ) {
+ // Get section edit text (returns $def_text for invalid sections)
+ $orig = $this->getOriginalContent( $user );
+ $content = $orig ? $orig->getSection( $this->section ) : null;
+
+ if ( !$content ) {
+ $content = $def_content;
+ }
+ } else {
+ $undoafter = $request->getInt( 'undoafter' );
+ $undo = $request->getInt( 'undo' );
+
+ if ( $undo > 0 && $undoafter > 0 ) {
+ $undorev = Revision::newFromId( $undo );
+ $oldrev = Revision::newFromId( $undoafter );
+
+ # Sanity check, make sure it's the right page,
+ # the revisions exist and they were not deleted.
+ # Otherwise, $content will be left as-is.
+ if ( !is_null( $undorev ) && !is_null( $oldrev ) &&
+ !$undorev->isDeleted( Revision::DELETED_TEXT ) &&
+ !$oldrev->isDeleted( Revision::DELETED_TEXT )
+ ) {
+ $content = $this->page->getUndoContent( $undorev, $oldrev );
+
+ if ( $content === false ) {
+ # Warn the user that something went wrong
+ $undoMsg = 'failure';
+ } else {
+ $oldContent = $this->page->getContent( Revision::RAW );
+ $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
+ $newContent = $content->preSaveTransform( $this->mTitle, $user, $popts );
+ if ( $newContent->getModel() !== $oldContent->getModel() ) {
+ // The undo may change content
+ // model if its reverting the top
+ // edit. This can result in
+ // mismatched content model/format.
+ $this->contentModel = $newContent->getModel();
+ $this->contentFormat = $oldrev->getContentFormat();
+ }
+
+ if ( $newContent->equals( $oldContent ) ) {
+ # Tell the user that the undo results in no change,
+ # i.e. the revisions were already undone.
+ $undoMsg = 'nochange';
+ $content = false;
+ } else {
+ # Inform the user of our success and set an automatic edit summary
+ $undoMsg = 'success';
+
+ # If we just undid one rev, use an autosummary
+ $firstrev = $oldrev->getNext();
+ if ( $firstrev && $firstrev->getId() == $undo ) {
+ $userText = $undorev->getUserText();
+ if ( $userText === '' ) {
+ $undoSummary = $this->context->msg(
+ 'undo-summary-username-hidden',
+ $undo
+ )->inContentLanguage()->text();
+ } else {
+ $undoSummary = $this->context->msg(
+ 'undo-summary',
+ $undo,
+ $userText
+ )->inContentLanguage()->text();
+ }
+ if ( $this->summary === '' ) {
+ $this->summary = $undoSummary;
+ } else {
+ $this->summary = $undoSummary . $this->context->msg( 'colon-separator' )
+ ->inContentLanguage()->text() . $this->summary;
+ }
+ $this->undidRev = $undo;
+ }
+ $this->formtype = 'diff';
+ }
+ }
+ } else {
+ // Failed basic sanity checks.
+ // Older revisions may have been removed since the link
+ // was created, or we may simply have got bogus input.
+ $undoMsg = 'norev';
+ }
+
+ $out = $this->context->getOutput();
+ // Messages: undo-success, undo-failure, undo-norev, undo-nochange
+ $class = ( $undoMsg == 'success' ? '' : 'error ' ) . "mw-undo-{$undoMsg}";
+ $this->editFormPageTop .= $out->parse( "<div class=\"{$class}\">" .
+ $this->context->msg( 'undo-' . $undoMsg )->plain() . '</div>', 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( '<div class="error">' . "\n" . $status->getWikiText() . '</div>' );
+ 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 = '<div class="error">' ."\n" . $status->getWikiText() .
+ '</div>';
+ 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