diff options
Diffstat (limited to 'SemanticForms/includes/SF_AutoeditAPI.php')
-rw-r--r-- | SemanticForms/includes/SF_AutoeditAPI.php | 1182 |
1 files changed, 1182 insertions, 0 deletions
diff --git a/SemanticForms/includes/SF_AutoeditAPI.php b/SemanticForms/includes/SF_AutoeditAPI.php new file mode 100644 index 00000000..1e57ff18 --- /dev/null +++ b/SemanticForms/includes/SF_AutoeditAPI.php @@ -0,0 +1,1182 @@ +<?php +/** + * File holding the SFAutoEditAPI class + * + * @author Stephan Gambke + * @file + * @ingroup SemanticForms + */ + +/** + * The SF_AutoEditAPI class. + * + * @ingroup SemanticForms + */ +class SFAutoeditAPI extends ApiBase { + + const ACTION_FORMEDIT = 0; + const ACTION_SAVE = 1; + const ACTION_PREVIEW = 2; + const ACTION_DIFF = 3; + + /** + * Error level used when a non-recoverable error occurred. + */ + const ERROR = 0; + + /** + * Error level used when a recoverable error occurred. + */ + const WARNING = 1; + + /** + * Error level used to give information that might be of interest to the user. + */ + const NOTICE = 2; + + /** + * Error level used for debug messages. + */ + const DEBUG = 3; + + private $mOptions = array( ); + private $mAction; + private $mStatus; + + /** + * Converts an options string into an options array and stores it + * + * @param string $options + * @return the options array + */ + function addOptionsFromString( $options ) { + return $this->parseDataFromQueryString( $this->mOptions, $options ); + } + + /** + * Returns the options array + * @return array + */ + function getOptions() { + return $this->mOptions; + } + + /** + * Returns the action performed by the module. + * + * Return value is either null or one of ACTION_SAVE, ACTION_PREVIEW, + * ACTION_FORMEDIT + * + * @return null|number + */ + function getAction() { + return $this->mAction; + } + + /** + * Sets the options array + */ + function setOptions( $options ) { + $this->mOptions = $options; + } + + /** + * Sets an option in the options array + */ + function setOption( $option, $value ) { + $this->mOptions[$option] = $value; + } + + /** + * Returns the HTTP status + * + * 200 - ok + * 400 - error + * + * @return number + */ + function getStatus() { + return $this->mStatus; + } + + /** + * Evaluates the parameters, performs the requested API query, and sets up + * the result. + * + * The execute() method will be invoked when an API call is processed. + * + * The result data is stored in the ApiResult object available through + * getResult(). + */ + function execute() { + + $this->prepareAction(); + + try { + $this->doAction(); + } catch ( MWException $e ) { + $this->logMessage( $e->getMessage(), $e->getCode() ); + } + + $this->finalizeResults(); + $this->setHeaders(); + } + + /** + * + */ + function prepareAction() { + + // get options from the request, but keep the explicitly set options + global $wgVersion; + if ( version_compare( $wgVersion, '1.20', '>=' ) ) { + $data = $this->getRequest()->getValues(); + } else { // TODO: remove else branch when raising supported version to MW 1.20, getValues() was buggy before + $data = $_POST + $_GET; + } + $this->mOptions = SFUtils::array_merge_recursive_distinct( $data, $this->mOptions ); + + global $wgParser; + if ( $wgParser === null ) { + $wgParser = new Parser(); + } + + $wgParser->startExternalParse( + null, + ParserOptions::newFromUser( $this->getUser() ), + Parser::OT_WIKI + ); + + // MW uses the parameter 'title' instead of 'target' when submitting + // data for formedit action => use that + if ( !array_key_exists( 'target', $this->mOptions ) && array_key_exists( 'title', $this->mOptions ) ) { + + $this->mOptions['target'] = $this->mOptions['title']; + unset( $this->mOptions['title'] ); + } + + // if the 'query' parameter was used, unpack the param string + if ( array_key_exists( 'query', $this->mOptions ) ) { + + $this->addOptionsFromString( $this->mOptions['query'] ); + unset( $this->mOptions['query'] ); + } + + // if an action is explicitly set in the form data, use that + if ( array_key_exists( 'wpSave', $this->mOptions ) ) { + + // set action to 'save' if requested + $this->mAction = self::ACTION_SAVE; + unset( $this->mOptions['wpSave'] ); + } else if ( array_key_exists( 'wpPreview', $this->mOptions ) ) { + + // set action to 'preview' if requested + $this->mAction = self::ACTION_PREVIEW; + unset( $this->mOptions['wpPreview'] ); + } else if ( array_key_exists( 'wpDiff', $this->mOptions ) ) { + + // set action to 'preview' if requested + $this->mAction = self::ACTION_DIFF; + unset( $this->mOptions['wpDiff'] ); + } else if ( array_key_exists( 'action', $this->mOptions ) ) { + + switch ( $this->mOptions['action'] ) { + + case 'sfautoedit' : + $this->mAction = self::ACTION_SAVE; + break; + case 'preview' : + $this->mAction = self::ACTION_PREVIEW; + break; + default : + $this->mAction = self::ACTION_FORMEDIT; + } + } else { + // set default action + $this->mAction = self::ACTION_FORMEDIT; + } + + $hookQuery = null; + + // ensure 'form' key exists + if ( array_key_exists( 'form', $this->mOptions ) ) { + $hookQuery = $this->mOptions['form']; + } else { + $this->mOptions['form'] = ''; + } + + // ensure 'target' key exists + if ( array_key_exists( 'target', $this->mOptions ) ) { + if ( $hookQuery !== null ) { + $hookQuery .= '/' . $this->mOptions['target']; + } + } else { + $this->mOptions['target'] = ''; + } + + // Normalize form and target names + + $form = Title::newFromText( $this->mOptions['form'] ); + if ( $form !== null ) { + $this->mOptions['form'] = $form->getPrefixedText(); + } + + $target = Title::newFromText( $this->mOptions['target'] ); + if ( $target !== null ) { + $this->mOptions['target'] = $target->getPrefixedText(); + } + + wfRunHooks( 'sfSetTargetName', array( &$this->mOptions['target'], $hookQuery ) ); + + // set html return status. If all goes well, this will not be changed + $this->mStatus = 200; + } + + /** + * Get the Title object of a form suitable for editing the target page. + * + * @return Title + * @throws MWException + */ + protected function getFormTitle() { + + // if no form was explicitly specified, try for explicitly set alternate forms + if ( $this->mOptions['form'] === '' ) { + + $this->logMessage( 'No form specified. Will try to find the default form for the target page.', self::DEBUG ); + + $formNames = array(); + + // try explicitly set alternative forms + if ( array_key_exists( 'alt_form', $this->mOptions ) ) { + + $formNames = (array)$this->mOptions['alt_form']; // cast to array to make sure we get an array, even if only a string was sent + + } + + // if no alternate forms were explicitly set, try finding a default form for the target page + if ( count( $formNames ) === 0 ) { + + // if no form and and no alt forms and no target page was specified, give up + if ( $this->mOptions['target'] === '' ) { + throw new MWException( wfMessage( 'sf_autoedit_notargetspecified' )->parse() ); + } + + $targetTitle = Title::newFromText( $this->mOptions['target'] ); + + // if the specified target title is invalid, give up + if ( !$targetTitle instanceof Title ) { + throw new MWException( wfMessage( 'sf_autoedit_invalidtargetspecified', $this->mOptions['target'] )->parse() ); + } + + $formNames = SFFormLinker::getDefaultFormsForPage( $targetTitle ); + + // if no default form can be found, try alternate forms + if ( count( $formNames ) === 0 ) { + + $formNames = SFFormLinker::getFormsThatPagePointsTo( $targetTitle->getText(), $targetTitle->getNamespace(), SFFormLinker::ALTERNATE_FORM ); + + // if still no form can be found, give up + if ( count( $formNames ) === 0 ) { + throw new MWException( wfMessage( 'sf_autoedit_noformfound' )->parse() ); + } + + } + + } + + // if more than one form was found, issue a notice and give up + // this happens if no default form but several alternate forms are defined + if ( count( $formNames ) > 1 ) { + throw new MWException( wfMessage( 'sf_autoedit_toomanyformsfound' )->parse(), self::DEBUG ); + } + + $this->mOptions['form'] = $formNames[0]; + + $this->logMessage( 'Using ' . $this->mOptions['form'] . ' as default form.', self::DEBUG ); + } + + $formTitle = Title::makeTitleSafe( SF_NS_FORM, $this->mOptions['form'] ); + + // if the given form is not a valid title, give up + if ( !($formTitle instanceOf Title) ) { + throw new MWException( wfMessage( 'sf_autoedit_invalidform', $this->mOptions['form'] )->parse() ); + } + + // if the form page is a redirect, follow the redirect + if ( $formTitle->isRedirect() ) { + + $this->logMessage( 'Form ' . $this->mOptions['form'] . ' is a redirect. Finding target.', self::DEBUG ); + + // FIXME: Title::newFromRedirectRecurse is deprecated as of MW 1.21 + $formTitle = Title::newFromRedirectRecurse( WikiPage::factory( $formTitle )->getRawText() ); + + // if we exeeded $wgMaxRedirects or encountered an invalid redirect target, give up + if ( $formTitle->isRedirect() ) { + + $newTitle = WikiPage::factory( $formTitle )->getRedirectTarget(); + + if ( $newTitle instanceOf Title && $newTitle->isValidRedirectTarget() ) { + throw new MWException( wfMessage( 'sf_autoedit_redirectlimitexeeded', $this->mOptions['form'] )->parse() ); + } else { + throw new MWException( wfMessage( 'sf_autoedit_invalidredirecttarget', $newTitle->getFullText(), $this->mOptions['form'] )->parse() ); + } + } + } + + // if specified or found form does not exist (e.g. is a red link), give up + // FIXME: Throw specialized error message, so a list of alternative forms can be shown + if ( !$formTitle->exists() ) { + throw new MWException( wfMessage( 'sf_autoedit_invalidform', $this->mOptions['form'] )->parse() ); + } + + return $formTitle; + } + + protected function setupEditPage( $targetContent ) { + + // Find existing target article if it exists, or create a new one. + $targetTitle = Title::newFromText( $this->mOptions['target'] ); + + // if the specified target title is invalid, give up + if ( !$targetTitle instanceof Title ) { + throw new MWException( wfMessage( 'sf_autoedit_invalidtargetspecified', $this->mOptions['target'] )->parse() ); + } + + $article = new Article( $targetTitle ); + + // set up a normal edit page + // we'll feed it our data to simulate a normal edit + $editor = new EditPage( $article ); + + // set up form data: + // merge data coming from the web request on top of some defaults + $data = array_merge( + array( + 'wpTextbox1' => $targetContent, + 'wpSummary' => '', + 'wpStarttime' => wfTimestampNow(), + 'wpEdittime' => '', + 'wpEditToken' => isset( $this->mOptions[ 'token' ] ) ? $this->mOptions[ 'token' ] : '', + 'action' => 'submit', + ), + $this->mOptions + ); + + if ( array_key_exists( 'format', $data ) ) { + unset( $data['format'] ); + } + + // set up a faux request with the simulated data + $request = new FauxRequest( $data, true ); + + // and import it into the edit page + $editor->importFormData( $request ); + + return $editor; + } + + /** + * Sets the output HTML of wgOut as the module's result + */ + protected function setResultFromOutput() { + + // turn on output buffering + ob_start(); + + // generate preview document and write it to output buffer + $this->getOutput()->output(); + + // retrieve the preview document from output buffer + $targetHtml = ob_get_contents(); + + // clean output buffer, so MW can use it again + ob_clean(); + + // store the document as result + $this->getResult()->addValue( null, 'result', $targetHtml ); + + } + + protected function doPreview( $editor ) { + + global $wgOut; + + $previewOutput = $editor->getPreviewText(); + + wfRunHooks( 'EditPage::showEditForm:initial', array( &$editor, &$wgOut ) ); + + $this->getOutput()->addStyle( 'common/IE80Fixes.css', 'screen', 'IE 8' ); + $this->getOutput()->setRobotPolicy( 'noindex,nofollow' ); + + // This hook seems slightly odd here, but makes things more + // consistent for extensions. + wfRunHooks( 'OutputPageBeforeHTML', array( &$wgOut, &$previewOutput ) ); + + $this->getOutput()->addHTML( Html::rawElement( 'div', array( 'id' => 'wikiPreview' ), $previewOutput ) ); + + $this->setResultFromOutput(); + + } + + protected function doDiff( $editor ) { + $editor->showDiff(); + $this->setResultFromOutput(); + } + + protected function doStore( EditPage $editor ) { + + $title = $editor->getTitle(); + + // If they used redlink=1 and the page exists, redirect to the main article and send notice + if ( $this->getRequest()->getBool( 'redlink' ) && $title->exists() ) { + $this->logMessage( wfMessage( 'sf_autoedit_redlinkexists' )->parse(), self::WARNING ); + } + + $permErrors = $title->getUserPermissionsErrors( 'edit', $this->getUser() ); + + // if this title needs to be created, user needs create rights + if ( !$title->exists() ) { + $permErrors = array_merge( $permErrors, wfArrayDiff2( $title->getUserPermissionsErrors( 'create', $this->getUser() ), $permErrors ) ); + } + + if ( $permErrors ) { + + // Auto-block user's IP if the account was "hard" blocked + $this->getUser()->spreadAnyEditBlock(); + + foreach ( $permErrors as $error ) { + $this->logMessage( wfMessage( $error )->parse() ); + } + + return; + } + + $resultDetails = false; + # Allow bots to exempt some edits from bot flagging + $bot = $this->getUser()->isAllowed( 'bot' ) && $editor->bot; + + if ( $editor->mTokenOk ) { + $status = $editor->internalAttemptSave( $resultDetails, $bot ); + } + else { + throw new MWException( wfMessage( 'session_fail_preview' )->parse() ); + } + + switch ( $status->value ) { + case EditPage::AS_HOOK_ERROR_EXPECTED: // A hook function returned an error + case EditPage::AS_CONTENT_TOO_BIG: // Content too big (> $wgMaxArticleSize) + case EditPage::AS_ARTICLE_WAS_DELETED: // article was deleted while editting and param wpRecreate == false or form was not posted + case EditPage::AS_CONFLICT_DETECTED: // (non-resolvable) edit conflict + case EditPage::AS_SUMMARY_NEEDED: // no edit summary given and the user has forceeditsummary set and the user is not editting in his own userspace or talkspace and wpIgnoreBlankSummary == false + case EditPage::AS_TEXTBOX_EMPTY: // user tried to create a new section without content + case EditPage::AS_MAX_ARTICLE_SIZE_EXCEEDED: // article is too big (> $wgMaxArticleSize), after merging in the new section + case EditPage::AS_END: // WikiPage::doEdit() was unsuccessfull + + throw new MWException( wfMessage( 'sf_autoedit_fail', $this->mOptions['target'] )->parse() ); + + case EditPage::AS_HOOK_ERROR: // Article update aborted by a hook function + + $this->logMessage( 'Article update aborted by a hook function', self::DEBUG ); + return false; // success + + // TODO: This error code only exists from 1.21 onwards. It is + // suitably handled by the default branch, but really should get its + // own branch. Uncomment once compatibility to pre1.21 is dropped. +// case EditPage::AS_PARSE_ERROR: // can't parse content +// +// throw new MWException( $status->getHTML() ); +// return true; // fail + + case EditPage::AS_SUCCESS_NEW_ARTICLE: // Article successfully created + + $query = $resultDetails['redirect'] ? 'redirect=no' : ''; + $anchor = isset( $resultDetails['sectionanchor'] ) ? $resultDetails['sectionanchor'] : ''; + + $this->getOutput()->redirect( $title->getFullURL( $query ) . $anchor ); + $this->getResult()->addValue( NULL, 'redirect', $title->getFullURL( $query ) . $anchor ); + return false; // success + + case EditPage::AS_SUCCESS_UPDATE: // Article successfully updated + + $extraQuery = ''; + $sectionanchor = $resultDetails['sectionanchor']; + + // Give extensions a chance to modify URL query on update + wfRunHooks( 'ArticleUpdateBeforeRedirect', array( $editor->getArticle(), &$sectionanchor, &$extraQuery ) ); + + if ( $resultDetails['redirect'] ) { + if ( $extraQuery == '' ) { + $extraQuery = 'redirect=no'; + } else { + $extraQuery = 'redirect=no&' . $extraQuery; + } + } + + $this->getOutput()->redirect( $title->getFullURL( $extraQuery ) . $sectionanchor ); + $this->getResult()->addValue( NULL, 'redirect', $title->getFullURL( $extraQuery ) . $sectionanchor ); + + return false; // success + + case EditPage::AS_BLANK_ARTICLE: // user tried to create a blank page + + $this->logMessage( 'User tried to create a blank page', self::DEBUG ); + + $this->getOutput()->redirect( $editor->getContextTitle()->getFullURL() ); + $this->getResult()->addValue( NULL, 'redirect', $editor->getContextTitle()->getFullURL() ); + + return false; // success + + case EditPage::AS_SPAM_ERROR: // summary contained spam according to one of the regexes in $wgSummarySpamRegex + + $match = $resultDetails['spam']; + if ( is_array( $match ) ) { + $match = $this->getLanguage()->listToText( $match ); + } + + throw new MWException( wfMessage( 'spamprotectionmatch', wfEscapeWikiText( $match ) )->parse() ); // FIXME: Include better error message + + case EditPage::AS_BLOCKED_PAGE_FOR_USER: // User is blocked from editting editor page + throw new UserBlockedError( $this->getUser()->getBlock() ); + + case EditPage::AS_IMAGE_REDIRECT_ANON: // anonymous user is not allowed to upload (User::isAllowed('upload') == false) + case EditPage::AS_IMAGE_REDIRECT_LOGGED: // logged in user is not allowed to upload (User::isAllowed('upload') == false) + throw new PermissionsError( 'upload' ); + + case EditPage::AS_READ_ONLY_PAGE_ANON: // editor anonymous user is not allowed to edit editor page + case EditPage::AS_READ_ONLY_PAGE_LOGGED: // editor logged in user is not allowed to edit editor page + throw new PermissionsError( 'edit' ); + + case EditPage::AS_READ_ONLY_PAGE: // wiki is in readonly mode (wfReadOnly() == true) + throw new ReadOnlyError; + + case EditPage::AS_RATE_LIMITED: // rate limiter for action 'edit' was tripped + throw new ThrottledError(); + + case EditPage::AS_NO_CREATE_PERMISSION: // user tried to create editor page, but is not allowed to do that ( Title->usercan('create') == false ) + $permission = $title->isTalkPage() ? 'createtalk' : 'createpage'; + throw new PermissionsError( $permission ); + + 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 $editor->hookError + $editor->hookError = '<div class="error">' . $status->getWikitext() . '</div>'; + throw new MWException( $status->getHTML() ); + } + } + + protected function doFormEdit( $formHTML, $formJS ) { + // return form html and js in the result + $this->getResult()->addValue( array('form'), 'HTML', $formHTML ); + $this->getResult()->addValue( array('form'), 'JS', $formJS ); +} + + protected function finalizeResults() { + + // set response text depending on the status and the requested action + if ( $this->mStatus === 200 ) { + if ( array_key_exists( 'ok text', $this->mOptions ) ) { + $responseText = MessageCache::singleton()->parse( $this->mOptions['ok text'], Title::newFromText( $this->mOptions['target'] ) )->getText(); + } elseif ( $this->mAction === self::ACTION_SAVE ) { + $responseText = wfMessage( 'sf_autoedit_success', $this->mOptions['target'], $this->mOptions['form'] )->parse(); + } else { + $responseText = null; + } + } else { + // get errortext (or use default) + if ( array_key_exists( 'error text', $this->mOptions ) ) { + $responseText = MessageCache::singleton()->parse( $this->mOptions['error text'], Title::newFromText( $this->mOptions['target'] ) )->getText(); + } elseif ( $this->mAction === self::ACTION_SAVE ) { + $responseText = wfMessage( 'sf_autoedit_fail', $this->mOptions['target'] )->parse(); + } else { + $responseText = null; + } + } + + $result = $this->getResult(); + + if ( $responseText !== null ) { + $result->addValue( null, 'responseText', $responseText ); + } + + $result->addValue( null, 'status', $this->mStatus, true ); + $result->addValue( array('form'), 'title', $this->mOptions['form'] ); + $result->addValue( null, 'target', $this->mOptions['target'], true ); + } + + /** + * Set custom headers to attach to the answer + */ + protected function setHeaders() { + + if ( !headers_sent() ) { + + header( 'X-Status: ' . $this->mStatus, true, $this->mStatus ); + header( 'X-Form: ' . $this->mOptions['form'] ); + header( 'X-Target: ' . $this->mOptions['target'] ); + + $redirect = $this->getOutput()->getRedirect(); + if ( $redirect ) { + header( 'X-Location: ' . $redirect ); + } + } + } + + /** + * Generates a target name from the given target name formula + * + * This parses the formula and replaces <unique number> tags + * + * @param type $targetNameFormula + * + * @throws MWException + * @return type + */ + protected function generateTargetName( $targetNameFormula ) { + + $targetName = $targetNameFormula; + + // prepend a super-page, if one was specified + if ( $this->getRequest()->getCheck( 'super_page' ) ) { + $targetName = $this->getRequest()->getVal( 'super_page' ) . '/' . $targetName; + } + + // prepend a namespace, if one was specified + if ( $this->getRequest()->getCheck( 'namespace' ) ) { + $targetName = $this->getRequest()->getVal( 'namespace' ) . ':' . $targetName; + } + + // replace "unique number" tag with one that won't get erased by the next line + $targetName = preg_replace( '/<unique number(.*)>/', '{num\1}', $targetName, 1 ); + + // if any formula stuff is still in the name after the parsing, just remove it + // FIXME: This is wrong. If anything is still left, something should have been present in the form and wasn't. An error should be raised. + //$targetName = StringUtils::delimiterReplace( '<', '>', '', $targetName ); + + // replace spaces back with underlines, in case a magic word or parser + // function name contains underlines - hopefully this won't cause + // problems of its own + $targetName = str_replace( ' ', '_', $targetName ); + + // now run the parser on it + global $wgParser; + $targetName = $wgParser->transformMsg( $targetName, ParserOptions::newFromUser( null ) ); + + $titleNumber = ''; + $isRandom = false; + $randomNumHasPadding = false; + $randomNumDigits = 6; + + if ( preg_match( '/{num.*}/', $targetName, $matches ) && strpos( $targetName, '{num' ) !== false ) { + // Random number + if ( preg_match( '/{num;random(;(0)?([1-9][0-9]*))?}/', $targetName, $matches ) ) { + $isRandom = true; + $randomNumHasPadding = array_key_exists( 2, $matches ); + $randomNumDigits = ( array_key_exists( 3, $matches ) ? $matches[3] : $randomNumDigits ); + $titleNumber = SFUtils::makeRandomNumber( $randomNumDigits, $randomNumHasPadding ); + } else if ( preg_match( '/{num.*start[_]*=[_]*([^;]*).*}/', $targetName, $matches ) ) { + // get unique number start value + // from target name; if it's not + // there, or it's not a positive + // number, start it out as blank + ; + if ( count( $matches ) == 2 && is_numeric( $matches[1] ) && $matches[1] >= 0 ) { + // the "start" value" + $titleNumber = $matches[1]; + } + } else if ( preg_match( '/^(_?{num.*}?)*$/', $targetName, $matches ) ) { + // the target name contains only underscores and number fields, + // i.e. would result in an empty title without the number set + $titleNumber = '1'; + } else { + $titleNumber = ''; + } + + // set target title + $targetTitle = Title::newFromText( preg_replace( '/{num.*}/', $titleNumber, $targetName ) ); + + // if the specified target title is invalid, give up + if ( !$targetTitle instanceof Title ) { + throw new MWException( wfMessage( 'sf_autoedit_invalidtargetspecified', trim( preg_replace( '/<unique number(.*)>/', $titleNumber, $targetNameFormula ) ) )->parse() ); + } + + // if title exists already cycle through numbers for this tag until + // we find one that gives a nonexistent page title; + // + // can not use $targetTitle->exists(); it does not use + // Title::GAID_FOR_UPDATE, which is needed to get correct data from + // cache; use $targetTitle->getArticleID() instead + while ( $targetTitle->getArticleID( Title::GAID_FOR_UPDATE ) !== 0 ) { + + if ( $isRandom ) { + $titleNumber = SFUtils::makeRandomNumber( $randomNumDigits, $randomNumHasPadding ); + } + // if title number is blank, change it to 2; otherwise, + // increment it, and if necessary pad it with leading 0s as well + elseif ( $titleNumber == "" ) { + $titleNumber = 2; + } else { + $titleNumber = str_pad( $titleNumber + 1, strlen( $titleNumber ), '0', STR_PAD_LEFT ); + } + + $targetTitle = Title::newFromText( preg_replace( '/{num.*}/', $titleNumber, $targetName ) ); + } + + $targetName = $targetTitle->getPrefixedText(); + } + + return $targetName; + } + + /** + * Depending on the requested action this method will try to store/preview + * the data in mOptions or retrieve the edit form. + * + * The form and target page will be available in mOptions after execution of + * the method. + * + * Errors and warnings are logged in the API result under the 'errors' key. + * The general request status is maintained in mStatus. + * + * @global $wgRequest + * @global $wgOut + * @global SFFormPrinter $sfgFormPrinter + * @throws MWException + */ + public function doAction() { + global $wgOut, $wgRequest, $sfgFormPrinter; + + // if the wiki is read-only, do not save + if ( wfReadOnly() ) { + + if ( $this->mAction === self::ACTION_SAVE ) { + throw new MWException( wfMessage( 'sf_autoedit_readonly', wfReadOnlyReason() )->parse() ); + } + + // even if not saving notify client anyway. Might want to dislay a notice + $this->logMessage( wfMessage( 'sf_autoedit_readonly', wfReadOnlyReason() )->parse(), self::NOTICE ); + } + + // find the title of the form to be used + $formTitle = $this->getFormTitle(); + + // get the form content + $formContent = StringUtils::delimiterReplace( + '<noinclude>', // start delimiter + '</noinclude>', // end delimiter + '', // replace by + WikiPage::factory( $formTitle )->getRawText() // subject + ); + + // signals that the form was submitted + // always true, else we would not be here + $isFormSubmitted = $this->mAction === self::ACTION_SAVE || $this->mAction === self::ACTION_PREVIEW || $this->mAction === self::ACTION_DIFF; + + // the article id of the form to be used + $formArticleId = $formTitle->getArticleID(); + + // the name of the target page; might be empty when using the one-step-process + $targetName = $this->mOptions['target']; + + // if the target page was not specified, try finding the page name formula + // (Why is this not done in SFFormPrinter::formHTML?) + if ( $targetName === '' ) { + + // Parse the form to see if it has a 'page name' value set. + if ( preg_match( '/{{{\s*info.*page name\s*=\s*(.*)}}}/msU', $formContent, $matches ) ) { + $pageNameElements = SFUtils::getFormTagComponents( trim( $matches[1] ) ); + $targetNameFormula = $pageNameElements[0]; + } else { + throw new MWException( wfMessage( 'sf_autoedit_notargetspecified' )->parse() ); + } + + $targetTitle = null; + } else { + $targetNameFormula = null; + $targetTitle = Title::newFromText( $targetName ); + } + + $preloadContent = ''; + + // save $wgRequest for later restoration + $oldRequest = $wgRequest; + + // preload data if not explicitly excluded and if the preload page exists + if ( !isset( $this->mOptions['preload'] ) || $this->mOptions['preload'] !== false ) { + + if ( isset( $this->mOptions['preload'] ) && is_string( $this->mOptions['preload'] ) ) { + $preloadTitle = Title::newFromText( $this->mOptions['preload'] ); + } else { + $preloadTitle = Title::newFromText( $targetName ); + } + + if ( $preloadTitle !== null && $preloadTitle->exists() ) { + + // the content of the page that was specified to be used for preloading + $preloadContent = WikiPage::factory( $preloadTitle )->getRawText(); + + $pageExists = true; + + } else { + if ( isset( $this->mOptions['preload'] ) ) { + $this->logMessage( wfMessage( 'sf_autoedit_invalidpreloadspecified', $this->mOptions['preload'] )->parse(), self::WARNING ); + } + } + } + + // allow extensions to set/change the preload text + wfRunHooks( 'sfEditFormPreloadText', array( &$preloadContent, $targetTitle, $formTitle ) ); + + // flag to keep track of formHTML runs + $formHtmlHasRun = false; + + if ( $preloadContent !== '' ) { + + // Spoof $wgRequest for SFFormPrinter::formHTML(). + $wgRequest = new FauxRequest( $this->mOptions, true ); + + // call SFFormPrinter::formHTML to get at the form html of the existing page + list ( $formHTML, $formJS, $targetContent, $form_page_title, $generatedTargetNameFormula ) = + $sfgFormPrinter->formHTML( + $formContent, $isFormSubmitted, $pageExists, $formArticleId, $preloadContent, $targetName, $targetNameFormula + ); + + $formHtmlHasRun = true; + + // parse the data to be preloaded from the form html of the + // existing page + $data = $this->parseDataFromHTMLFrag( $formHTML ); + + // and merge/overwrite it with the new data + $this->mOptions = SFUtils::array_merge_recursive_distinct( $data, $this->mOptions ); + } + + // We already preloaded stuff for saving/previewing - + // do not do this again. + if ( $isFormSubmitted && !$wgRequest->getCheck( 'partial' ) ) { + $preloadContent = ''; + $pageExists = false; + } else { + // Source of the data is a page. + $pageExists = ( is_a( $targetTitle, 'Title') && $targetTitle->exists() ); + } + + // Spoof $wgRequest for SFFormPrinter::formHTML(). + $wgRequest = new FauxRequest( $this->mOptions, true ); + + // if necessary spoof wgOut; if we took the general $wgOut again some JS + // modules might attach themselves twice and thus be called twice + if ( $formHtmlHasRun ) { + // save wgOut for later restoration + $oldOut = $wgOut; + $wgOut = new OutputPage( RequestContext::getMain() ); + } + + // get wikitext for submitted data and form + list ( $formHTML, $formJS, $targetContent, $generatedFormName, $generatedTargetNameFormula ) = + $sfgFormPrinter->formHTML( $formContent, $isFormSubmitted, $pageExists, $formArticleId, $preloadContent, $targetName, $targetNameFormula ); + + if ( $formHtmlHasRun ) { + // restore wgOut + $wgOut = $oldOut; + } + + // restore original request + $wgRequest = $oldRequest; + + if ( $generatedFormName !== '' ) { + $formTitle = Title::newFromText( $generatedFormName ); + $this->mOptions['formtitle'] = $formTitle->getText(); + } + + $this->mOptions['formHTML'] = $formHTML; + $this->mOptions['formJS'] = $formJS; + + if ( $isFormSubmitted ) { + + // if the target page was not specified, see if something was generated + // from the target name formula + if ( $this->mOptions['target'] === '' ) { + + // if no name was generated, we can not save => give up + if ( $generatedTargetNameFormula === '' ) { + throw new MWException( wfMessage( 'sf_autoedit_notargetspecified' )->parse() ); + } + + $this->mOptions['target'] = $this->generateTargetName( $generatedTargetNameFormula ); + } + + // Lets other code process additional form-definition syntax + wfRunHooks( 'sfWritePageData', array( $this->mOptions['form'], Title::newFromText( $this->mOptions['target'] ), &$targetContent ) ); + + $editor = $this->setupEditPage( $targetContent ); + + // perform the requested action + if ( $this->mAction === self::ACTION_PREVIEW ) { + $this->doPreview( $editor ); + } else if ( $this->mAction === self::ACTION_DIFF ) { + $this->doDiff( $editor ); + } else { + $this->doStore( $editor ); + } + } else if ( $this->mAction === self::ACTION_FORMEDIT ) { + $this->doFormEdit( $formHTML, $formJS ); + } + } + + private function parseDataFromHTMLFrag( $html ) { + + $data = array( ); + $doc = new DOMDocument(); + @$doc->loadHTML( + '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd"><html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/></head><body>' + . $html + . '</body></html>' + ); + + // Process input tags. + $inputs = $doc->getElementsByTagName( 'input' ); + + for ( $i = 0; $i < $inputs->length; $i++ ) { + + $input = $inputs->item( $i ); + $type = $input->getAttribute( 'type' ); + $name = trim( $input->getAttribute( 'name' ) ); + + if ( !$name || $input->hasAttribute( 'disabled' ) ) { + continue; + } + + if ( $type === '' ) + $type = 'text'; + + switch ( $type ) { + case 'checkbox': + case 'radio': + if ( $input->hasAttribute( 'checked' ) ) { + self::addToArray( $data, $name, $input->getAttribute( 'value' ) ); + } + break; + + // case 'button': + case 'hidden': + case 'image': + case 'password': + // case 'reset': + // case 'submit': + case 'text': + self::addToArray( $data, $name, $input->getAttribute( 'value' ) ); + break; + } + } + + // Process select tags + $selects = $doc->getElementsByTagName( 'select' ); + + for ( $i = 0; $i < $selects->length; $i++ ) { + + $select = $selects->item( $i ); + $name = trim( $select->getAttribute( 'name' ) ); + + if ( !$name || $select->hasAttribute( 'disabled' ) ) { + continue; + } + + $options = $select->getElementsByTagName( 'option' ); + + // if the current $select is a radio button select (i.e. not multiple) + // set the first option to selected as default. This may be overwritten + // in the loop below + if ( $options->length > 0 && (!$select->hasAttribute( 'multiple' ) ) ) { + self::addToArray( $data, $name, $options->item( 0 )->getAttribute( 'value' ) ); + } + + for ( $o = 0; $o < $options->length; $o++ ) { + if ( $options->item( $o )->hasAttribute( 'selected' ) ) { + if ( $options->item( $o )->getAttribute( 'value' ) ) { + self::addToArray( $data, $name, $options->item( $o )->getAttribute( 'value' ) ); + } else { + self::addToArray( $data, $name, $options->item( $o )->nodeValue ); + } + } + } + } + + // Process textarea tags + $textareas = $doc->getElementsByTagName( 'textarea' ); + + for ( $i = 0; $i < $textareas->length; $i++ ) { + $textarea = $textareas->item( $i ); + $name = trim( $textarea->getAttribute( 'name' ) ); + + if ( !$name ) + continue; + + self::addToArray( $data, $name, $textarea->textContent ); + } + + return $data; + } + + /** + * Parses data from a query string into the $data array + * + * @param Array $data + * @param String $queryString + * @return Array + */ + private function parseDataFromQueryString( &$data, $queryString ) { + $params = explode( '&', $queryString ); + + foreach ( $params as $param ) { + $elements = explode( '=', $param, 2 ); + + $key = trim( urldecode( $elements[0] ) ); + $value = count( $elements ) > 1 ? urldecode( $elements[1] ) : null; + + if ( $key == "query" || $key == "query string" ) { + $this->parseDataFromQueryString( $data, $value ); + } else { + self::addToArray( $data, $key, $value ); + } + } + + return $data; + } + + // This function recursively inserts the value into a tree. + // $array is root + // $key identifies path to position in tree. + // Format: 1stLevelName[2ndLevel][3rdLevel][...], i.e. normal array notation + // $value: the value to insert + // $toplevel: if this is a toplevel value. + public static function addToArray( &$array, $key, $value, $toplevel = true ) { + $matches = array( ); + + if ( preg_match( '/^([^\[\]]*)\[([^\[\]]*)\](.*)/', $key, $matches ) ) { + + // for some reason toplevel keys get their spaces encoded by MW. + // We have to imitate that. + if ( $toplevel ) { + $key = str_replace( ' ', '_', $matches[1] ); + } else { + $key = $matches[1]; + } + + // if subsequent element does not exist yet or is a string (we prefer arrays over strings) + if ( !array_key_exists( $key, $array ) || is_string( $array[$key] ) ) { + $array[$key] = array( ); + } + + self::addToArray( $array[$key], $matches[2] . $matches[3], $value, false ); + } else { + if ( $key ) { + // only add the string value if there is no child array present + if ( !array_key_exists( $key, $array ) || !is_array( $array[$key] ) ) { + $array[$key] = $value; + } + } else { + array_push( $array, $value ); + } + } + } + + /** + * Add error message to the ApiResult + * + * @param string $msg + * @param int $errorLevel + * + * @return string + */ + private function logMessage( $msg, $errorLevel = self::ERROR ) { + + if ( $errorLevel === self::ERROR ) { + $this->mStatus = 400; + } + + $this->getResult()->addValue( array( 'errors' ), null, array( 'level' => $errorLevel, 'message' => $msg ) ); + + return $msg; + } + + /** + * Indicates whether this module requires write mode + * @return bool + */ + public function isWriteMode() { + return true; + } + + /** + * Returns the array of allowed parameters (parameter name) => (default + * value) or (parameter name) => (array with PARAM_* constants as keys) + * Don't call this function directly: use getFinalParams() to allow + * hooks to modify parameters as needed. + * @return array or false + */ + function getAllowedParams() { + return array( + 'form' => null, + 'target' => null, + 'query' => null, + 'preload' => null + ); + } + + /** + * Returns an array of parameter descriptions. + * Don't call this functon directly: use getFinalParamDescription() to + * allow hooks to modify descriptions as needed. + * @return array or false + */ + function getParamDescription() { + return array( + 'form' => 'The form to use.', + 'target' => 'The target page.', + 'query' => 'The query string.', + 'preload' => 'The name of a page to preload' + ); + } + + /** + * Returns the description string for this module + * @return mixed string or array of strings + */ + function getDescription() { + return <<<END +This module is used to remotely create or edit pages using Semantic Forms. + +Add "template-name[field-name]=field-value" to the query string parameter, to set the value for a specific field. +To set values for more than one field use "&", or rather its URL encoded version "%26": "template-name[field-name-1]=field-value-1%26template-name[field-name-2]=field-value-2". +See the first example below. + +In addition to the query parameter, any parameter in the URL of the form "template-name[field-name]=field-value" will be treated as part of the query. See the second example. +END; + } + + /** + * Returns usage examples for this module. + * @return mixed string or array of strings + */ + protected function getExamples() { + return array( + 'With query parameter: api.php?action=sfautoedit&form=form-name&target=page-name&query=template-name[field-name-1]=field-value-1%26template-name[field-name-2]=field-value-2', + 'Without query parameter: api.php?action=sfautoedit&form=form-name&target=page-name&template-name[field-name-1]=field-value-1&template-name[field-name-2]=field-value-2' + ); + } + + /** + * Returns a string that identifies the version of the class. + * Includes the class name, the svn revision, timestamp, and + * last author. + * + * @return string + */ + function getVersion() { + global $sfgIP; + $gitSha1 = SpecialVersion::getGitHeadSha1( $sfgIP ); + return __CLASS__ . '-' . SF_VERSION . ($gitSha1 !== false) ? ' (' . substr( $gitSha1, 0, 7 ) . ')' : ''; + } +} |