X-Git-Url: https://scripts.mit.edu/gitweb/autoinstalls/mediawiki.git/blobdiff_plain/c33ba33ef28344d64266b8b588460a301750fad5..HEAD:/includes/EditPage.php diff --git a/includes/EditPage.php b/includes/EditPage.php index cceb053d..eeae7b9d 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -1,2090 +1,4725 @@ mContextTitle is the page that forms submit to, links point to, + * redirects go to, etc. $this->mTitle (as well as $mArticle) is the + * page in the database that is actually being edited. These are + * usually the same, but they are now allowed to be different. + * + * Surgeon General's Warning: prolonged exposure to this class is known to cause + * headaches, which may be fatal. */ class EditPage { - var $mArticle; - var $mTitle; - var $mMetaData = ''; - var $isConflict = false; - var $isCssJsSubpage = false; - var $deletedSinceEdit = false; - var $formtype; - var $firsttime; - var $lastDelete; - var $mTokenOk = false; - var $mTokenOkExceptSuffix = false; - var $mTriedSave = false; - var $tooBig = false; - var $kblength = false; - var $missingComment = false; - var $missingSummary = false; - var $allowBlankSummary = false; - var $autoSumm = ''; - var $hookError = ''; - var $mPreviewTemplates; + /** + * Used for Unicode support checks + */ + const UNICODE_CHECK = 'ℳ𝒲♥𝓊𝓃𝒾𝒸ℴ𝒹ℯ'; + + /** + * Status: Article successfully updated + */ + const AS_SUCCESS_UPDATE = 200; + + /** + * Status: Article successfully created + */ + const AS_SUCCESS_NEW_ARTICLE = 201; + + /** + * Status: Article update aborted by a hook function + */ + const AS_HOOK_ERROR = 210; + + /** + * Status: A hook function returned an error + */ + const AS_HOOK_ERROR_EXPECTED = 212; + + /** + * Status: User is blocked from editing this page + */ + const AS_BLOCKED_PAGE_FOR_USER = 215; + + /** + * Status: Content too big (> $wgMaxArticleSize) + */ + const AS_CONTENT_TOO_BIG = 216; + + /** + * Status: this anonymous user is not allowed to edit this page + */ + const AS_READ_ONLY_PAGE_ANON = 218; + + /** + * Status: this logged in user is not allowed to edit this page + */ + const AS_READ_ONLY_PAGE_LOGGED = 219; + + /** + * Status: wiki is in readonly mode (wfReadOnly() == true) + */ + const AS_READ_ONLY_PAGE = 220; + + /** + * Status: rate limiter for action 'edit' was tripped + */ + const AS_RATE_LIMITED = 221; + + /** + * Status: article was deleted while editing and param wpRecreate == false or form + * was not posted + */ + const AS_ARTICLE_WAS_DELETED = 222; + + /** + * Status: user tried to create this page, but is not allowed to do that + * ( Title->userCan('create') == false ) + */ + const AS_NO_CREATE_PERMISSION = 223; + + /** + * Status: user tried to create a blank page and wpIgnoreBlankArticle == false + */ + const AS_BLANK_ARTICLE = 224; + + /** + * Status: (non-resolvable) edit conflict + */ + const AS_CONFLICT_DETECTED = 225; + + /** + * Status: no edit summary given and the user has forceeditsummary set and the user is not + * editing in his own userspace or talkspace and wpIgnoreBlankSummary == false + */ + const AS_SUMMARY_NEEDED = 226; + + /** + * Status: user tried to create a new section without content + */ + const AS_TEXTBOX_EMPTY = 228; + + /** + * Status: article is too big (> $wgMaxArticleSize), after merging in the new section + */ + const AS_MAX_ARTICLE_SIZE_EXCEEDED = 229; + + /** + * Status: WikiPage::doEdit() was unsuccessful + */ + const AS_END = 231; + + /** + * Status: summary contained spam according to one of the regexes in $wgSummarySpamRegex + */ + const AS_SPAM_ERROR = 232; + + /** + * Status: anonymous user is not allowed to upload (User::isAllowed('upload') == false) + */ + const AS_IMAGE_REDIRECT_ANON = 233; + + /** + * Status: logged in user is not allowed to upload (User::isAllowed('upload') == false) + */ + const AS_IMAGE_REDIRECT_LOGGED = 234; + + /** + * Status: user tried to modify the content model, but is not allowed to do that + * ( User::isAllowed('editcontentmodel') == false ) + */ + const AS_NO_CHANGE_CONTENT_MODEL = 235; + + /** + * Status: user tried to create self-redirect (redirect to the same article) and + * wpIgnoreSelfRedirect == false + */ + const AS_SELF_REDIRECT = 236; + + /** + * Status: an error relating to change tagging. Look at the message key for + * more details + */ + const AS_CHANGE_TAG_ERROR = 237; + + /** + * Status: can't parse content + */ + const AS_PARSE_ERROR = 240; + + /** + * Status: when changing the content model is disallowed due to + * $wgContentHandlerUseDB being false + */ + const AS_CANNOT_USE_CUSTOM_MODEL = 241; + + /** + * Status: edit rejected because browser doesn't support Unicode. + */ + const AS_UNICODE_NOT_SUPPORTED = 242; + + /** + * HTML id and name for the beginning of the edit form. + */ + const EDITFORM_ID = 'editform'; + + /** + * Prefix of key for cookie used to pass post-edit state. + * The revision id edited is added after this + */ + const POST_EDIT_COOKIE_KEY_PREFIX = 'PostEditRevision'; + + /** + * Duration of PostEdit cookie, in seconds. + * The cookie will be removed instantly if the JavaScript runs. + * + * Otherwise, though, we don't want the cookies to accumulate. + * RFC 2109 ( https://www.ietf.org/rfc/rfc2109.txt ) specifies a possible + * limit of only 20 cookies per domain. This still applies at least to some + * versions of IE without full updates: + * https://blogs.msdn.com/b/ieinternals/archive/2009/08/20/wininet-ie-cookie-internals-faq.aspx + * + * A value of 20 minutes should be enough to take into account slow loads and minor + * clock skew while still avoiding cookie accumulation when JavaScript is turned off. + */ + const POST_EDIT_COOKIE_DURATION = 1200; + + /** + * @deprecated for public usage since 1.30 use EditPage::getArticle() + * @var Article + */ + public $mArticle; + /** @var WikiPage */ + private $page; + + /** + * @deprecated for public usage since 1.30 use EditPage::getTitle() + * @var Title + */ + public $mTitle; + + /** @var null|Title */ + private $mContextTitle = null; + + /** @var string */ + public $action = 'submit'; + + /** @var bool */ + public $isConflict = false; + + /** + * @deprecated since 1.30 use Title::isCssJsSubpage() + * @var bool + */ + public $isCssJsSubpage = false; + + /** + * @deprecated since 1.30 use Title::isCssSubpage() + * @var bool + */ + public $isCssSubpage = false; + + /** + * @deprecated since 1.30 use Title::isJsSubpage() + * @var bool + */ + public $isJsSubpage = false; + + /** + * @deprecated since 1.30 + * @var bool + */ + public $isWrongCaseCssJsPage = false; + + /** @var bool New page or new section */ + public $isNew = false; + + /** @var bool */ + public $deletedSinceEdit; + + /** @var string */ + public $formtype; + + /** @var bool */ + public $firsttime; + + /** @var bool|stdClass */ + public $lastDelete; + + /** @var bool */ + public $mTokenOk = false; + + /** @var bool */ + public $mTokenOkExceptSuffix = false; + + /** @var bool */ + public $mTriedSave = false; + + /** @var bool */ + public $incompleteForm = false; + + /** @var bool */ + public $tooBig = false; + + /** @var bool */ + public $missingComment = false; + + /** @var bool */ + public $missingSummary = false; + + /** @var bool */ + public $allowBlankSummary = false; + + /** @var bool */ + protected $blankArticle = false; + + /** @var bool */ + protected $allowBlankArticle = false; + + /** @var bool */ + protected $selfRedirect = false; + + /** @var bool */ + protected $allowSelfRedirect = false; + + /** @var string */ + public $autoSumm = ''; + + /** @var string */ + public $hookError = ''; + + /** @var ParserOutput */ + public $mParserOutput; + + /** @var bool Has a summary been preset using GET parameter &summary= ? */ + public $hasPresetSummary = false; + + /** @var Revision|bool */ + public $mBaseRevision = false; + + /** @var bool */ + public $mShowSummaryField = true; # Form values - var $save = false, $preview = false, $diff = false; - var $minoredit = false, $watchthis = false, $recreate = false; - var $textbox1 = '', $textbox2 = '', $summary = ''; - var $edittime = '', $section = '', $starttime = ''; - var $oldid = 0, $editintro = '', $scrolltop = null; + + /** @var bool */ + public $save = false; + + /** @var bool */ + public $preview = false; + + /** @var bool */ + public $diff = false; + + /** @var bool */ + public $minoredit = false; + + /** @var bool */ + public $watchthis = false; + + /** @var bool */ + public $recreate = false; + + /** @var string */ + public $textbox1 = ''; + + /** @var string */ + public $textbox2 = ''; + + /** @var string */ + public $summary = ''; + + /** @var bool */ + public $nosummary = false; + + /** @var string */ + public $edittime = ''; + + /** @var int */ + private $editRevId = null; + + /** @var string */ + public $section = ''; + + /** @var string */ + public $sectiontitle = ''; + + /** @var string */ + public $starttime = ''; + + /** @var int */ + public $oldid = 0; + + /** @var int */ + public $parentRevId = 0; + + /** @var string */ + public $editintro = ''; + + /** @var null */ + public $scrolltop = null; + + /** @var bool */ + public $bot = true; + + /** @var string */ + public $contentModel; + + /** @var null|string */ + public $contentFormat = null; + + /** @var null|array */ + private $changeTags = null; # Placeholders for text injection by hooks (must be HTML) # extensions should take care to _append_ to the present value - public $editFormPageTop; // Before even the preview - public $editFormTextTop; - public $editFormTextAfterWarn; - public $editFormTextAfterTools; - public $editFormTextBottom; + + /** @var string Before even the preview */ + public $editFormPageTop = ''; + public $editFormTextTop = ''; + public $editFormTextBeforeContent = ''; + public $editFormTextAfterWarn = ''; + public $editFormTextAfterTools = ''; + public $editFormTextBottom = ''; + public $editFormTextAfterContent = ''; + public $previewTextAfterContent = ''; + public $mPreloadContent = null; + + /* $didSave should be set to true whenever an article was successfully altered. */ + public $didSave = false; + public $undidRev = 0; + + public $suppressIntro = false; + + /** @var bool */ + protected $edit; + + /** @var bool|int */ + protected $contentLength = false; + + /** + * @var bool Set in ApiEditPage, based on ContentHandler::allowsDirectApiEditing + */ + private $enableApiEditOverride = false; + + /** + * @var IContextSource + */ + protected $context; + + /** + * @var bool Whether an old revision is edited + */ + private $isOldRev = false; /** - * @todo document - * @param $article + * @var string|null What the user submitted in the 'wpUnicodeCheck' field */ - function EditPage( $article ) { - $this->mArticle =& $article; - global $wgTitle; - $this->mTitle =& $wgTitle; + private $unicodeCheck; - # Placeholders for text injection by hooks (empty per default) - $this->editFormPageTop = - $this->editFormTextTop = - $this->editFormTextAfterWarn = - $this->editFormTextAfterTools = - $this->editFormTextBottom = ""; + /** + * @param Article $article + */ + public function __construct( Article $article ) { + $this->mArticle = $article; + $this->page = $article->getPage(); // model object + $this->mTitle = $article->getTitle(); + $this->context = $article->getContext(); + + $this->contentModel = $this->mTitle->getContentModel(); + + $handler = ContentHandler::getForModelID( $this->contentModel ); + $this->contentFormat = $handler->getDefaultFormat(); } - + /** - * Fetch initial editing page content. + * @return Article */ - private function getContent( $def_text = '' ) { - global $wgOut, $wgRequest, $wgParser; + public function getArticle() { + return $this->mArticle; + } - # Get variables from query string :P - $section = $wgRequest->getVal( 'section' ); - $preload = $wgRequest->getVal( 'preload' ); - $undoafter = $wgRequest->getVal( 'undoafter' ); - $undo = $wgRequest->getVal( 'undo' ); + /** + * @since 1.28 + * @return IContextSource + */ + public function getContext() { + return $this->context; + } - wfProfileIn( __METHOD__ ); + /** + * @since 1.19 + * @return Title + */ + public function getTitle() { + return $this->mTitle; + } - $text = ''; - if( !$this->mTitle->exists() ) { - if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) { - # If this is a system message, get the default text. - $text = wfMsgWeirdKey ( $this->mTitle->getText() ) ; - } else { - # If requested, preload some text. - $text = $this->getPreloadedText( $preload ); - } - # We used to put MediaWiki:Newarticletext here if - # $text was empty at this point. - # This is now shown above the edit box instead. + /** + * Set the context Title object + * + * @param Title|null $title Title object or null + */ + public function setContextTitle( $title ) { + $this->mContextTitle = $title; + } + + /** + * Get the context title object. + * If not set, $wgTitle will be returned. This behavior might change in + * the future to return $this->mTitle instead. + * + * @return Title + */ + public function getContextTitle() { + if ( is_null( $this->mContextTitle ) ) { + wfDebugLog( + 'GlobalTitleFail', + __METHOD__ . ' called by ' . wfGetAllCallers( 5 ) . ' with no title set.' + ); + global $wgTitle; + return $wgTitle; } else { - // FIXME: may be better to use Revision class directly - // But don't mess with it just yet. Article knows how to - // fetch the page record from the high-priority server, - // which is needed to guarantee we don't pick up lagged - // information. + return $this->mContextTitle; + } + } - $text = $this->mArticle->getContent(); + /** + * Check if the edit page is using OOUI controls + * @return bool Always true + * @deprecated since 1.30 + */ + public function isOouiEnabled() { + return true; + } - if ($undo > 0 && $undoafter > 0 && $undo < $undoafter) { - # If they got undoafter and undo round the wrong way, switch them - list( $undo, $undoafter ) = array( $undoafter, $undo ); - } + /** + * Returns if the given content model is editable. + * + * @param string $modelId The ID of the content model to test. Use CONTENT_MODEL_XXX constants. + * @return bool + * @throws MWException If $modelId has no known handler + */ + public function isSupportedContentModel( $modelId ) { + return $this->enableApiEditOverride === true || + ContentHandler::getForModelID( $modelId )->supportsDirectEditing(); + } - if ( $undo > 0 && $undo > $undoafter ) { - # Undoing a specific edit overrides section editing; section-editing - # doesn't work with undoing. - if ( $undoafter ) { - $undorev = Revision::newFromId($undo); - $oldrev = Revision::newFromId($undoafter); - } else { - $undorev = Revision::newFromId($undo); - $oldrev = $undorev ? $undorev->getPrevious() : null; - } + /** + * Allow editing of content that supports API direct editing, but not general + * direct editing. Set to false by default. + * + * @param bool $enableOverride + */ + public function setApiEditOverride( $enableOverride ) { + $this->enableApiEditOverride = $enableOverride; + } - #Sanity check, make sure it's the right page. - # Otherwise, $text will be left as-is. - if ( !is_null($undorev) && !is_null($oldrev) && $undorev->getPage()==$oldrev->getPage() && $undorev->getPage()==$this->mArticle->getID() ) { - $undorev_text = $undorev->getText(); - $oldrev_text = $oldrev->getText(); - $currev_text = $text; + /** + * @deprecated since 1.29, call edit directly + */ + public function submit() { + wfDeprecated( __METHOD__, '1.29' ); + $this->edit(); + } - #No use doing a merge if it's just a straight revert. - if ( $currev_text != $undorev_text ) { - $result = wfMerge($undorev_text, $oldrev_text, $currev_text, $text); - } else { - $text = $oldrev_text; - $result = true; - } - } else { - // Failed basic sanity checks. - // Older revisions may have been removed since the link - // was created, or we may simply have got bogus input. - $result = false; - } + /** + * This is the function that gets called for "action=edit". It + * sets up various member variables, then passes execution to + * another function, usually showEditForm() + * + * 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. + */ + public function edit() { + // Allow extensions to modify/prevent this form or submission + if ( !Hooks::run( 'AlternateEdit', [ $this ] ) ) { + return; + } - if( $result ) { - # Inform the user of our success and set an automatic edit summary - $this->editFormPageTop .= $wgOut->parse( wfMsgNoTrans( 'undo-success' ) ); - $firstrev = $oldrev->getNext(); - # If we just undid one rev, use an autosummary - if ( $firstrev->mId == $undo ) { - $this->summary = wfMsgForContent('undo-summary', $undo, $undorev->getUserText()); - } - $this->formtype = 'diff'; - } else { - # Warn the user that something went wrong - $this->editFormPageTop .= $wgOut->parse( wfMsgNoTrans( 'undo-failure' ) ); - } - } else if( $section != '' ) { - if( $section == 'new' ) { - $text = $this->getPreloadedText( $preload ); - } else { - $text = $wgParser->getSection( $text, $section, $def_text ); - } + wfDebug( __METHOD__ . ": enter\n" ); + + $request = $this->context->getRequest(); + // If they used redlink=1 and the page exists, redirect to the main article + if ( $request->getBool( 'redlink' ) && $this->mTitle->exists() ) { + $this->context->getOutput()->redirect( $this->mTitle->getFullURL() ); + return; + } + + $this->importFormData( $request ); + $this->firsttime = false; + + if ( wfReadOnly() && $this->save ) { + // Force preview + $this->save = false; + $this->preview = true; + } + + if ( $this->save ) { + $this->formtype = 'save'; + } elseif ( $this->preview ) { + $this->formtype = 'preview'; + } elseif ( $this->diff ) { + $this->formtype = 'diff'; + } else { # First time through + $this->firsttime = true; + if ( $this->previewOnOpen() ) { + $this->formtype = 'preview'; + } else { + $this->formtype = 'initial'; } } - wfProfileOut( __METHOD__ ); - return $text; + $permErrors = $this->getEditPermissionErrors( $this->save ? 'secure' : 'full' ); + if ( $permErrors ) { + wfDebug( __METHOD__ . ": User can't edit\n" ); + // Auto-block user's IP if the account was "hard" blocked + if ( !wfReadOnly() ) { + DeferredUpdates::addCallableUpdate( function () { + $this->context->getUser()->spreadAnyEditBlock(); + } ); + } + $this->displayPermissionsError( $permErrors ); + + return; + } + + $revision = $this->mArticle->getRevisionFetched(); + // Disallow editing revisions with content models different from the current one + // Undo edits being an exception in order to allow reverting content model changes. + if ( $revision + && $revision->getContentModel() !== $this->contentModel + ) { + $prevRev = null; + if ( $this->undidRev ) { + $undidRevObj = Revision::newFromId( $this->undidRev ); + $prevRev = $undidRevObj ? $undidRevObj->getPrevious() : null; + } + if ( !$this->undidRev + || !$prevRev + || $prevRev->getContentModel() !== $this->contentModel + ) { + $this->displayViewSourcePage( + $this->getContentObject(), + $this->context->msg( + 'contentmodelediterror', + $revision->getContentModel(), + $this->contentModel + )->plain() + ); + return; + } + } + + $this->isConflict = false; + // css / js subpages of user pages get a special treatment + // The following member variables are deprecated since 1.30, + // the functions should be used instead. + $this->isCssJsSubpage = $this->mTitle->isCssJsSubpage(); + $this->isCssSubpage = $this->mTitle->isCssSubpage(); + $this->isJsSubpage = $this->mTitle->isJsSubpage(); + $this->isWrongCaseCssJsPage = $this->isWrongCaseCssJsPage(); + + # Show applicable editing introductions + if ( $this->formtype == 'initial' || $this->firsttime ) { + $this->showIntro(); + } + + # Attempt submission here. This will check for edit conflicts, + # and redundantly check for locked database, blocked IPs, etc. + # that edit() already checked just in case someone tries to sneak + # in the back door with a hand-edited submission URL. + + if ( 'save' == $this->formtype ) { + $resultDetails = null; + $status = $this->attemptSave( $resultDetails ); + if ( !$this->handleStatus( $status, $resultDetails ) ) { + return; + } + } + + # First time through: get contents, set time for conflict + # checking, etc. + if ( 'initial' == $this->formtype || $this->firsttime ) { + if ( $this->initialiseForm() === false ) { + $this->noSuchSectionPage(); + return; + } + + if ( !$this->mTitle->getArticleID() ) { + Hooks::run( 'EditFormPreloadText', [ &$this->textbox1, &$this->mTitle ] ); + } else { + Hooks::run( 'EditFormInitialText', [ $this ] ); + } + + } + + $this->showEditForm(); + } + + /** + * @param string $rigor Same format as Title::getUserPermissionErrors() + * @return array + */ + protected function getEditPermissionErrors( $rigor = 'secure' ) { + $user = $this->context->getUser(); + $permErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user, $rigor ); + # Can this title be created? + if ( !$this->mTitle->exists() ) { + $permErrors = array_merge( + $permErrors, + wfArrayDiff2( + $this->mTitle->getUserPermissionsErrors( 'create', $user, $rigor ), + $permErrors + ) + ); + } + # Ignore some permissions errors when a user is just previewing/viewing diffs + $remove = []; + foreach ( $permErrors as $error ) { + if ( ( $this->preview || $this->diff ) + && ( + $error[0] == 'blockedtext' || + $error[0] == 'autoblockedtext' || + $error[0] == 'systemblockedtext' + ) + ) { + $remove[] = $error; + } + } + $permErrors = wfArrayDiff2( $permErrors, $remove ); + + return $permErrors; + } + + /** + * Display a permissions error page, like OutputPage::showPermissionsErrorPage(), + * but with the following differences: + * - If redlink=1, the user will be redirected to the page + * - If there is content to display or the error occurs while either saving, + * previewing or showing the difference, it will be a + * "View source for ..." page displaying the source code after the error message. + * + * @since 1.19 + * @param array $permErrors Array of permissions errors, as returned by + * Title::getUserPermissionsErrors(). + * @throws PermissionsError + */ + protected function displayPermissionsError( array $permErrors ) { + $out = $this->context->getOutput(); + if ( $this->context->getRequest()->getBool( 'redlink' ) ) { + // The edit page was reached via a red link. + // Redirect to the article page and let them click the edit tab if + // they really want a permission error. + $out->redirect( $this->mTitle->getFullURL() ); + return; + } + + $content = $this->getContentObject(); + + # Use the normal message if there's nothing to display + if ( $this->firsttime && ( !$content || $content->isEmpty() ) ) { + $action = $this->mTitle->exists() ? 'edit' : + ( $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage' ); + throw new PermissionsError( $action, $permErrors ); + } + + $this->displayViewSourcePage( + $content, + $out->formatPermissionsErrorMessage( $permErrors, 'edit' ) + ); + } + + /** + * Display a read-only View Source page + * @param Content $content content object + * @param string $errorMessage additional wikitext error message to display + */ + protected function displayViewSourcePage( Content $content, $errorMessage = '' ) { + $out = $this->context->getOutput(); + Hooks::run( 'EditPage::showReadOnlyForm:initial', [ $this, &$out ] ); + + $out->setRobotPolicy( 'noindex,nofollow' ); + $out->setPageTitle( $this->context->msg( + 'viewsource-title', + $this->getContextTitle()->getPrefixedText() + ) ); + $out->addBacklinkSubtitle( $this->getContextTitle() ); + $out->addHTML( $this->editFormPageTop ); + $out->addHTML( $this->editFormTextTop ); + + if ( $errorMessage !== '' ) { + $out->addWikiText( $errorMessage ); + $out->addHTML( "
\n" ); + } + + # If the user made changes, preserve them when showing the markup + # (This happens when a user is blocked during edit, for instance) + if ( !$this->firsttime ) { + $text = $this->textbox1; + $out->addWikiMsg( 'viewyourtext' ); + } else { + try { + $text = $this->toEditText( $content ); + } catch ( MWException $e ) { + # Serialize using the default format if the content model is not supported + # (e.g. for an old revision with a different model) + $text = $content->serialize(); + } + $out->addWikiMsg( 'viewsourcetext' ); + } + + $out->addHTML( $this->editFormTextBeforeContent ); + $this->showTextbox( $text, 'wpTextbox1', [ 'readonly' ] ); + $out->addHTML( $this->editFormTextAfterContent ); + + $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) ); + + $out->addModules( 'mediawiki.action.edit.collapsibleFooter' ); + + $out->addHTML( $this->editFormTextBottom ); + if ( $this->mTitle->exists() ) { + $out->returnToMain( null, $this->mTitle ); + } + } + + /** + * Should we show a preview when the edit form is first shown? + * + * @return bool + */ + protected function previewOnOpen() { + $previewOnOpenNamespaces = $this->context->getConfig()->get( 'PreviewOnOpenNamespaces' ); + $request = $this->context->getRequest(); + if ( $request->getVal( 'preview' ) == 'yes' ) { + // Explicit override from request + return true; + } elseif ( $request->getVal( 'preview' ) == 'no' ) { + // Explicit override from request + return false; + } elseif ( $this->section == 'new' ) { + // Nothing *to* preview for new sections + return false; + } elseif ( ( $request->getVal( 'preload' ) !== null || $this->mTitle->exists() ) + && $this->context->getUser()->getOption( 'previewonfirst' ) + ) { + // Standard preference behavior + return true; + } elseif ( !$this->mTitle->exists() + && isset( $previewOnOpenNamespaces[$this->mTitle->getNamespace()] ) + && $previewOnOpenNamespaces[$this->mTitle->getNamespace()] + ) { + // Categories are special + return true; + } else { + return false; + } + } + + /** + * Checks whether the user entered a skin name in uppercase, + * e.g. "User:Example/Monobook.css" instead of "monobook.css" + * + * @return bool + */ + protected function isWrongCaseCssJsPage() { + if ( $this->mTitle->isCssJsSubpage() ) { + $name = $this->mTitle->getSkinFromCssJsSubpage(); + $skins = array_merge( + array_keys( Skin::getSkinNames() ), + [ 'common' ] + ); + return !in_array( $name, $skins ) + && in_array( strtolower( $name ), $skins ); + } else { + return false; + } + } + + /** + * Returns whether section editing is supported for the current page. + * Subclasses may override this to replace the default behavior, which is + * to check ContentHandler::supportsSections. + * + * @return bool True if this edit page supports sections, false otherwise. + */ + protected function isSectionEditSupported() { + $contentHandler = ContentHandler::getForTitle( $this->mTitle ); + return $contentHandler->supportsSections(); + } + + /** + * This function collects the form data and uses it to populate various member variables. + * @param WebRequest &$request + * @throws ErrorPageError + */ + public function importFormData( &$request ) { + # Section edit can come from either the form or a link + $this->section = $request->getVal( 'wpSection', $request->getVal( 'section' ) ); + + if ( $this->section !== null && $this->section !== '' && !$this->isSectionEditSupported() ) { + throw new ErrorPageError( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' ); + } + + $this->isNew = !$this->mTitle->exists() || $this->section == 'new'; + + if ( $request->wasPosted() ) { + # These fields need to be checked for encoding. + # Also remove trailing whitespace, but don't remove _initial_ + # whitespace from the text boxes. This may be significant formatting. + $this->textbox1 = rtrim( $request->getText( 'wpTextbox1' ) ); + if ( !$request->getCheck( 'wpTextbox2' ) ) { + // Skip this if wpTextbox2 has input, it indicates that we came + // from a conflict page with raw page text, not a custom form + // modified by subclasses + $textbox1 = $this->importContentFormData( $request ); + if ( $textbox1 !== null ) { + $this->textbox1 = $textbox1; + } + } + + $this->unicodeCheck = $request->getText( 'wpUnicodeCheck' ); + + $this->summary = $request->getText( 'wpSummary' ); + + # If the summary consists of a heading, e.g. '==Foobar==', extract the title from the + # header syntax, e.g. 'Foobar'. This is mainly an issue when we are using wpSummary for + # section titles. + $this->summary = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->summary ); + + # Treat sectiontitle the same way as summary. + # Note that wpSectionTitle is not yet a part of the actual edit form, as wpSummary is + # currently doing double duty as both edit summary and section title. Right now this + # is just to allow API edits to work around this limitation, but this should be + # incorporated into the actual edit form when EditPage is rewritten (Bugs 18654, 26312). + $this->sectiontitle = $request->getText( 'wpSectionTitle' ); + $this->sectiontitle = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->sectiontitle ); + + $this->edittime = $request->getVal( 'wpEdittime' ); + $this->editRevId = $request->getIntOrNull( 'editRevId' ); + $this->starttime = $request->getVal( 'wpStarttime' ); + + $undidRev = $request->getInt( 'wpUndidRevision' ); + if ( $undidRev ) { + $this->undidRev = $undidRev; + } + + $this->scrolltop = $request->getIntOrNull( 'wpScrolltop' ); + + if ( $this->textbox1 === '' && $request->getVal( 'wpTextbox1' ) === null ) { + // wpTextbox1 field is missing, possibly due to being "too big" + // according to some filter rules such as Suhosin's setting for + // suhosin.request.max_value_length (d'oh) + $this->incompleteForm = true; + } else { + // If we receive the last parameter of the request, we can fairly + // claim the POST request has not been truncated. + + // TODO: softened the check for cutover. Once we determine + // that it is safe, we should complete the transition by + // removing the "edittime" clause. + $this->incompleteForm = ( !$request->getVal( 'wpUltimateParam' ) + && is_null( $this->edittime ) ); + } + if ( $this->incompleteForm ) { + # If the form is incomplete, force to preview. + wfDebug( __METHOD__ . ": Form data appears to be incomplete\n" ); + wfDebug( "POST DATA: " . var_export( $_POST, true ) . "\n" ); + $this->preview = true; + } else { + $this->preview = $request->getCheck( 'wpPreview' ); + $this->diff = $request->getCheck( 'wpDiff' ); + + // Remember whether a save was requested, so we can indicate + // if we forced preview due to session failure. + $this->mTriedSave = !$this->preview; + + if ( $this->tokenOk( $request ) ) { + # Some browsers will not report any submit button + # if the user hits enter in the comment box. + # The unmarked state will be assumed to be a save, + # if the form seems otherwise complete. + wfDebug( __METHOD__ . ": Passed token check.\n" ); + } elseif ( $this->diff ) { + # Failed token check, but only requested "Show Changes". + wfDebug( __METHOD__ . ": Failed token check; Show Changes requested.\n" ); + } else { + # Page might be a hack attempt posted from + # an external site. Preview instead of saving. + wfDebug( __METHOD__ . ": Failed token check; forcing preview\n" ); + $this->preview = true; + } + } + $this->save = !$this->preview && !$this->diff; + if ( !preg_match( '/^\d{14}$/', $this->edittime ) ) { + $this->edittime = null; + } + + if ( !preg_match( '/^\d{14}$/', $this->starttime ) ) { + $this->starttime = null; + } + + $this->recreate = $request->getCheck( 'wpRecreate' ); + + $this->minoredit = $request->getCheck( 'wpMinoredit' ); + $this->watchthis = $request->getCheck( 'wpWatchthis' ); + + $user = $this->context->getUser(); + # Don't force edit summaries when a user is editing their own user or talk page + if ( ( $this->mTitle->mNamespace == NS_USER || $this->mTitle->mNamespace == NS_USER_TALK ) + && $this->mTitle->getText() == $user->getName() + ) { + $this->allowBlankSummary = true; + } else { + $this->allowBlankSummary = $request->getBool( 'wpIgnoreBlankSummary' ) + || !$user->getOption( 'forceeditsummary' ); + } + + $this->autoSumm = $request->getText( 'wpAutoSummary' ); + + $this->allowBlankArticle = $request->getBool( 'wpIgnoreBlankArticle' ); + $this->allowSelfRedirect = $request->getBool( 'wpIgnoreSelfRedirect' ); + + $changeTags = $request->getVal( 'wpChangeTags' ); + if ( is_null( $changeTags ) || $changeTags === '' ) { + $this->changeTags = []; + } else { + $this->changeTags = array_filter( array_map( 'trim', explode( ',', + $changeTags ) ) ); + } + } else { + # Not a posted form? Start with nothing. + wfDebug( __METHOD__ . ": Not a posted form.\n" ); + $this->textbox1 = ''; + $this->summary = ''; + $this->sectiontitle = ''; + $this->edittime = ''; + $this->editRevId = null; + $this->starttime = wfTimestampNow(); + $this->edit = false; + $this->preview = false; + $this->save = false; + $this->diff = false; + $this->minoredit = false; + // Watch may be overridden by request parameters + $this->watchthis = $request->getBool( 'watchthis', false ); + $this->recreate = false; + + // When creating a new section, we can preload a section title by passing it as the + // preloadtitle parameter in the URL (T15100) + if ( $this->section == 'new' && $request->getVal( 'preloadtitle' ) ) { + $this->sectiontitle = $request->getVal( 'preloadtitle' ); + // Once wpSummary isn't being use for setting section titles, we should delete this. + $this->summary = $request->getVal( 'preloadtitle' ); + } elseif ( $this->section != 'new' && $request->getVal( 'summary' ) ) { + $this->summary = $request->getText( 'summary' ); + if ( $this->summary !== '' ) { + $this->hasPresetSummary = true; + } + } + + if ( $request->getVal( 'minor' ) ) { + $this->minoredit = true; + } + } + + $this->oldid = $request->getInt( 'oldid' ); + $this->parentRevId = $request->getInt( 'parentRevId' ); + + $this->bot = $request->getBool( 'bot', true ); + $this->nosummary = $request->getBool( 'nosummary' ); + + // May be overridden by revision. + $this->contentModel = $request->getText( 'model', $this->contentModel ); + // May be overridden by revision. + $this->contentFormat = $request->getText( 'format', $this->contentFormat ); + + try { + $handler = ContentHandler::getForModelID( $this->contentModel ); + } catch ( MWUnknownContentModelException $e ) { + throw new ErrorPageError( + 'editpage-invalidcontentmodel-title', + 'editpage-invalidcontentmodel-text', + [ wfEscapeWikiText( $this->contentModel ) ] + ); + } + + if ( !$handler->isSupportedFormat( $this->contentFormat ) ) { + throw new ErrorPageError( + 'editpage-notsupportedcontentformat-title', + 'editpage-notsupportedcontentformat-text', + [ + wfEscapeWikiText( $this->contentFormat ), + wfEscapeWikiText( ContentHandler::getLocalizedName( $this->contentModel ) ) + ] + ); + } + + /** + * @todo Check if the desired model is allowed in this namespace, and if + * a transition from the page's current model to the new model is + * allowed. + */ + + $this->editintro = $request->getText( 'editintro', + // Custom edit intro for new sections + $this->section === 'new' ? 'MediaWiki:addsection-editintro' : '' ); + + // Allow extensions to modify form data + Hooks::run( 'EditPage::importFormData', [ $this, $request ] ); + } + + /** + * Subpage overridable method for extracting the page content data from the + * posted form to be placed in $this->textbox1, if using customized input + * this method should be overridden and return the page text that will be used + * for saving, preview parsing and so on... + * + * @param WebRequest &$request + * @return string|null + */ + protected function importContentFormData( &$request ) { + return; // Don't do anything, EditPage already extracted wpTextbox1 + } + + /** + * 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( "
" . + $this->context->msg( 'undo-' . $undoMsg )->plain() . '
', 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( "
\n$1\n
", [ + 'sharedupload-desc-create', $file->getRepo()->getDisplayName(), $descUrl + ] ); + } else { + $out->wrapWikiMsg( "
\n$1\n
", [ + '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( '~~', '', $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( + '
' . + $this->context->msg( $key, $username, "$comment" )->parse() . + Xml::checkLabel( $this->context->msg( 'recreate' )->text(), 'wpRecreate', 'wpRecreate', false, + [ 'title' => Linker::titleAttrib( 'recreate' ), 'tabindex' => 1, 'id' => 'wpRecreate' ] + ) . + '
' + ); } - # Show applicable editing introductions - if( $this->formtype == 'initial' || $this->firsttime ) - $this->showIntro(); - - if( $this->mTitle->isTalkPage() ) { - $wgOut->addWikiText( wfMsg( 'talkpagetext' ) ); + # When the summary is hidden, also hide them on preview/show changes + if ( $this->nosummary ) { + $out->addHTML( Html::hidden( 'nosummary', true ) ); } - # Attempt submission here. This will check for edit conflicts, - # and redundantly check for locked database, blocked IPs, etc. - # that edit() already checked just in case someone tries to sneak - # in the back door with a hand-edited submission URL. + # If a blank edit summary was previously provided, and the appropriate + # user preference is active, pass a hidden tag as wpIgnoreBlankSummary. This will stop the + # user being bounced back more than once in the event that a summary + # is not required. + # #### + # For a bit more sophisticated detection of blank summaries, hash the + # automatic one and pass that in the hidden field wpAutoSummary. + if ( $this->missingSummary || ( $this->section == 'new' && $this->nosummary ) ) { + $out->addHTML( Html::hidden( 'wpIgnoreBlankSummary', true ) ); + } - if ( 'save' == $this->formtype ) { - if ( !$this->attemptSave() ) { - wfProfileOut( "$fname-business-end" ); - wfProfileOut( $fname ); - return; - } + if ( $this->undidRev ) { + $out->addHTML( Html::hidden( 'wpUndidRevision', $this->undidRev ) ); } - # First time through: get contents, set time for conflict - # checking, etc. - if ( 'initial' == $this->formtype || $this->firsttime ) { - if ($this->initialiseForm() === false) { - $this->noSuchSectionPage(); - wfProfileOut( "$fname-business-end" ); - wfProfileOut( $fname ); - return; - } - if( !$this->mTitle->getArticleId() ) - wfRunHooks( 'EditFormPreloadText', array( &$this->textbox1, &$this->mTitle ) ); + if ( $this->selfRedirect ) { + $out->addHTML( Html::hidden( 'wpIgnoreSelfRedirect', true ) ); } - $this->showEditForm(); - wfProfileOut( "$fname-business-end" ); - wfProfileOut( $fname ); - } + if ( $this->hasPresetSummary ) { + // If a summary has been preset using &summary= we don't want to prompt for + // a different summary. Only prompt for a summary if the summary is blanked. + // (T19416) + $this->autoSumm = md5( '' ); + } - /** - * Should we show a preview when the edit form is first shown? - * - * @return bool - */ - private function previewOnOpen() { - global $wgRequest, $wgUser; - if( $wgRequest->getVal( 'preview' ) == 'yes' ) { - // Explicit override from request - return true; - } elseif( $wgRequest->getVal( 'preview' ) == 'no' ) { - // Explicit override from request - return false; - } elseif( $this->section == 'new' ) { - // Nothing *to* preview for new sections - return false; - } elseif( ( $wgRequest->getVal( 'preload' ) !== '' || $this->mTitle->exists() ) && $wgUser->getOption( 'previewonfirst' ) ) { - // Standard preference behaviour - return true; - } elseif( !$this->mTitle->exists() && $this->mTitle->getNamespace() == NS_CATEGORY ) { - // Categories are special - return true; + $autosumm = $this->autoSumm ? $this->autoSumm : md5( $this->summary ); + $out->addHTML( Html::hidden( 'wpAutoSummary', $autosumm ) ); + + $out->addHTML( Html::hidden( 'oldid', $this->oldid ) ); + $out->addHTML( Html::hidden( 'parentRevId', $this->getParentRevId() ) ); + + $out->addHTML( Html::hidden( 'format', $this->contentFormat ) ); + $out->addHTML( Html::hidden( 'model', $this->contentModel ) ); + + $out->enableOOUI(); + + if ( $this->section == 'new' ) { + $this->showSummaryInput( true, $this->summary ); + $out->addHTML( $this->getSummaryPreview( true, $this->summary ) ); + } + + $out->addHTML( $this->editFormTextBeforeContent ); + + if ( !$this->mTitle->isCssJsSubpage() && $showToolbar && $user->getOption( 'showtoolbar' ) ) { + $out->addHTML( self::getEditToolbar( $this->mTitle ) ); + } + + if ( $this->blankArticle ) { + $out->addHTML( Html::hidden( 'wpIgnoreBlankArticle', true ) ); + } + + if ( $this->isConflict ) { + // In an edit conflict bypass the overridable content form method + // and fallback to the raw wpTextbox1 since editconflicts can't be + // resolved between page source edits and custom ui edits using the + // custom edit ui. + $this->textbox2 = $this->textbox1; + + $content = $this->getCurrentContent(); + $this->textbox1 = $this->toEditText( $content ); + + $this->showTextbox1(); } else { - return false; + $this->showContentForm(); } - } - /** - * @todo document - * @param $request - */ - function importFormData( &$request ) { - global $wgLang, $wgUser; - $fname = 'EditPage::importFormData'; - wfProfileIn( $fname ); + $out->addHTML( $this->editFormTextAfterContent ); - if( $request->wasPosted() ) { - # These fields need to be checked for encoding. - # Also remove trailing whitespace, but don't remove _initial_ - # whitespace from the text boxes. This may be significant formatting. - $this->textbox1 = $this->safeUnicodeInput( $request, 'wpTextbox1' ); - $this->textbox2 = $this->safeUnicodeInput( $request, 'wpTextbox2' ); - $this->mMetaData = rtrim( $request->getText( 'metadata' ) ); - # Truncate for whole multibyte characters. +5 bytes for ellipsis - $this->summary = $wgLang->truncate( $request->getText( 'wpSummary' ), 250 ); + $this->showStandardInputs(); - $this->edittime = $request->getVal( 'wpEdittime' ); - $this->starttime = $request->getVal( 'wpStarttime' ); + $this->showFormAfterText(); - $this->scrolltop = $request->getIntOrNull( 'wpScrolltop' ); + $this->showTosSummary(); - if( is_null( $this->edittime ) ) { - # If the form is incomplete, force to preview. - wfDebug( "$fname: Form data appears to be incomplete\n" ); - wfDebug( "POST DATA: " . var_export( $_POST, true ) . "\n" ); - $this->preview = true; - } else { - /* Fallback for live preview */ - $this->preview = $request->getCheck( 'wpPreview' ) || $request->getCheck( 'wpLivePreview' ); - $this->diff = $request->getCheck( 'wpDiff' ); + $this->showEditTools(); - // Remember whether a save was requested, so we can indicate - // if we forced preview due to session failure. - $this->mTriedSave = !$this->preview; + $out->addHTML( $this->editFormTextAfterTools . "\n" ); - if ( $this->tokenOk( $request ) ) { - # Some browsers will not report any submit button - # if the user hits enter in the comment box. - # The unmarked state will be assumed to be a save, - # if the form seems otherwise complete. - wfDebug( "$fname: Passed token check.\n" ); - } else if ( $this->diff ) { - # Failed token check, but only requested "Show Changes". - wfDebug( "$fname: Failed token check; Show Changes requested.\n" ); - } else { - # Page might be a hack attempt posted from - # an external site. Preview instead of saving. - wfDebug( "$fname: Failed token check; forcing preview\n" ); - $this->preview = true; - } - } - $this->save = ! ( $this->preview OR $this->diff ); - if( !preg_match( '/^\d{14}$/', $this->edittime )) { - $this->edittime = null; - } + $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) ); - if( !preg_match( '/^\d{14}$/', $this->starttime )) { - $this->starttime = null; - } + $out->addHTML( Html::rawElement( 'div', [ 'class' => 'hiddencats' ], + Linker::formatHiddenCategories( $this->page->getHiddenCategories() ) ) ); - $this->recreate = $request->getCheck( 'wpRecreate' ); + $out->addHTML( Html::rawElement( 'div', [ 'class' => 'limitreport' ], + self::getPreviewLimitReport( $this->mParserOutput ) ) ); - $this->minoredit = $request->getCheck( 'wpMinoredit' ); - $this->watchthis = $request->getCheck( 'wpWatchthis' ); + $out->addModules( 'mediawiki.action.edit.collapsibleFooter' ); - # Don't force edit summaries when a user is editing their own user or talk page - if( ( $this->mTitle->mNamespace == NS_USER || $this->mTitle->mNamespace == NS_USER_TALK ) && $this->mTitle->getText() == $wgUser->getName() ) { - $this->allowBlankSummary = true; - } else { - $this->allowBlankSummary = $request->getBool( 'wpIgnoreBlankSummary' ); + if ( $this->isConflict ) { + try { + $this->showConflict(); + } catch ( MWContentSerializationException $ex ) { + // this can't really happen, but be nice if it does. + $msg = $this->context->msg( + 'content-failed-to-parse', + $this->contentModel, + $this->contentFormat, + $ex->getMessage() + ); + $out->addWikiText( '
' . $msg->text() . '
' ); } + } - $this->autoSumm = $request->getText( 'wpAutoSummary' ); + // Set a hidden field so JS knows what edit form mode we are in + if ( $this->isConflict ) { + $mode = 'conflict'; + } elseif ( $this->preview ) { + $mode = 'preview'; + } elseif ( $this->diff ) { + $mode = 'diff'; } else { - # Not a posted form? Start with nothing. - wfDebug( "$fname: Not a posted form.\n" ); - $this->textbox1 = ''; - $this->textbox2 = ''; - $this->mMetaData = ''; - $this->summary = ''; - $this->edittime = ''; - $this->starttime = wfTimestampNow(); - $this->edit = false; - $this->preview = false; - $this->save = false; - $this->diff = false; - $this->minoredit = false; - $this->watchthis = false; - $this->recreate = false; + $mode = 'text'; } + $out->addHTML( Html::hidden( 'mode', $mode, [ 'id' => 'mw-edit-mode' ] ) ); - $this->oldid = $request->getInt( 'oldid' ); - - # Section edit can come from either the form or a link - $this->section = $request->getVal( 'wpSection', $request->getVal( 'section' ) ); + // Marker for detecting truncated form data. This must be the last + // parameter sent in order to be of use, so do not move me. + $out->addHTML( Html::hidden( 'wpUltimateParam', true ) ); + $out->addHTML( $this->editFormTextBottom . "\n\n" ); - $this->live = $request->getCheck( 'live' ); - $this->editintro = $request->getText( 'editintro' ); - - wfProfileOut( $fname ); + if ( !$user->getOption( 'previewontop' ) ) { + $this->displayPreviewArea( $previewOutput, false ); + } } /** - * Make sure the form isn't faking a user's credentials. + * Wrapper around TemplatesOnThisPageFormatter to make + * a "templates on this page" list. * - * @param $request WebRequest - * @return bool - * @private + * @param Title[] $templates + * @return string HTML */ - function tokenOk( &$request ) { - global $wgUser; - $token = $request->getVal( 'wpEditToken' ); - $this->mTokenOk = $wgUser->matchEditToken( $token ); - $this->mTokenOkExceptSuffix = $wgUser->matchEditTokenNoSuffix( $token ); - return $this->mTokenOk; - } + public function makeTemplatesOnThisPageList( array $templates ) { + $templateListFormatter = new TemplatesOnThisPageFormatter( + $this->context, MediaWikiServices::getInstance()->getLinkRenderer() + ); - /** - * Show all applicable editing introductions - */ - private function showIntro() { - global $wgOut, $wgUser; - if( !$this->showCustomIntro() && !$this->mTitle->exists() ) { - if( $wgUser->isLoggedIn() ) { - $wgOut->addWikiText( wfMsg( 'newarticletext' ) ); - } else { - $wgOut->addWikiText( wfMsg( 'newarticletextanon' ) ); - } - $this->showDeletionLog( $wgOut ); + // preview if preview, else section if section, else false + $type = false; + if ( $this->preview ) { + $type = 'preview'; + } elseif ( $this->section != '' ) { + $type = 'section'; } + + return Html::rawElement( 'div', [ 'class' => 'templatesUsed' ], + $templateListFormatter->format( $templates, $type ) + ); } - + /** - * Attempt to show a custom editing introduction, if supplied + * Extract the section title from current section text, if any. * - * @return bool + * @param string $text + * @return string|bool String or false */ - private function showCustomIntro() { - if( $this->editintro ) { - $title = Title::newFromText( $this->editintro ); - if( $title instanceof Title && $title->exists() && $title->userCanRead() ) { - global $wgOut; - $revision = Revision::newFromTitle( $title ); - $wgOut->addSecondaryWikiText( $revision->getText() ); - return true; - } else { - return false; - } + public static function extractSectionTitle( $text ) { + preg_match( "/^(=+)(.+)\\1\\s*(\n|$)/i", $text, $matches ); + if ( !empty( $matches[2] ) ) { + global $wgParser; + return $wgParser->stripSectionName( trim( $matches[2] ) ); } else { return false; } } - /** - * Attempt submission - * @return bool false if output is done, true if the rest of the form should be displayed - */ - function attemptSave() { - global $wgSpamRegex, $wgFilterCallback, $wgUser, $wgOut; - global $wgMaxArticleSize; - - $fname = 'EditPage::attemptSave'; - wfProfileIn( $fname ); - wfProfileIn( "$fname-checks" ); - - if( !wfRunHooks( 'EditPage::attemptSave', array( &$this ) ) ) - { - wfDebug( "Hook 'EditPage::attemptSave' aborted article saving" ); - return false; - } - - # Reintegrate metadata - if ( $this->mMetaData != '' ) $this->textbox1 .= "\n" . $this->mMetaData ; - $this->mMetaData = '' ; - - # Check for spam - $matches = array(); - if ( $wgSpamRegex && preg_match( $wgSpamRegex, $this->textbox1, $matches ) ) { - $this->spamPage ( $matches[0] ); - wfProfileOut( "$fname-checks" ); - wfProfileOut( $fname ); - return false; - } - if ( $wgFilterCallback && $wgFilterCallback( $this->mTitle, $this->textbox1, $this->section ) ) { - # Error messages or other handling should be performed by the filter function - wfProfileOut( $fname ); - wfProfileOut( "$fname-checks" ); - return false; - } - if ( !wfRunHooks( 'EditFilter', array( $this, $this->textbox1, $this->section, &$this->hookError ) ) ) { - # Error messages etc. could be handled within the hook... - wfProfileOut( $fname ); - wfProfileOut( "$fname-checks" ); - return false; - } elseif( $this->hookError != '' ) { - # ...or the hook could be expecting us to produce an error - wfProfileOut( "$fname-checks " ); - wfProfileOut( $fname ); - return true; - } - if ( $wgUser->isBlockedFrom( $this->mTitle, false ) ) { - # Check block state against master, thus 'false'. - $this->blockedPage(); - wfProfileOut( "$fname-checks" ); - wfProfileOut( $fname ); - return false; - } - $this->kblength = (int)(strlen( $this->textbox1 ) / 1024); - if ( $this->kblength > $wgMaxArticleSize ) { - // Error will be displayed by showEditForm() - $this->tooBig = true; - wfProfileOut( "$fname-checks" ); - wfProfileOut( $fname ); - return true; - } - - if ( !$wgUser->isAllowed('edit') ) { - if ( $wgUser->isAnon() ) { - $this->userNotLoggedInPage(); - wfProfileOut( "$fname-checks" ); - wfProfileOut( $fname ); - return false; - } - else { - $wgOut->readOnlyPage(); - wfProfileOut( "$fname-checks" ); - wfProfileOut( $fname ); - return false; + protected function showHeader() { + $out = $this->context->getOutput(); + $user = $this->context->getUser(); + if ( $this->isConflict ) { + $this->addExplainConflictHeader( $out ); + $this->editRevId = $this->page->getLatest(); + } else { + if ( $this->section != '' && $this->section != 'new' ) { + if ( !$this->summary && !$this->preview && !$this->diff ) { + $sectionTitle = self::extractSectionTitle( $this->textbox1 ); // FIXME: use Content object + if ( $sectionTitle !== false ) { + $this->summary = "/* $sectionTitle */ "; + } + } } - } - - if ( wfReadOnly() ) { - $wgOut->readOnlyPage(); - wfProfileOut( "$fname-checks" ); - wfProfileOut( $fname ); - return false; - } - if ( $wgUser->pingLimiter() ) { - $wgOut->rateLimited(); - wfProfileOut( "$fname-checks" ); - wfProfileOut( $fname ); - return false; - } - - # If the article has been deleted while editing, don't save it without - # confirmation - if ( $this->deletedSinceEdit && !$this->recreate ) { - wfProfileOut( "$fname-checks" ); - wfProfileOut( $fname ); - return true; - } - wfProfileOut( "$fname-checks" ); + $buttonLabel = $this->context->msg( $this->getSubmitButtonLabel() )->text(); - # If article is new, insert it. - $aid = $this->mTitle->getArticleID( GAID_FOR_UPDATE ); - if ( 0 == $aid ) { + if ( $this->missingComment ) { + $out->wrapWikiMsg( "
\n$1\n
", 'missingcommenttext' ); + } - // Late check for create permission, just in case *PARANOIA* - if ( !$this->mTitle->userCan( 'create' ) ) { - wfDebug( "$fname: no create permission\n" ); - $this->noCreatePermission(); - wfProfileOut( $fname ); - return; + if ( $this->missingSummary && $this->section != 'new' ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'missingsummary', $buttonLabel ] + ); } - # Don't save a new article if it's blank. - if ( ( '' == $this->textbox1 ) ) { - $wgOut->redirect( $this->mTitle->getFullURL() ); - wfProfileOut( $fname ); - return false; + if ( $this->missingSummary && $this->section == 'new' ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'missingcommentheader', $buttonLabel ] + ); } - $isComment=($this->section=='new'); - $this->mArticle->insertNewArticle( $this->textbox1, $this->summary, - $this->minoredit, $this->watchthis, false, $isComment); + if ( $this->blankArticle ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'blankarticle', $buttonLabel ] + ); + } - wfProfileOut( $fname ); - return false; - } + if ( $this->selfRedirect ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'selfredirect', $buttonLabel ] + ); + } - # Article exists. Check for edit conflict. + if ( $this->hookError !== '' ) { + $out->addWikiText( $this->hookError ); + } - $this->mArticle->clear(); # Force reload of dates, etc. - $this->mArticle->forUpdate( true ); # Lock the article + if ( $this->section != 'new' ) { + $revision = $this->mArticle->getRevisionFetched(); + if ( $revision ) { + // Let sysop know that this will make private content public if saved + + if ( !$revision->userCan( Revision::DELETED_TEXT, $user ) ) { + $out->wrapWikiMsg( + "\n", + 'rev-deleted-text-permission' + ); + } elseif ( $revision->isDeleted( Revision::DELETED_TEXT ) ) { + $out->wrapWikiMsg( + "\n", + 'rev-deleted-text-view' + ); + } - wfDebug("timestamp: {$this->mArticle->getTimestamp()}, edittime: {$this->edittime}\n"); + if ( !$revision->isCurrent() ) { + $this->mArticle->setOldSubtitle( $revision->getId() ); + $out->addWikiMsg( 'editingold' ); + $this->isOldRev = true; + } + } elseif ( $this->mTitle->exists() ) { + // Something went wrong - if( $this->mArticle->getTimestamp() != $this->edittime ) { - $this->isConflict = true; - if( $this->section == 'new' ) { - if( $this->mArticle->getUserText() == $wgUser->getName() && - $this->mArticle->getComment() == $this->summary ) { - // Probably a duplicate submission of a new comment. - // This can happen when squid resends a request after - // a timeout but the first one actually went through. - wfDebug( "EditPage::editForm duplicate new section submission; trigger edit conflict!\n" ); - } else { - // New comment; suppress conflict. - $this->isConflict = false; - wfDebug( "EditPage::editForm conflict suppressed; new section\n" ); + $out->wrapWikiMsg( "
\n$1\n
\n", + [ 'missing-revision', $this->oldid ] ); } } } - $userid = $wgUser->getID(); - if ( $this->isConflict) { - wfDebug( "EditPage::editForm conflict! getting section '$this->section' for time '$this->edittime' (article time '" . - $this->mArticle->getTimestamp() . "')\n" ); - $text = $this->mArticle->replaceSection( $this->section, $this->textbox1, $this->summary, $this->edittime); - } - else { - wfDebug( "EditPage::editForm getting section '$this->section'\n" ); - $text = $this->mArticle->replaceSection( $this->section, $this->textbox1, $this->summary); - } - if( is_null( $text ) ) { - wfDebug( "EditPage::editForm activating conflict; section replace failed.\n" ); - $this->isConflict = true; - $text = $this->textbox1; - } - - # Suppress edit conflict with self, except for section edits where merging is required. - if ( ( $this->section == '' ) && ( 0 != $userid ) && ( $this->mArticle->getUser() == $userid ) ) { - wfDebug( "EditPage::editForm Suppressing edit conflict, same user.\n" ); - $this->isConflict = false; + if ( wfReadOnly() ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'readonlywarning', wfReadOnlyReason() ] + ); + } elseif ( $user->isAnon() ) { + if ( $this->formtype != 'preview' ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'anoneditwarning', + // Log-in link + SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [ + 'returnto' => $this->getTitle()->getPrefixedDBkey() + ] ), + // Sign-up link + SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [ + 'returnto' => $this->getTitle()->getPrefixedDBkey() + ] ) + ] + ); + } else { + $out->wrapWikiMsg( "
\n$1
", + 'anonpreviewwarning' + ); + } } else { - # switch from section editing to normal editing in edit conflict - if($this->isConflict) { - # Attempt merge - if( $this->mergeChangesInto( $text ) ){ - // Successful merge! Maybe we should tell the user the good news? - $this->isConflict = false; - wfDebug( "EditPage::editForm Suppressing edit conflict, successful merge.\n" ); - } else { - $this->section = ''; - $this->textbox1 = $text; - wfDebug( "EditPage::editForm Keeping edit conflict, failed merge.\n" ); + if ( $this->mTitle->isCssJsSubpage() ) { + # Check the skin exists + if ( $this->isWrongCaseCssJsPage() ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'userinvalidcssjstitle', $this->mTitle->getSkinFromCssJsSubpage() ] + ); + } + if ( $this->getTitle()->isSubpageOf( $user->getUserPage() ) ) { + $isCssSubpage = $this->mTitle->isCssSubpage(); + $out->wrapWikiMsg( '
$1
', + $isCssSubpage ? 'usercssispublic' : 'userjsispublic' + ); + if ( $this->formtype !== 'preview' ) { + $config = $this->context->getConfig(); + if ( $isCssSubpage && $config->get( 'AllowUserCss' ) ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'usercssyoucanpreview' ] + ); + } + + if ( $this->mTitle->isJsSubpage() && $config->get( 'AllowUserJs' ) ) { + $out->wrapWikiMsg( + "
\n$1\n
", + [ 'userjsyoucanpreview' ] + ); + } + } } } } - if ( $this->isConflict ) { - wfProfileOut( $fname ); - return true; - } + $this->addPageProtectionWarningHeaders(); - $oldtext = $this->mArticle->getContent(); + $this->addLongPageWarningHeader(); - # Handle the user preference to force summaries here, but not for null edits - if( $this->section != 'new' && !$this->allowBlankSummary && $wgUser->getOption( 'forceeditsummary') - && 0 != strcmp($oldtext, $text) && !Article::getRedirectAutosummary( $text )) { - if( md5( $this->summary ) == $this->autoSumm ) { - $this->missingSummary = true; - wfProfileOut( $fname ); - return( true ); - } - } + # Add header copyright warning + $this->showHeaderCopyrightWarning(); + } - #And a similar thing for new sections - if( $this->section == 'new' && !$this->allowBlankSummary && $wgUser->getOption( 'forceeditsummary' ) ) { - if (trim($this->summary) == '') { - $this->missingSummary = true; - wfProfileOut( $fname ); - return( true ); - } + /** + * Helper function for summary input functions, which returns the neccessary + * attributes for the input. + * + * @param array|null $inputAttrs Array of attrs to use on the input + * @return array + */ + private function getSummaryInputAttributes( array $inputAttrs = null ) { + // Note: the maxlength is overridden in JS to 255 and to make it use UTF-8 bytes, not characters. + return ( is_array( $inputAttrs ) ? $inputAttrs : [] ) + [ + 'id' => 'wpSummary', + 'name' => 'wpSummary', + 'maxlength' => '200', + 'tabindex' => 1, + 'size' => 60, + 'spellcheck' => 'true', + ]; + } + + /** + * Standard summary input and label (wgSummary), abstracted so EditPage + * subclasses may reorganize the form. + * Note that you do not need to worry about the label's for=, it will be + * inferred by the id given to the input. You can remove them both by + * passing [ 'id' => false ] to $userInputAttrs. + * + * @deprecated since 1.30 Use getSummaryInputWidget() instead + * @param string $summary The value of the summary input + * @param string $labelText The html to place inside the label + * @param array $inputAttrs Array of attrs to use on the input + * @param array $spanLabelAttrs Array of attrs to use on the span inside the label + * @return array An array in the format [ $label, $input ] + */ + public function getSummaryInput( $summary = "", $labelText = null, + $inputAttrs = null, $spanLabelAttrs = null + ) { + wfDeprecated( __METHOD__, '1.30' ); + $inputAttrs = $this->getSummaryInputAttributes( $inputAttrs ); + $inputAttrs += Linker::tooltipAndAccesskeyAttribs( 'summary' ); + + $spanLabelAttrs = ( is_array( $spanLabelAttrs ) ? $spanLabelAttrs : [] ) + [ + 'class' => $this->missingSummary ? 'mw-summarymissed' : 'mw-summary', + 'id' => "wpSummaryLabel" + ]; + + $label = null; + if ( $labelText ) { + $label = Xml::tags( + 'label', + $inputAttrs['id'] ? [ 'for' => $inputAttrs['id'] ] : null, + $labelText + ); + $label = Xml::tags( 'span', $spanLabelAttrs, $label ); } - # All's well - wfProfileIn( "$fname-sectionanchor" ); - $sectionanchor = ''; - if( $this->section == 'new' ) { - if ( $this->textbox1 == '' ) { - $this->missingComment = true; - return true; - } - if( $this->summary != '' ) { - $sectionanchor = $this->sectionAnchor( $this->summary ); - # This is a new section, so create a link to the new section - # in the revision summary. - $this->summary = wfMsgForContent('newsectionsummary') . - " [[{$this->mTitle->getPrefixedText()}#{$this->summary}|{$this->summary}]]"; - } - } 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::replaceSection - # 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 and strlen($matches[2]) > 0) { - $sectionanchor = $this->sectionAnchor( $matches[2] ); - } - } - wfProfileOut( "$fname-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 = $text; - $this->section = ''; + $input = Html::input( 'wpSummary', $summary, 'text', $inputAttrs ); - // Check for length errors again now that the section is merged in - $this->kblength = (int)(strlen( $text ) / 1024); - if ( $this->kblength > $wgMaxArticleSize ) { - $this->tooBig = true; - wfProfileOut( $fname ); - return true; - } + return [ $label, $input ]; + } - # update the article here - if( $this->mArticle->updateArticle( $text, $this->summary, $this->minoredit, - $this->watchthis, '', $sectionanchor ) ) { - wfProfileOut( $fname ); - return false; - } else { - $this->isConflict = true; - } - wfProfileOut( $fname ); - return true; + /** + * Builds a standard summary input with a label. + * + * @deprecated since 1.30 Use getSummaryInputWidget() instead + * @param string $summary The value of the summary input + * @param string $labelText The html to place inside the label + * @param array $inputAttrs Array of attrs to use on the input + * + * @return OOUI\FieldLayout OOUI FieldLayout with Label and Input + */ + function getSummaryInputOOUI( $summary = "", $labelText = null, $inputAttrs = null ) { + wfDeprecated( __METHOD__, '1.30' ); + $this->getSummaryInputWidget( $summary, $labelText, $inputAttrs ); } /** - * Initialise form fields in the object - * Called on the first invocation, e.g. when a user clicks an edit link + * Builds a standard summary input with a label. + * + * @param string $summary The value of the summary input + * @param string $labelText The html to place inside the label + * @param array $inputAttrs Array of attrs to use on the input + * + * @return OOUI\FieldLayout OOUI FieldLayout with Label and Input */ - function initialiseForm() { - $this->edittime = $this->mArticle->getTimestamp(); - $this->summary = ''; - $this->textbox1 = $this->getContent(false); - if ($this->textbox1 === false) return false; + function getSummaryInputWidget( $summary = "", $labelText = null, $inputAttrs = null ) { + $inputAttrs = OOUI\Element::configFromHtmlAttributes( + $this->getSummaryInputAttributes( $inputAttrs ) + ); + $inputAttrs += [ + 'title' => Linker::titleAttrib( 'summary' ), + 'accessKey' => Linker::accesskey( 'summary' ), + ]; + + // For compatibility with old scripts and extensions, we want the legacy 'id' on the `` + $inputAttrs['inputId'] = $inputAttrs['id']; + $inputAttrs['id'] = 'wpSummaryWidget'; + + return new OOUI\FieldLayout( + new OOUI\TextInputWidget( [ + 'value' => $summary, + 'infusable' => true, + ] + $inputAttrs ), + [ + 'label' => new OOUI\HtmlSnippet( $labelText ), + 'align' => 'top', + 'id' => 'wpSummaryLabel', + 'classes' => [ $this->missingSummary ? 'mw-summarymissed' : 'mw-summary' ], + ] + ); + } - if ( !$this->mArticle->exists() && $this->mArticle->mTitle->getNamespace() == NS_MEDIAWIKI ) - $this->textbox1 = wfMsgWeirdKey( $this->mArticle->mTitle->getText() ); - wfProxyCheck(); - return true; + /** + * @param bool $isSubjectPreview True if this is the section subject/title + * up top, or false if this is the comment summary + * down below the textarea + * @param string $summary The text of the summary to display + */ + protected function showSummaryInput( $isSubjectPreview, $summary = "" ) { + # Add a class if 'missingsummary' is triggered to allow styling of the summary line + $summaryClass = $this->missingSummary ? 'mw-summarymissed' : 'mw-summary'; + if ( $isSubjectPreview ) { + if ( $this->nosummary ) { + return; + } + } else { + if ( !$this->mShowSummaryField ) { + return; + } + } + + $labelText = $this->context->msg( $isSubjectPreview ? 'subject' : 'summary' )->parse(); + $this->context->getOutput()->addHTML( $this->getSummaryInputWidget( + $summary, + $labelText, + [ 'class' => $summaryClass ] + ) ); } /** - * Send the edit form and related headers to $wgOut - * @param $formCallback Optional callable that takes an OutputPage - * parameter; will be called during form output - * near the top, for captchas and the like. + * @param bool $isSubjectPreview True if this is the section subject/title + * up top, or false if this is the comment summary + * down below the textarea + * @param string $summary The text of the summary to display + * @return string */ - function showEditForm( $formCallback=null ) { - global $wgOut, $wgUser, $wgLang, $wgContLang, $wgMaxArticleSize; + protected function getSummaryPreview( $isSubjectPreview, $summary = "" ) { + // avoid spaces in preview, gets always trimmed on save + $summary = trim( $summary ); + if ( !$summary || ( !$this->preview && !$this->diff ) ) { + return ""; + } - $fname = 'EditPage::showEditForm'; - wfProfileIn( $fname ); + global $wgParser; - $sk = $wgUser->getSkin(); + if ( $isSubjectPreview ) { + $summary = $this->context->msg( 'newsectionsummary' ) + ->rawParams( $wgParser->stripSectionName( $summary ) ) + ->inContentLanguage()->text(); + } - wfRunHooks( 'EditPage::showEditForm:initial', array( &$this ) ) ; + $message = $isSubjectPreview ? 'subject-preview' : 'summary-preview'; - $wgOut->setRobotpolicy( 'noindex,nofollow' ); + $summary = $this->context->msg( $message )->parse() + . Linker::commentBlock( $summary, $this->mTitle, $isSubjectPreview ); + return Xml::tags( 'div', [ 'class' => 'mw-summary-preview' ], $summary ); + } - # Enabled article-related sidebar, toplinks, etc. - $wgOut->setArticleRelated( true ); + protected function showFormBeforeText() { + $out = $this->context->getOutput(); + $out->addHTML( Html::hidden( 'wpSection', $this->section ) ); + $out->addHTML( Html::hidden( 'wpStarttime', $this->starttime ) ); + $out->addHTML( Html::hidden( 'wpEdittime', $this->edittime ) ); + $out->addHTML( Html::hidden( 'editRevId', $this->editRevId ) ); + $out->addHTML( Html::hidden( 'wpScrolltop', $this->scrolltop, [ 'id' => 'wpScrolltop' ] ) ); + } - if ( $this->formtype == 'preview' ) { - $wgOut->setPageTitleActionText( wfMsg( 'preview' ) ); - } + protected function showFormAfterText() { + /** + * 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. + */ + $this->context->getOutput()->addHTML( + "\n" . + Html::hidden( "wpEditToken", $this->context->getUser()->getEditToken() ) . + "\n" + ); + } - if ( $this->isConflict ) { - $s = wfMsg( 'editconflict', $this->mTitle->getPrefixedText() ); - $wgOut->setPageTitle( $s ); - $wgOut->addWikiText( wfMsg( 'explainconflict' ) ); + /** + * Subpage overridable method for printing the form for page content editing + * By default this simply outputs wpTextbox1 + * Subclasses can override this to provide a custom UI for editing; + * be it a form, or simply wpTextbox1 with a modified content that will be + * reverse modified when extracted from the post data. + * Note that this is basically the inverse for importContentFormData + */ + protected function showContentForm() { + $this->showTextbox1(); + } - $this->textbox2 = $this->textbox1; - $this->textbox1 = $this->getContent(); - $this->edittime = $this->mArticle->getTimestamp(); + /** + * Method to output wpTextbox1 + * The $textoverride method can be used by subclasses overriding showContentForm + * to pass back to this method. + * + * @param array $customAttribs Array of html attributes to use in the textarea + * @param string $textoverride Optional text to override $this->textarea1 with + */ + protected function showTextbox1( $customAttribs = null, $textoverride = null ) { + if ( $this->wasDeletedSinceLastEdit() && $this->formtype == 'save' ) { + $attribs = [ 'style' => 'display:none;' ]; } else { - - if( $this->section != '' ) { - if( $this->section == 'new' ) { - $s = wfMsg('editingcomment', $this->mTitle->getPrefixedText() ); + $classes = []; // Textarea CSS + if ( $this->mTitle->isProtected( 'edit' ) && + MWNamespace::getRestrictionLevels( $this->mTitle->getNamespace() ) !== [ '' ] + ) { + # Is the title semi-protected? + if ( $this->mTitle->isSemiProtected() ) { + $classes[] = 'mw-textarea-sprotected'; } else { - $s = wfMsg('editingsection', $this->mTitle->getPrefixedText() ); - $matches = array(); - if( !$this->summary && !$this->preview && !$this->diff ) { - preg_match( "/^(=+)(.+)\\1/mi", - $this->textbox1, - $matches ); - if( !empty( $matches[2] ) ) { - $this->summary = "/* ". trim($matches[2])." */ "; - } - } + # Then it must be protected based on static groups (regular) + $classes[] = 'mw-textarea-protected'; + } + # Is the title cascade-protected? + if ( $this->mTitle->isCascadeProtected() ) { + $classes[] = 'mw-textarea-cprotected'; } - } else { - $s = wfMsg( 'editing', $this->mTitle->getPrefixedText() ); - } - $wgOut->setPageTitle( $s ); - - if ( $this->missingComment ) { - $wgOut->addWikiText( wfMsg( 'missingcommenttext' ) ); } - - if( $this->missingSummary && $this->section != 'new' ) { - $wgOut->addWikiText( wfMsg( 'missingsummary' ) ); + # Is an old revision being edited? + if ( $this->isOldRev ) { + $classes[] = 'mw-textarea-oldrev'; } - if( $this->missingSummary && $this->section == 'new' ) { - $wgOut->addWikiText( wfMsg( 'missingcommentheader' ) ); - } + $attribs = [ 'tabindex' => 1 ]; - if( !$this->hookError == '' ) { - $wgOut->addWikiText( $this->hookError ); + if ( is_array( $customAttribs ) ) { + $attribs += $customAttribs; } - if ( !$this->checkUnicodeCompliantBrowser() ) { - $wgOut->addWikiText( wfMsg( 'nonunicodebrowser') ); - } - if ( isset( $this->mArticle ) && isset( $this->mArticle->mRevision ) ) { - // Let sysop know that this will make private content public if saved - if( $this->mArticle->mRevision->isDeleted( Revision::DELETED_TEXT ) ) { - $wgOut->addWikiText( wfMsg( 'rev-deleted-text-view' ) ); - } - if( !$this->mArticle->mRevision->isCurrent() ) { - $this->mArticle->setOldSubtitle( $this->mArticle->mRevision->getId() ); - $wgOut->addWikiText( wfMsg( 'editingold' ) ); + if ( count( $classes ) ) { + if ( isset( $attribs['class'] ) ) { + $classes[] = $attribs['class']; } + $attribs['class'] = implode( ' ', $classes ); } } - if( wfReadOnly() ) { - $wgOut->addWikiText( wfMsg( 'readonlywarning' ) ); - } elseif( $wgUser->isAnon() && $this->formtype != 'preview' ) { - $wgOut->addWikiText( wfMsg( 'anoneditwarning' ) ); - } else { - if( $this->isCssJsSubpage && $this->formtype != 'preview' ) { - # Check the skin exists - if( $this->isValidCssJsSubpage ) { - $wgOut->addWikiText( wfMsg( 'usercssjsyoucanpreview' ) ); - } else { - $wgOut->addWikiText( wfMsg( 'userinvalidcssjstitle', $this->mTitle->getSkinFromCssJsSubpage() ) ); - } - } - } + $this->showTextbox( + $textoverride !== null ? $textoverride : $this->textbox1, + 'wpTextbox1', + $attribs + ); + } - if( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) { - # Show a warning if editing an interface message - $wgOut->addWikiText( wfMsg( 'editinginterface' ) ); - } elseif( $this->mTitle->isProtected( 'edit' ) ) { - # Is the title semi-protected? - if( $this->mTitle->isSemiProtected() ) { - $notice = wfMsg( 'semiprotectedpagewarning' ); - if( wfEmptyMsg( 'semiprotectedpagewarning', $notice ) || $notice == '-' ) - $notice = ''; - } else { - # Then it must be protected based on static groups (regular) - $notice = wfMsg( 'protectedpagewarning' ); - } - $wgOut->addWikiText( $notice ); - } - if ( $this->mTitle->isCascadeProtected() ) { - # Is this page under cascading protection from some source pages? - list($cascadeSources, /* $restrictions */) = $this->mTitle->getCascadeProtectionSources(); - if ( count($cascadeSources) > 0 ) { - # Explain, and list the titles responsible - $notice = wfMsgExt( 'cascadeprotectedwarning', array('parsemag'), count($cascadeSources) ) . "\n"; - foreach( $cascadeSources as $page ) { - $notice .= '* [[:' . $page->getPrefixedText() . "]]\n"; - } - } - $wgOut->addWikiText( $notice ); - } + protected function showTextbox2() { + $this->showTextbox( $this->textbox2, 'wpTextbox2', [ 'tabindex' => 6, 'readonly' ] ); + } + + protected function showTextbox( $text, $name, $customAttribs = [] ) { + $wikitext = $this->addNewLineAtEnd( $text ); + + $attribs = $this->buildTextboxAttribs( $name, $customAttribs, $this->context->getUser() ); - if ( $this->kblength === false ) { - $this->kblength = (int)(strlen( $this->textbox1 ) / 1024); + $this->context->getOutput()->addHTML( Html::textarea( $name, $wikitext, $attribs ) ); + } + + protected function displayPreviewArea( $previewOutput, $isOnTop = false ) { + $classes = []; + if ( $isOnTop ) { + $classes[] = 'ontop'; } - if ( $this->tooBig || $this->kblength > $wgMaxArticleSize ) { - $wgOut->addWikiText( wfMsg( 'longpageerror', $wgLang->formatNum( $this->kblength ), $wgMaxArticleSize ) ); - } elseif( $this->kblength > 29 ) { - $wgOut->addWikiText( wfMsg( 'longpagewarning', $wgLang->formatNum( $this->kblength ) ) ); + + $attribs = [ 'id' => 'wikiPreview', 'class' => implode( ' ', $classes ) ]; + + if ( $this->formtype != 'preview' ) { + $attribs['style'] = 'display: none;'; } - #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 + $out = $this->context->getOutput(); + $out->addHTML( Xml::openElement( 'div', $attribs ) ); + if ( $this->formtype == 'preview' ) { - $previewOutput = $this->getPreviewText(); + $this->showPreview( $previewOutput ); + } else { + // Empty content container for LivePreview + $pageViewLang = $this->mTitle->getPageViewLanguage(); + $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(), + 'class' => 'mw-content-' . $pageViewLang->getDir() ]; + $out->addHTML( Html::rawElement( 'div', $attribs ) ); } - $rows = $wgUser->getIntOption( 'rows' ); - $cols = $wgUser->getIntOption( 'cols' ); + $out->addHTML( '' ); - $ew = $wgUser->getOption( 'editwidth' ); - if ( $ew ) $ew = " style=\"width:100%\""; - else $ew = ''; + if ( $this->formtype == 'diff' ) { + try { + $this->showDiff(); + } catch ( MWContentSerializationException $ex ) { + $msg = $this->context->msg( + 'content-failed-to-parse', + $this->contentModel, + $this->contentFormat, + $ex->getMessage() + ); + $out->addWikiText( '
' . $msg->text() . '
' ); + } + } + } - $q = 'action=submit'; - #if ( "no" == $redirect ) { $q .= "&redirect=no"; } - $action = $this->mTitle->escapeLocalURL( $q ); + /** + * Append preview output to OutputPage. + * Includes category rendering if this is a category page. + * + * @param string $text The HTML to be output for the preview. + */ + protected function showPreview( $text ) { + if ( $this->mArticle instanceof CategoryPage ) { + $this->mArticle->openShowCategory(); + } + # This hook seems slightly odd here, but makes things more + # consistent for extensions. + $out = $this->context->getOutput(); + Hooks::run( 'OutputPageBeforeHTML', [ &$out, &$text ] ); + $out->addHTML( $text ); + if ( $this->mArticle instanceof CategoryPage ) { + $this->mArticle->closeShowCategory(); + } + } - $summary = wfMsg('summary'); - $subject = wfMsg('subject'); + /** + * Get a diff between the current contents of the edit box and the + * version of the page we're editing from. + * + * If this is a section edit, we'll replace the section as for final + * save and then make a comparison. + */ + public function showDiff() { + global $wgContLang; - $cancel = $sk->makeKnownLink( $this->mTitle->getPrefixedText(), - wfMsgExt('cancel', array('parseinline')) ); - $edithelpurl = Skin::makeInternalOrExternalUrl( wfMsgForContent( 'edithelppage' )); - $edithelp = ''. - htmlspecialchars( wfMsg( 'edithelp' ) ).' '. - htmlspecialchars( wfMsg( 'newwindow' ) ); + $oldtitlemsg = 'currentrev'; + # if message does not exist, show diff against the preloaded default + if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI && !$this->mTitle->exists() ) { + $oldtext = $this->mTitle->getDefaultMessageText(); + if ( $oldtext !== false ) { + $oldtitlemsg = 'defaultmessagetext'; + $oldContent = $this->toEditContent( $oldtext ); + } else { + $oldContent = null; + } + } else { + $oldContent = $this->getCurrentContent(); + } - global $wgRightsText; - $copywarn = "
\n" . - wfMsg( $wgRightsText ? 'copyrightwarning' : 'copyrightwarning2', - '[[' . wfMsgForContent( 'copyrightpage' ) . ']]', - $wgRightsText ) . "\n
"; - - if( $wgUser->getOption('showtoolbar') and !$this->isCssJsSubpage ) { - # prepare toolbar for edit buttons - $toolbar = $this->getEditToolbar(); + $textboxContent = $this->toEditContent( $this->textbox1 ); + if ( $this->editRevId !== null ) { + $newContent = $this->page->replaceSectionAtRev( + $this->section, $textboxContent, $this->summary, $this->editRevId + ); } else { - $toolbar = ''; + $newContent = $this->page->replaceSectionContent( + $this->section, $textboxContent, $this->summary, $this->edittime + ); } - // activate checkboxes if user wants them to be always active - if( !$this->preview && !$this->diff ) { - # Sort out the "watch" checkbox - if( $wgUser->getOption( 'watchdefault' ) ) { - # Watch all edits - $this->watchthis = true; - } elseif( $wgUser->getOption( 'watchcreations' ) && !$this->mTitle->exists() ) { - # Watch creations - $this->watchthis = true; - } elseif( $this->mTitle->userIsWatching() ) { - # Already watched - $this->watchthis = true; - } + if ( $newContent ) { + Hooks::run( 'EditPageGetDiffContent', [ $this, &$newContent ] ); - if( $wgUser->getOption( 'minordefault' ) ) $this->minoredit = true; + $user = $this->context->getUser(); + $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang ); + $newContent = $newContent->preSaveTransform( $this->mTitle, $user, $popts ); } - $wgOut->addHTML( $this->editFormPageTop ); + if ( ( $oldContent && !$oldContent->isEmpty() ) || ( $newContent && !$newContent->isEmpty() ) ) { + $oldtitle = $this->context->msg( $oldtitlemsg )->parse(); + $newtitle = $this->context->msg( 'yourtext' )->parse(); - if ( $wgUser->getOption( 'previewontop' ) ) { - - if ( 'preview' == $this->formtype ) { - $this->showPreview( $previewOutput ); - } else { - $wgOut->addHTML( '
' ); + if ( !$oldContent ) { + $oldContent = $newContent->getContentHandler()->makeEmptyContent(); } - if ( 'diff' == $this->formtype ) { - $this->showDiff(); + if ( !$newContent ) { + $newContent = $oldContent->getContentHandler()->makeEmptyContent(); } + + $de = $oldContent->getContentHandler()->createDifferenceEngine( $this->context ); + $de->setContent( $oldContent, $newContent ); + + $difftext = $de->getDiff( $oldtitle, $newtitle ); + $de->showDiffStyle(); + } else { + $difftext = ''; + } + + $this->context->getOutput()->addHTML( '
' . $difftext . '
' ); + } + + /** + * 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( '
' ); + $out->addWikiMsg( $msg ); + $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( '
' . + $this->context->msg( 'edittools' )->inContentLanguage()->parse() . + '
' ); + } - $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
"; - $editsummary = ''; - $subjectpreview = $summarytext && $this->preview ? "
".wfMsg('subject-preview').':'.$sk->commentBlock( $this->summary, $this->mTitle )."
\n" : ''; - $summarypreview = ''; + /** + * Get the copyright warning, by default returns wikitext + * + * @param Title $title + * @param string $format Output format, valid values are any function of a Message object + * @param Language|string|null $langcode Language code or Language object. + * @return string + */ + public static function getCopyrightWarning( $title, $format = 'plain', $langcode = null ) { + global $wgRightsText; + if ( $wgRightsText ) { + $copywarnMsg = [ 'copyrightwarning', + '[[' . wfMessage( 'copyrightpage' )->inContentLanguage()->text() . ']]', + $wgRightsText ]; } else { - $commentsubject = ''; - $editsummary="\n
\n
"; - $summarypreview = $summarytext && $this->preview ? "
".wfMsg('summary-preview').':'.$sk->commentBlock( $this->summary, $this->mTitle )."
\n" : ''; - $subjectpreview = ''; + $copywarnMsg = [ 'copyrightwarning2', + '[[' . wfMessage( 'copyrightpage' )->inContentLanguage()->text() . ']]' ]; } + // Allow for site and per-namespace customization of contribution/copyright notice. + Hooks::run( 'EditPageCopyrightWarning', [ $title, &$copywarnMsg ] ); - # Set focus to the edit box on load, except on preview or diff, where it would interfere with the display - if( !$this->preview && !$this->diff ) { - $wgOut->setOnloadHandler( 'document.editform.wpTextbox1.focus()' ); + $msg = call_user_func_array( 'wfMessage', $copywarnMsg )->title( $title ); + if ( $langcode ) { + $msg->inLanguage( $langcode ); } - $templates = ($this->preview || $this->section != '') ? $this->mPreviewTemplates : $this->mArticle->getUsedTemplates(); - $formattedtemplates = $sk->formatTemplates( $templates, $this->preview, $this->section != ''); + return "
\n" . + $msg->$format() . "\n
"; + } - global $wgUseMetadataEdit ; - if ( $wgUseMetadataEdit ) { - $metadata = $this->mMetaData ; - $metadata = htmlspecialchars( $wgContLang->recodeForEdit( $metadata ) ) ; - $top = wfMsgWikiHtml( 'metadata_help' ); - $metadata = $top . "" ; + /** + * Get the Limit report for page previews + * + * @since 1.22 + * @param ParserOutput $output ParserOutput object from the parse + * @return string HTML + */ + public static function getPreviewLimitReport( $output ) { + if ( !$output || !$output->getLimitReportData() ) { + return ''; } - else $metadata = "" ; - $hidden = ''; - $recreate = ''; - if ($this->deletedSinceEdit) { - if ( 'save' != $this->formtype ) { - $wgOut->addWikiText( wfMsg('deletedwhileediting')); - } else { - // Hide the toolbar and edit area, use can click preview to get it back - // Add an confirmation checkbox and explanation. - $toolbar = ''; - $hidden = 'type="hidden" style="display:none;"'; - $recreate = $wgOut->parse( wfMsg( 'confirmrecreate', $this->lastDelete->user_name , $this->lastDelete->log_comment )); - $recreate .= - "
". - ""; + $limitReport = Html::rawElement( 'div', [ 'class' => 'mw-limitReportExplanation' ], + wfMessage( 'limitreport-title' )->parseAsBlock() + ); + + // Show/hide animation doesn't work correctly on a table, so wrap it in a div. + $limitReport .= Html::openElement( 'div', [ 'class' => 'preview-limit-report-wrapper' ] ); + + $limitReport .= Html::openElement( 'table', [ + 'class' => 'preview-limit-report wikitable' + ] ) . + Html::openElement( 'tbody' ); + + foreach ( $output->getLimitReportData() as $key => $value ) { + if ( Hooks::run( 'ParserLimitReportFormat', + [ $key, &$value, &$limitReport, true, true ] + ) ) { + $keyMsg = wfMessage( $key ); + $valueMsg = wfMessage( [ "$key-value-html", "$key-value" ] ); + if ( !$valueMsg->exists() ) { + $valueMsg = new RawMessage( '$1' ); + } + if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) { + $limitReport .= Html::openElement( 'tr' ) . + Html::rawElement( 'th', null, $keyMsg->parse() ) . + Html::rawElement( 'td', null, $valueMsg->params( $value )->parse() ) . + Html::closeElement( 'tr' ); + } } } - $tabindex = 2; + $limitReport .= Html::closeElement( 'tbody' ) . + Html::closeElement( 'table' ) . + Html::closeElement( 'div' ); - $checkboxes = self::getCheckboxes( $tabindex, $sk, - array( 'minor' => $this->minoredit, 'watch' => $this->watchthis ) ); + return $limitReport; + } + + protected function showStandardInputs( &$tabindex = 2 ) { + $out = $this->context->getOutput(); + $out->addHTML( "
\n" ); + + if ( $this->section != 'new' ) { + $this->showSummaryInput( false, $this->summary ); + $out->addHTML( $this->getSummaryPreview( false, $this->summary ) ); + } - $checkboxhtml = implode( $checkboxes, "\n" ); + $checkboxes = $this->getCheckboxesWidget( + $tabindex, + [ 'minor' => $this->minoredit, 'watch' => $this->watchthis ] + ); + $checkboxesHTML = new OOUI\HorizontalLayout( [ 'items' => $checkboxes ] ); - $buttons = $this->getEditButtons( $tabindex ); - $buttonshtml = implode( $buttons, "\n" ); + $out->addHTML( "
" . $checkboxesHTML . "
\n" ); - $safemodehtml = $this->checkUnicodeCompliantBrowser() - ? '' : Xml::hidden( 'safemode', '1' ); + // Show copyright warning. + $out->addWikiText( $this->getCopywarn() ); + $out->addHTML( $this->editFormTextAfterWarn ); - $wgOut->addHTML( << -END -); + $out->addHTML( "
\n" ); + $out->addHTML( implode( $this->getEditButtons( $tabindex ), "\n" ) . "\n" ); - if( is_callable( $formCallback ) ) { - call_user_func_array( $formCallback, array( &$wgOut ) ); + $cancel = $this->getCancelLink(); + if ( $cancel !== '' ) { + $cancel .= Html::element( 'span', + [ 'class' => 'mw-editButtons-pipe-separator' ], + $this->context->msg( 'pipe-separator' )->text() ); } - wfRunHooks( 'EditPage::showEditForm:fields', array( &$this, &$wgOut ) ); + $message = $this->context->msg( 'edithelppage' )->inContentLanguage()->text(); + $edithelpurl = Skin::makeInternalOrExternalUrl( $message ); + $edithelp = + Html::linkButton( + $this->context->msg( 'edithelp' )->text(), + [ 'target' => 'helpwindow', 'href' => $edithelpurl ], + [ 'mw-ui-quiet' ] + ) . + $this->context->msg( 'word-separator' )->escaped() . + $this->context->msg( 'newwindow' )->parse(); - // Put these up at the top to ensure they aren't lost on early form submission - $wgOut->addHTML( " -section ) . "\" name=\"wpSection\" /> -starttime}\" name=\"wpStarttime\" />\n -edittime}\" name=\"wpEdittime\" />\n -scrolltop}\" name=\"wpScrolltop\" id=\"wpScrolltop\" />\n" ); - - $wgOut->addHTML( << -END -. htmlspecialchars( $this->safeUnicodeOutput( $this->textbox1 ) ) . -" - - " ); - - $wgOut->addWikiText( $copywarn ); - $wgOut->addHTML( $this->editFormTextAfterWarn ); - $wgOut->addHTML( " -{$metadata} -{$editsummary} -{$summarypreview} -{$checkboxhtml} -{$safemodehtml} -"); - - $wgOut->addHTML( -"
-{$buttonshtml} - {$cancel} | {$edithelp} -
-
"); - - $wgOut->addHtml( '
' ); - $wgOut->addWikiText( wfMsgForContent( 'edittools' ) ); - $wgOut->addHtml( '
' ); - - $wgOut->addHTML( $this->editFormTextAfterTools ); - - $wgOut->addHTML( " -
-{$formattedtemplates} -
-" ); + $out->addHTML( " {$cancel}\n" ); + $out->addHTML( " {$edithelp}\n" ); + $out->addHTML( "
\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( '

$1

', "yourdiff" ); + + $content1 = $this->toEditContent( $this->textbox1 ); + $content2 = $this->toEditContent( $this->textbox2 ); + + $handler = ContentHandler::getForModelID( $this->contentModel ); + $de = $handler->createDifferenceEngine( $this->context ); + $de->setContent( $content2, $content1 ); + $de->showDiff( + $this->context->msg( 'yourtext' )->parse(), + $this->context->msg( 'storedversion' )->text() + ); + + $out->wrapWikiMsg( '

$1

', "yourtext" ); + $this->showTextbox2(); } + } - # For a bit more sophisticated detection of blank summaries, hash the - # automatic one and pass that in a hidden field. - $autosumm = $this->autoSumm ? $this->autoSumm : md5( $this->summary ); - $wgOut->addHtml( wfHidden( 'wpAutoSummary', $autosumm ) ); + protected function incrementConflictStats() { + $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $stats->increment( 'edit.failures.conflict' ); + // Only include 'standard' namespaces to avoid creating unknown numbers of statsd metrics + if ( + $this->mTitle->getNamespace() >= NS_MAIN && + $this->mTitle->getNamespace() <= NS_CATEGORY_TALK + ) { + $stats->increment( 'edit.failures.conflict.byNamespaceId.' . $this->mTitle->getNamespace() ); + } + } - if ( $this->isConflict ) { - $wgOut->addWikiText( '==' . wfMsg( "yourdiff" ) . '==' ); + /** + * @return string + */ + public function getCancelLink() { + $cancelParams = []; + if ( !$this->isConflict && $this->oldid > 0 ) { + $cancelParams['oldid'] = $this->oldid; + } elseif ( $this->getContextTitle()->isRedirect() ) { + $cancelParams['redirect'] = 'no'; + } - $de = new DifferenceEngine( $this->mTitle ); - $de->setText( $this->textbox2, $this->textbox1 ); - $de->showDiff( wfMsg( "yourtext" ), wfMsg( "storedversion" ) ); + return new OOUI\ButtonWidget( [ + 'id' => 'mw-editform-cancel', + 'href' => $this->getContextTitle()->getLinkUrl( $cancelParams ), + 'label' => new OOUI\HtmlSnippet( $this->context->msg( 'cancel' )->parse() ), + 'framed' => false, + 'infusable' => true, + 'flags' => 'destructive', + ] ); + } - $wgOut->addWikiText( '==' . wfMsg( "yourtext" ) . '==' ); - $wgOut->addHTML( "" ); - } - $wgOut->addHTML( $this->editFormTextBottom ); - $wgOut->addHTML( "\n" ); - if ( !$wgUser->getOption( 'previewontop' ) ) { + /** + * Returns the URL to use in the form's action attribute. + * This is used by EditPage subclasses when simply customizing the action + * variable in the constructor is not enough. This can be used when the + * EditPage lives inside of a Special page rather than a custom page action. + * + * @param Title $title Title object for which is being edited (where we go to for &action= links) + * @return string + */ + protected function getActionURL( Title $title ) { + return $title->getLocalURL( [ 'action' => $this->action ] ); + } - if ( $this->formtype == 'preview') { - $this->showPreview( $previewOutput ); - } else { - $wgOut->addHTML( '
' ); - } + /** + * Check if a page was deleted while the user was editing it, before submit. + * Note that we rely on the logging table, which hasn't been always there, + * but that doesn't matter, because this only applies to brand new + * deletes. + * @return bool + */ + protected function wasDeletedSinceLastEdit() { + if ( $this->deletedSinceEdit !== null ) { + return $this->deletedSinceEdit; + } - if ( $this->formtype == 'diff') { - $this->showDiff(); - } + $this->deletedSinceEdit = false; + if ( !$this->mTitle->exists() && $this->mTitle->isDeletedQuick() ) { + $this->lastDelete = $this->getLastDelete(); + if ( $this->lastDelete ) { + $deleteTime = wfTimestamp( TS_MW, $this->lastDelete->log_timestamp ); + if ( $deleteTime > $this->starttime ) { + $this->deletedSinceEdit = true; + } + } } - wfProfileOut( $fname ); + return $this->deletedSinceEdit; } /** - * Append preview output to $wgOut. - * Includes category rendering if this is a category page. - * - * @param string $text The HTML to be output for the preview. + * @return bool|stdClass */ - private function showPreview( $text ) { - global $wgOut; + protected function getLastDelete() { + $dbr = wfGetDB( DB_REPLICA ); + $commentQuery = CommentStore::newKey( 'log_comment' )->getJoin(); + $data = $dbr->selectRow( + [ 'logging', 'user' ] + $commentQuery['tables'], + [ + 'log_type', + 'log_action', + 'log_timestamp', + 'log_user', + 'log_namespace', + 'log_title', + 'log_params', + 'log_deleted', + 'user_name' + ] + $commentQuery['fields'], [ + 'log_namespace' => $this->mTitle->getNamespace(), + 'log_title' => $this->mTitle->getDBkey(), + 'log_type' => 'delete', + 'log_action' => 'delete', + 'user_id=log_user' + ], + __METHOD__, + [ 'LIMIT' => 1, 'ORDER BY' => 'log_timestamp DESC' ], + [ + 'user' => [ 'JOIN', 'user_id=log_user' ], + ] + $commentQuery['joins'] + ); + // Quick paranoid permission checks... + if ( is_object( $data ) ) { + if ( $data->log_deleted & LogPage::DELETED_USER ) { + $data->user_name = $this->context->msg( 'rev-deleted-user' )->escaped(); + } - $wgOut->addHTML( '
' ); - if($this->mTitle->getNamespace() == NS_CATEGORY) { - $this->mArticle->openShowCategory(); - } - wfRunHooks( 'OutputPageBeforeHTML',array( &$wgOut, &$text ) ); - $wgOut->addHTML( $text ); - if($this->mTitle->getNamespace() == NS_CATEGORY) { - $this->mArticle->closeShowCategory(); - } - $wgOut->addHTML( '
' ); - } - - /** - * Live Preview lets us fetch rendered preview page content and - * add it to the page without refreshing the whole page. - * If not supported by the browser it will fall through to the normal form - * submission method. - * - * This function outputs a script tag to support live preview, and - * returns an onclick handler which should be added to the attributes - * of the preview button - */ - function doLivePreviewScript() { - global $wgStylePath, $wgJsMimeType, $wgStyleVersion, $wgOut, $wgTitle; - $wgOut->addHTML( '' . "\n" ); - $liveAction = $wgTitle->getLocalUrl( 'action=submit&wpPreview=true&live=true' ); - return "return !livePreview(" . - "getElementById('wikiPreview')," . - "editform.wpTextbox1.value," . - '"' . $liveAction . '"' . ")"; - } - - function getLastDelete() { - $dbr = wfGetDB( DB_SLAVE ); - $fname = 'EditPage::getLastDelete'; - $res = $dbr->select( - array( 'logging', 'user' ), - array( 'log_type', - 'log_action', - 'log_timestamp', - 'log_user', - 'log_namespace', - 'log_title', - 'log_comment', - 'log_params', - 'user_name', ), - array( 'log_namespace' => $this->mTitle->getNamespace(), - 'log_title' => $this->mTitle->getDBkey(), - 'log_type' => 'delete', - 'log_action' => 'delete', - 'user_id=log_user' ), - $fname, - array( 'LIMIT' => 1, 'ORDER BY' => 'log_timestamp DESC' ) ); - - if($dbr->numRows($res) == 1) { - while ( $x = $dbr->fetchObject ( $res ) ) - $data = $x; - $dbr->freeResult ( $res ) ; - } else { - $data = null; + if ( $data->log_deleted & LogPage::DELETED_COMMENT ) { + $data->log_comment_text = $this->context->msg( 'rev-deleted-comment' )->escaped(); + $data->log_comment_data = null; + } } + return $data; } /** - * @todo document + * Get the rendered text for previewing. + * @throws MWException + * @return string */ - function getPreviewText() { - global $wgOut, $wgUser, $wgTitle, $wgParser; - - $fname = 'EditPage::getPreviewText'; - wfProfileIn( $fname ); + public function getPreviewText() { + $out = $this->context->getOutput(); + $config = $this->context->getConfig(); - if ( $this->mTriedSave && !$this->mTokenOk ) { - if ( $this->mTokenOkExceptSuffix ) { - $msg = 'token_suffix_mismatch'; - } else { - $msg = 'session_fail_preview'; + if ( $config->get( 'RawHtml' ) && !$this->mTokenOk ) { + // Could be an offsite preview attempt. This is very unsafe if + // HTML is enabled, as it could be an attack. + $parsedNote = ''; + if ( $this->textbox1 !== '' ) { + // Do not put big scary notice, if previewing the empty + // string, which happens when you initially edit + // a category page, due to automatic preview-on-open. + $parsedNote = $out->parse( "
" . + $this->context->msg( 'session_fail_preview_html' )->text() . "
", + true, /* interface */true ); } - } else { - $msg = 'previewnote'; - } - $previewhead = '

' . htmlspecialchars( wfMsg( 'preview' ) ) . "

\n" . - "
" . $wgOut->parse( wfMsg( $msg ) ) . "
\n"; - if ( $this->isConflict ) { - $previewhead.='

' . htmlspecialchars( wfMsg( 'previewconflict' ) ) . "

\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( "
" . - wfMsg( 'session_fail_preview_html' ) . "
" ); - } + try { + $content = $this->toEditContent( $this->textbox1 ); - # don't parse user css/js, show message about preview - # XXX: stupid php bug won't let us use $wgTitle->isCssJsSubpage() here + $previewHTML = ''; + if ( !Hooks::run( + 'AlternateEditPreview', + [ $this, &$content, &$previewHTML, &$this->mParserOutput ] ) + ) { + return $previewHTML; + } - if ( $this->isCssJsSubpage ) { - if(preg_match("/\\.css$/", $wgTitle->getText() ) ) { - $previewtext = wfMsg('usercsspreview'); - } else if(preg_match("/\\.js$/", $wgTitle->getText() ) ) { - $previewtext = wfMsg('userjspreview'); + # provide a anchor link to the editform + $continueEditing = '' . + '[[#' . self::EDITFORM_ID . '|' . + $this->context->getLanguage()->getArrow() . ' ' . + $this->context->msg( 'continue-editing' )->text() . ']]'; + if ( $this->mTriedSave && !$this->mTokenOk ) { + if ( $this->mTokenOkExceptSuffix ) { + $note = $this->context->msg( 'token_suffix_mismatch' )->plain(); + $this->incrementEditFailureStats( 'bad_token' ); + } else { + $note = $this->context->msg( 'session_fail_preview' )->plain(); + $this->incrementEditFailureStats( 'session_loss' ); + } + } elseif ( $this->incompleteForm ) { + $note = $this->context->msg( 'edit_form_incomplete' )->plain(); + if ( $this->mTriedSave ) { + $this->incrementEditFailureStats( 'incomplete_form' ); + } + } else { + $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing; + } + + # don't parse non-wikitext pages, show message about preview + if ( $this->mTitle->isCssJsSubpage() || $this->mTitle->isCssOrJsPage() ) { + if ( $this->mTitle->isCssJsSubpage() ) { + $level = 'user'; + } elseif ( $this->mTitle->isCssOrJsPage() ) { + $level = 'site'; + } else { + $level = false; + } + + if ( $content->getModel() == CONTENT_MODEL_CSS ) { + $format = 'css'; + if ( $level === 'user' && !$config->get( 'AllowUserCss' ) ) { + $format = false; + } + } elseif ( $content->getModel() == CONTENT_MODEL_JAVASCRIPT ) { + $format = 'js'; + if ( $level === 'user' && !$config->get( 'AllowUserJs' ) ) { + $format = false; + } + } else { + $format = false; + } + + # Used messages to make sure grep find them: + # Messages: usercsspreview, userjspreview, sitecsspreview, sitejspreview + if ( $level && $format ) { + $note = "
" . + $this->context->msg( "{$level}{$format}preview" )->text() . + ' ' . $continueEditing . "
"; + } } - $parserOptions->setTidy(true); - $parserOutput = $wgParser->parse( $previewtext , $wgTitle, $parserOptions ); - $wgOut->addHTML( $parserOutput->mText ); - wfProfileOut( $fname ); - return $previewhead; - } else { - $toparse = $this->textbox1; # If we're adding a comment, we need to show the # summary as the headline - if($this->section=="new" && $this->summary!="") { - $toparse="== {$this->summary} ==\n\n".$toparse; + if ( $this->section === "new" && $this->summary !== "" ) { + $content = $content->addSectionHeader( $this->summary ); } - if ( $this->mMetaData != "" ) $toparse .= "\n" . $this->mMetaData ; - $parserOptions->setTidy(true); - $parserOutput = $wgParser->parse( $this->mArticle->preSaveTransform( $toparse ) ."\n\n", - $wgTitle, $parserOptions ); + $hook_args = [ $this, &$content ]; + Hooks::run( 'EditPageGetPreviewContent', $hook_args ); - $previewHTML = $parserOutput->getText(); - $wgOut->addParserOutputNoText( $parserOutput ); - - # ParserOutput might have altered the page title, so reset it - $wgOut->setPageTitle( wfMsg( 'editing', $this->mTitle->getPrefixedText() ) ); + $parserResult = $this->doPreviewParse( $content ); + $parserOutput = $parserResult['parserOutput']; + $previewHTML = $parserResult['html']; + $this->mParserOutput = $parserOutput; + $out->addParserOutputMetadata( $parserOutput ); - foreach ( $parserOutput->getTemplates() as $ns => $template) - foreach ( array_keys( $template ) as $dbk) - $this->mPreviewTemplates[] = Title::makeTitle($ns, $dbk); + if ( count( $parserOutput->getWarnings() ) ) { + $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() ); + } - wfProfileOut( $fname ); - return $previewhead . $previewHTML; + } catch ( MWContentSerializationException $ex ) { + $m = $this->context->msg( + 'content-failed-to-parse', + $this->contentModel, + $this->contentFormat, + $ex->getMessage() + ); + $note .= "\n\n" . $m->parse(); + $previewHTML = ''; } - } - - /** - * Call the stock "user is blocked" page - */ - function blockedPage() { - global $wgOut, $wgUser; - $wgOut->blockedPage( false ); # Standard block notice on the top, don't 'return' - # If the user made changes, preserve them when showing the markup - # (This happens when a user is blocked during edit, for instance) - $first = $this->firsttime || ( !$this->save && $this->textbox1 == '' ); - if( $first ) { - $source = $this->mTitle->exists() ? $this->getContent() : false; + if ( $this->isConflict ) { + $conflict = '

' + . $this->context->msg( 'previewconflict' )->escaped() . "

\n"; } else { - $source = $this->textbox1; - } - - # Spit out the source or the user's modified version - if( $source !== false ) { - $rows = $wgUser->getOption( 'rows' ); - $cols = $wgUser->getOption( 'cols' ); - $attribs = array( 'id' => 'wpTextbox1', 'name' => 'wpTextbox1', 'cols' => $cols, 'rows' => $rows, 'readonly' => 'readonly' ); - $wgOut->addHtml( '
' ); - $wgOut->addWikiText( wfMsg( $first ? 'blockedoriginalsource' : 'blockededitsource', $this->mTitle->getPrefixedText() ) ); - $wgOut->addHtml( wfOpenElement( 'textarea', $attribs ) . htmlspecialchars( $source ) . wfCloseElement( 'textarea' ) ); + $conflict = '
'; } - } - /** - * Produce the stock "please login to edit pages" page - */ - function userNotLoggedInPage() { - global $wgUser, $wgOut; - $skin = $wgUser->getSkin(); + $previewhead = "
\n" . + '

' . $this->context->msg( 'preview' )->escaped() . "

" . + $out->parse( $note, true, /* interface */true ) . $conflict . "
\n"; - $loginTitle = SpecialPage::getTitleFor( 'Userlogin' ); - $loginLink = $skin->makeKnownLinkObj( $loginTitle, wfMsgHtml( 'loginreqlink' ), 'returnto=' . $this->mTitle->getPrefixedUrl() ); + $pageViewLang = $this->mTitle->getPageViewLanguage(); + $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(), + 'class' => 'mw-content-' . $pageViewLang->getDir() ]; + $previewHTML = Html::rawElement( 'div', $attribs, $previewHTML ); - $wgOut->setPageTitle( wfMsg( 'whitelistedittitle' ) ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - $wgOut->setArticleRelated( false ); + return $previewhead . $previewHTML . $this->previewTextAfterContent; + } - $wgOut->addHtml( wfMsgWikiHtml( 'whitelistedittext', $loginLink ) ); - $wgOut->returnToMain( false, $this->mTitle->getPrefixedUrl() ); + private function incrementEditFailureStats( $failureType ) { + $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $stats->increment( 'edit.failures.' . $failureType ); } /** - * Creates a basic error page which informs the user that - * they have to validate their email address before being - * allowed to edit. + * Get parser options for a preview + * @return ParserOptions */ - function userNotConfirmedPage() { - global $wgOut; - - $wgOut->setPageTitle( wfMsg( 'confirmedittitle' ) ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - $wgOut->setArticleRelated( false ); - - $wgOut->addWikiText( wfMsg( 'confirmedittext' ) ); - $wgOut->returnToMain( false ); + protected function getPreviewParserOptions() { + $parserOptions = $this->page->makeParserOptions( $this->context ); + $parserOptions->setIsPreview( true ); + $parserOptions->setIsSectionPreview( !is_null( $this->section ) && $this->section !== '' ); + $parserOptions->enableLimitReport(); + return $parserOptions; } /** - * Creates a basic error page which informs the user that - * they have attempted to edit a nonexistant section. + * Parse the page for a preview. Subclasses may override this class, in order + * to parse with different options, or to otherwise modify the preview HTML. + * + * @param Content $content The page content + * @return array with keys: + * - parserOutput: The ParserOutput object + * - html: The HTML to be displayed */ - function noSuchSectionPage() { - global $wgOut; - - $wgOut->setPageTitle( wfMsg( 'nosuchsectiontitle' ) ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - $wgOut->setArticleRelated( false ); - - $wgOut->addWikiText( wfMsg( 'nosuchsectiontext', $this->section ) ); - $wgOut->returnToMain( false, $this->mTitle->getPrefixedUrl() ); + protected function doPreviewParse( Content $content ) { + $user = $this->context->getUser(); + $parserOptions = $this->getPreviewParserOptions(); + $pstContent = $content->preSaveTransform( $this->mTitle, $user, $parserOptions ); + $scopedCallback = $parserOptions->setupFakeRevision( + $this->mTitle, $pstContent, $user ); + $parserOutput = $pstContent->getParserOutput( $this->mTitle, null, $parserOptions ); + ScopedCallback::consume( $scopedCallback ); + $parserOutput->setEditSectionTokens( false ); // no section edit links + return [ + 'parserOutput' => $parserOutput, + 'html' => $parserOutput->getText() ]; } /** - * Produce the stock "your edit contains spam" page - * - * @param $match Text which triggered one or more filters + * @return array */ - function spamPage( $match = false ) { - global $wgOut; - - $wgOut->setPageTitle( wfMsg( 'spamprotectiontitle' ) ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - $wgOut->setArticleRelated( false ); - - $wgOut->addWikiText( wfMsg( 'spamprotectiontext' ) ); - if ( $match ) - $wgOut->addWikiText( wfMsg( 'spamprotectionmatch', "{$match}" ) ); - - $wgOut->returnToMain( false ); + public function getTemplates() { + if ( $this->preview || $this->section != '' ) { + $templates = []; + if ( !isset( $this->mParserOutput ) ) { + return $templates; + } + foreach ( $this->mParserOutput->getTemplates() as $ns => $template ) { + foreach ( array_keys( $template ) as $dbk ) { + $templates[] = Title::makeTitle( $ns, $dbk ); + } + } + return $templates; + } else { + return $this->mTitle->getTemplateLinksFrom(); + } } /** - * @private - * @todo document + * Shows a bulletin board style toolbar for common editing functions. + * It can be disabled in the user preferences. + * + * @param Title $title Title object for the page being edited (optional) + * @return string */ - function mergeChangesInto( &$editText ){ - $fname = 'EditPage::mergeChangesInto'; - wfProfileIn( $fname ); + public static function getEditToolbar( $title = null ) { + global $wgContLang, $wgOut; + global $wgEnableUploads, $wgForeignFileRepos; + + $imagesAvailable = $wgEnableUploads || count( $wgForeignFileRepos ); + $showSignature = true; + if ( $title ) { + $showSignature = MWNamespace::wantSignatures( $title->getNamespace() ); + } - $db = wfGetDB( DB_MASTER ); + /** + * $toolarray is an array of arrays each of which includes the + * opening tag, the closing tag, optionally a sample text that is + * inserted between the two when no selection is highlighted + * and. The tip text is shown when the user moves the mouse + * over the button. + * + * Images are defined in ResourceLoaderEditToolbarModule. + */ + $toolarray = [ + [ + 'id' => 'mw-editbutton-bold', + 'open' => '\'\'\'', + 'close' => '\'\'\'', + 'sample' => wfMessage( 'bold_sample' )->text(), + 'tip' => wfMessage( 'bold_tip' )->text(), + ], + [ + 'id' => 'mw-editbutton-italic', + 'open' => '\'\'', + 'close' => '\'\'', + 'sample' => wfMessage( 'italic_sample' )->text(), + 'tip' => wfMessage( 'italic_tip' )->text(), + ], + [ + 'id' => 'mw-editbutton-link', + 'open' => '[[', + 'close' => ']]', + 'sample' => wfMessage( 'link_sample' )->text(), + 'tip' => wfMessage( 'link_tip' )->text(), + ], + [ + 'id' => 'mw-editbutton-extlink', + 'open' => '[', + 'close' => ']', + 'sample' => wfMessage( 'extlink_sample' )->text(), + 'tip' => wfMessage( 'extlink_tip' )->text(), + ], + [ + 'id' => 'mw-editbutton-headline', + 'open' => "\n== ", + 'close' => " ==\n", + 'sample' => wfMessage( 'headline_sample' )->text(), + 'tip' => wfMessage( 'headline_tip' )->text(), + ], + $imagesAvailable ? [ + 'id' => 'mw-editbutton-image', + 'open' => '[[' . $wgContLang->getNsText( NS_FILE ) . ':', + 'close' => ']]', + 'sample' => wfMessage( 'image_sample' )->text(), + 'tip' => wfMessage( 'image_tip' )->text(), + ] : false, + $imagesAvailable ? [ + 'id' => 'mw-editbutton-media', + 'open' => '[[' . $wgContLang->getNsText( NS_MEDIA ) . ':', + 'close' => ']]', + 'sample' => wfMessage( 'media_sample' )->text(), + 'tip' => wfMessage( 'media_tip' )->text(), + ] : false, + [ + 'id' => 'mw-editbutton-nowiki', + 'open' => "", + 'close' => "", + 'sample' => wfMessage( 'nowiki_sample' )->text(), + 'tip' => wfMessage( 'nowiki_tip' )->text(), + ], + $showSignature ? [ + 'id' => 'mw-editbutton-signature', + 'open' => wfMessage( 'sig-text', '~~~~' )->inContentLanguage()->text(), + 'close' => '', + 'sample' => '', + 'tip' => wfMessage( 'sig_tip' )->text(), + ] : false, + [ + 'id' => 'mw-editbutton-hr', + 'open' => "\n----\n", + 'close' => '', + 'sample' => '', + 'tip' => wfMessage( 'hr_tip' )->text(), + ] + ]; + + $script = 'mw.loader.using("mediawiki.toolbar", function () {'; + foreach ( $toolarray as $tool ) { + if ( !$tool ) { + continue; + } - // This is the revision the editor started from - $baseRevision = Revision::loadFromTimestamp( - $db, $this->mArticle->mTitle, $this->edittime ); - if( is_null( $baseRevision ) ) { - wfProfileOut( $fname ); - return false; + $params = [ + // Images are defined in ResourceLoaderEditToolbarModule + false, + // Note that we use the tip both for the ALT tag and the TITLE tag of the image. + // Older browsers show a "speedtip" type message only for ALT. + // Ideally these should be different, realistically they + // probably don't need to be. + $tool['tip'], + $tool['open'], + $tool['close'], + $tool['sample'], + $tool['id'], + ]; + + $script .= Xml::encodeJsCall( + 'mw.toolbar.addButton', + $params, + ResourceLoader::inDebugMode() + ); } - $baseText = $baseRevision->getText(); - // The current state, we want to merge updates into it - $currentRevision = Revision::loadFromTitle( - $db, $this->mArticle->mTitle ); - if( is_null( $currentRevision ) ) { - wfProfileOut( $fname ); - return false; - } - $currentText = $currentRevision->getText(); + $script .= '});'; - $result = ''; - if( wfMerge( $baseText, $editText, $currentText, $result ) ){ - $editText = $result; - wfProfileOut( $fname ); - return true; - } else { - wfProfileOut( $fname ); - return false; - } + $toolbar = '
'; + + if ( Hooks::run( 'EditPageBeforeEditToolbar', [ &$toolbar ] ) ) { + // Only add the old toolbar cruft to the page payload if the toolbar has not + // been over-written by a hook caller + $wgOut->addScript( ResourceLoader::makeInlineScript( $script ) ); + }; + + return $toolbar; } /** - * Check if the browser is on a blacklist of user-agents known to - * mangle UTF-8 data on form submission. Returns true if Unicode - * should make it through, false if it's known to be a problem. - * @return bool - * @private + * Return an array of checkbox definitions. + * + * Array keys correspond to the `` 'name' attribute to use for each checkbox. + * + * Array values are associative arrays with the following keys: + * - 'label-message' (required): message for label text + * - 'id' (required): 'id' attribute for the `` + * - 'default' (required): default checkedness (true or false) + * - 'title-message' (optional): used to generate 'title' attribute for the `