From 3a6c9d36c9ebf7dea98ebc3f17a44569f2f220de Mon Sep 17 00:00:00 2001 From: Aaron Schulz Date: Wed, 19 Nov 2014 16:33:51 -0800 Subject: [PATCH] Added ApiStashEdit module for pre-emptive edit parsing * This lets edits be prepared while users enter edit summaries. * The edit form will now make use of this API, controlled by $wgAjaxEditStash. Change-Id: I4f4057bc0d1d4a66a8f7cfb7cdc26d443a8eb0c4 --- RELEASE-NOTES-1.25 | 2 + autoload.php | 1 + includes/DefaultSettings.php | 13 + includes/EditPage.php | 14 +- includes/api/ApiMain.php | 1 + includes/api/ApiStashEdit.php | 293 ++++++++++++++++++ includes/page/WikiPage.php | 68 ++-- resources/Resources.php | 7 + .../mediawiki.action.edit.stash.js | 31 ++ 9 files changed, 405 insertions(+), 25 deletions(-) create mode 100644 includes/api/ApiStashEdit.php create mode 100644 resources/src/mediawiki.action/mediawiki.action.edit.stash.js diff --git a/RELEASE-NOTES-1.25 b/RELEASE-NOTES-1.25 index 2b347b983f..ce825e553f 100644 --- a/RELEASE-NOTES-1.25 +++ b/RELEASE-NOTES-1.25 @@ -27,6 +27,8 @@ production. longer be used. If extracts and page images are desired, the TextExtracts and PageImages extensions are required. * $wgOpenSearchTemplate is deprecated in favor of $wgOpenSearchTemplates. +* Edits are now prepared via AJAX as users type edit summaries. This behavior + can be disabled via $wgAjaxEditStash. === New features in 1.25 === * (T64861) Updated plural rules to CLDR 26. Includes incompatible changes diff --git a/autoload.php b/autoload.php index 58e62b9e23..da1c97a8e2 100644 --- a/autoload.php +++ b/autoload.php @@ -120,6 +120,7 @@ $wgAutoloadLocalClasses = array( 'ApiRollback' => __DIR__ . '/includes/api/ApiRollback.php', 'ApiRsd' => __DIR__ . '/includes/api/ApiRsd.php', 'ApiSetNotificationTimestamp' => __DIR__ . '/includes/api/ApiSetNotificationTimestamp.php', + 'ApiStashEdit' => __DIR__ . '/includes/api/ApiStashEdit.php', 'ApiTokens' => __DIR__ . '/includes/api/ApiTokens.php', 'ApiUnblock' => __DIR__ . '/includes/api/ApiUnblock.php', 'ApiUndelete' => __DIR__ . '/includes/api/ApiUndelete.php', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index ca410884a1..88bb44174e 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -5024,6 +5024,13 @@ $wgRateLimits = array( 'ip' => null, 'subnet' => null, ), + 'stashedit' => array( // stashing edits into cache before save + 'anon' => null, + 'user' => null, + 'newbie' => null, + 'ip' => null, + 'subnet' => null, + ) ); /** @@ -7037,6 +7044,12 @@ $wgAjaxUploadDestCheck = true; */ $wgAjaxLicensePreview = true; +/** + * Have clients send edits to be prepared when filling in edit summaries. + * This gives the server a head start on the expensive parsing operation. + */ +$wgAjaxEditStash = true; + /** * Settings for incoming cross-site AJAX requests: * Newer browsers support cross-site AJAX when the target resource allows requests diff --git a/includes/EditPage.php b/includes/EditPage.php index e51999dd8e..4a013ef3dd 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -321,6 +321,9 @@ class EditPage { /** @var int */ public $oldid = 0; + /** @var int */ + public $parentRevId = 0; + /** @var string */ public $editintro = ''; @@ -881,6 +884,7 @@ class EditPage { } $this->oldid = $request->getInt( 'oldid' ); + $this->parentRevId = $request->getInt( 'parentRevId' ); $this->bot = $request->getBool( 'bot', true ); $this->nosummary = $request->getBool( 'nosummary' ); @@ -2071,7 +2075,7 @@ class EditPage { } function setHeaders() { - global $wgOut, $wgUser; + global $wgOut, $wgUser, $wgAjaxEditStash; $wgOut->addModules( 'mediawiki.action.edit' ); $wgOut->addModuleStyles( 'mediawiki.action.edit.styles' ); @@ -2084,6 +2088,10 @@ class EditPage { $wgOut->addModules( 'mediawiki.action.edit.editWarning' ); } + if ( $wgAjaxEditStash ) { + $wgOut->addModules( 'mediawiki.action.edit.stash' ); + } + $wgOut->setRobotPolicy( 'noindex,nofollow' ); # Enabled article-related sidebar, toplinks, etc. @@ -2448,6 +2456,8 @@ class EditPage { $wgOut->addHTML( Html::hidden( 'wpAutoSummary', $autosumm ) ); $wgOut->addHTML( Html::hidden( 'oldid', $this->oldid ) ); + $wgOut->addHTML( Html::hidden( 'parentRevId', + $this->parentRevId ?: $this->mArticle->getRevIdFetched() ) ); $wgOut->addHTML( Html::hidden( 'format', $this->contentFormat ) ); $wgOut->addHTML( Html::hidden( 'model', $this->contentModel ) ); @@ -2856,7 +2866,7 @@ class EditPage { global $wgOut; $section = htmlspecialchars( $this->section ); $wgOut->addHTML( << + diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index 7600066a23..3d04f95a8d 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -54,6 +54,7 @@ class ApiMain extends ApiBase { 'query' => 'ApiQuery', 'expandtemplates' => 'ApiExpandTemplates', 'parse' => 'ApiParse', + 'stashedit' => 'ApiStashEdit', 'opensearch' => 'ApiOpenSearch', 'feedcontributions' => 'ApiFeedContributions', 'feedrecentchanges' => 'ApiFeedRecentChanges', diff --git a/includes/api/ApiStashEdit.php b/includes/api/ApiStashEdit.php new file mode 100644 index 0000000000..d8c7077315 --- /dev/null +++ b/includes/api/ApiStashEdit.php @@ -0,0 +1,293 @@ +getUser(); + $params = $this->extractRequestParams(); + + $page = $this->getTitleOrPageId( $params ); + $title = $page->getTitle(); + + if ( !ContentHandler::getForModelID( $params['contentmodel'] ) + ->isSupportedFormat( $params['contentformat'] ) + ) { + $this->dieUsage( "Unsupported content model/format", 'badmodelformat' ); + } + + $text = trim( $params['text'] ); // needed so the key SHA1's match + $textContent = ContentHandler::makeContent( + $text, $title, $params['contentmodel'], $params['contentformat'] ); + + $page = WikiPage::factory( $title ); + if ( $page->exists() ) { + // Page exists: get the merged content with the proposed change + $baseRev = Revision::newFromPageId( $page->getId(), $params['baserevid'] ); + if ( !$baseRev ) { + $this->dieUsage( "No revision ID {$params['baserevid']}", 'missingrev' ); + } + $currentRev = $page->getRevision(); + if ( !$currentRev ) { + $this->dieUsage( "No current revision of page ID {$page->getId()}", 'missingrev' ); + } + // Merge in the new version of the section to get the proposed version + $editContent = $page->replaceSectionAtRev( + $params['section'], + $textContent, + $params['sectiontitle'], + $baseRev->getId() + ); + if ( !$editContent ) { + $this->dieUsage( "Could not merge updated section.", 'replacefailed' ); + } + if ( $currentRev->getId() == $baseRev->getId() ) { + // Base revision was still the latest; nothing to merge + $content = $editContent; + } else { + // Merge the edit into the current version + $baseContent = $baseRev->getContent(); + $currentContent = $currentRev->getContent(); + if ( !$baseContent || !$currentContent ) { + $this->dieUsage( "Missing content for page ID {$page->getId()}", 'missingrev' ); + } + $handler = ContentHandler::getForModelID( $baseContent->getModel() ); + $content = $handler->merge3( $baseContent, $editContent, $currentContent ); + } + } else { + // New pages: use the user-provided content model + $content = $textContent; + } + + if ( !$content ) { // merge3() failed + $this->getResult()->addValue( null, + $this->getModuleName(), array( 'status' => 'editconflict' ) ); + return; + } + + // The user will abort the AJAX request by pressing "save", so ignore that + ignore_user_abort( true ); + + // Get a key based on the source text, format, and user preferences + $key = self::getStashKey( $title, $content, $user ); + // De-duplicate requests on the same key + if ( $user->pingLimiter( 'stashedit' ) ) { + $editInfo = false; + $status = 'ratelimited'; + } elseif ( $wgMemc->lock( $key, 0, 30 ) ) { + $contentFormat = $content->getDefaultFormat(); + $editInfo = $page->prepareContentForEdit( $content, null, $user, $contentFormat ); + $wgMemc->unlock( $key ); + $status = 'error'; // default + } else { + $editInfo = false; + $status = 'busy'; + } + + if ( $editInfo && $editInfo->output ) { + $parserOutput = $editInfo->output; + // If an item is renewed, mind the cache TTL determined by config and parser functions + $since = time() - wfTimestamp( TS_UNIX, $parserOutput->getTimestamp() ); + $ttl = min( $parserOutput->getCacheExpiry() - $since, 5 * 60 ); + if ( $ttl > 0 && !$parserOutput->getFlag( 'vary-revision' ) ) { + // Only store what is actually needed + $stashInfo = (object)array( + 'pstContent' => $editInfo->pstContent, + 'output' => $editInfo->output, + 'timestamp' => $editInfo->timestamp + ); + $ok = $wgMemc->set( $key, $stashInfo, $ttl ); + if ( $ok ) { + $status = 'stashed'; + wfDebugLog( 'PreparedEdit', "Cached parser output for key '$key'." ); + } else { + $status = 'error'; + wfDebugLog( 'PreparedEdit', "Failed to cache parser output for key '$key'." ); + } + } else { + $status = 'uncacheable'; + wfDebugLog( 'PreparedEdit', "Uncacheable parser output for key '$key'." ); + } + } + + $this->getResult()->addValue( null, $this->getModuleName(), array( 'status' => $status ) ); + } + + /** + * Get the temporary prepared edit stash key for a user + * + * @param Title $title + * @param Content $content + * @param User $user User to get parser options from + * @return string + */ + protected static function getStashKey( + Title $title, Content $content, User $user + ) { + return wfMemcKey( 'prepared-edit', + md5( $title->getPrefixedDBkey() ), // handle rename races + $content->getModel(), + $content->getDefaultFormat(), + sha1( $content->serialize( $content->getDefaultFormat() ) ), + $user->getId() ?: md5( $user->getName() ), // account for user parser options + $user->getId() ? $user->getTouched() : '-' // handle preference change races + ); + } + + /** + * Check that a prepared edit is in cache and still up-to-date + * + * This method blocks if the prepared edit is already being rendered, + * waiting until rendering finishes before doing final validity checks. + * + * The cache is rejected if template or file changes are detected. + * Note that foreign template or file transclusions are not checked. + * + * The result is a map (pstContent,output,timestamp) with fields + * extracted directly from WikiPage::prepareContentForEdit(). + * + * @param Title $title + * @param Content $content + * @param User $user User to get parser options from + * @return stdClass|bool Returns false on cache miss + */ + public static function checkCache( Title $title, Content $content, User $user ) { + global $wgMemc; + + $key = self::getStashKey( $title, $content, $user ); + $editInfo = $wgMemc->get( $key ); + if ( !is_object( $editInfo ) ) { + $start = microtime( true ); + // We ignore user aborts and keep parsing. Block on any prior parsing + // so as to use it's results and make use of the time spent parsing. + if ( $wgMemc->lock( $key, 30, 30 ) ) { + $editInfo = $wgMemc->get( $key ); + $wgMemc->unlock( $key ); + $sec = microtime( true ) - $start; + wfDebugLog( 'PreparedEdit', "Waited $sec seconds on '$key'." ); + } + } + + if ( !is_object( $editInfo ) || !$editInfo->output ) { + return false; + } + + $time = wfTimestamp( TS_UNIX, $editInfo->output->getTimestamp() ); + if ( ( time() - $time ) <= 3 ) { + wfDebugLog( 'PreparedEdit', "Timestamp-based cache hit for key '$key'." ); + return $editInfo; // assume nothing changed + } + + $dbr = wfGetDB( DB_SLAVE ); + // Check that no templates used in the output changed... + $cWhr = array(); // conditions to find changes/creations + $dWhr = array(); // conditions to find deletions + foreach ( $editInfo->output->getTemplateIds() as $ns => $stuff ) { + foreach ( $stuff as $dbkey => $revId ) { + $cWhr[] = array( 'page_namespace' => $ns, 'page_title' => $dbkey, + 'page_latest != ' . intval( $revId ) ); + $dWhr[] = array( 'page_namespace' => $ns, 'page_title' => $dbkey ); + } + } + $change = $dbr->selectField( 'page', '1', $dbr->makeList( $cWhr, LIST_OR ), __METHOD__ ); + $n = $dbr->selectField( 'page', 'COUNT(*)', $dbr->makeList( $dWhr, LIST_OR ), __METHOD__ ); + if ( $change || $n != count( $dWhr ) ) { + wfDebugLog( 'PreparedEdit', "Stale cache for key '$key'; template changed." ); + return false; + } + + // Check that no files used in the output changed... + $cWhr = array(); // conditions to find changes/creations + $dWhr = array(); // conditions to find deletions + foreach ( $editInfo->output->getFileSearchOptions() as $name => $options ) { + $cWhr[] = array( 'img_name' => $dbkey, + 'img_sha1 != ' . $dbr->addQuotes( strval( $options['sha1'] ) ) ); + $dWhr[] = array( 'img_name' => $dbkey ); + } + $change = $dbr->selectField( 'image', '1', $dbr->makeList( $cWhr, LIST_OR ), __METHOD__ ); + $n = $dbr->selectField( 'image', 'COUNT(*)', $dbr->makeList( $dWhr, LIST_OR ), __METHOD__ ); + if ( $change || $n != count( $dWhr ) ) { + wfDebugLog( 'PreparedEdit', "Stale cache for key '$key'; file changed." ); + return false; + } + + wfDebugLog( 'PreparedEdit', "Cache hit for key '$key'." ); + + return $editInfo; + } + + public function getAllowedParams() { + return array( + 'title' => array( + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => true + ), + 'section' => array( + ApiBase::PARAM_TYPE => 'string', + ), + 'sectiontitle' => array( + ApiBase::PARAM_TYPE => 'string' + ), + 'text' => array( + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => true + ), + 'contentmodel' => array( + ApiBase::PARAM_TYPE => ContentHandler::getContentModels(), + ApiBase::PARAM_REQUIRED => true + ), + 'contentformat' => array( + ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(), + ApiBase::PARAM_REQUIRED => true + ), + 'baserevid' => array( + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_REQUIRED => true + ) + ); + } + + function needsToken() { + return 'csrf'; + } + + function mustBePosted() { + return true; + } + + function isInternal() { + return true; + } +} diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index b99093cb27..8b26c2319c 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -1696,7 +1696,7 @@ class WikiPage implements Page, IDBAccessObject { * * @param bool|int $baseRevId The revision ID this edit was based off, if any * @param User $user The user doing the edit - * @param string $serialisation_format Format for storing the content in the + * @param string $serialFormat Format for storing the content in the * database. * * @throws MWException @@ -1717,7 +1717,7 @@ class WikiPage implements Page, IDBAccessObject { * @since 1.21 */ public function doEditContent( Content $content, $summary, $flags = 0, $baseRevId = false, - User $user = null, $serialisation_format = null + User $user = null, $serialFormat = null ) { global $wgUser, $wgUseAutomaticEditSummaries, $wgUseRCPatrol, $wgUseNPPatrol; @@ -1783,7 +1783,7 @@ class WikiPage implements Page, IDBAccessObject { $summary = $handler->getAutosummary( $old_content, $content, $flags ); } - $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialisation_format ); + $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialFormat, true ); $serialized = $editInfo->pst; /** @@ -1825,7 +1825,7 @@ class WikiPage implements Page, IDBAccessObject { 'user_text' => $user->getName(), 'timestamp' => $now, 'content_model' => $content->getModel(), - 'content_format' => $serialisation_format, + 'content_format' => $serialFormat, ) ); // XXX: pass content object?! $changed = !$content->equals( $old_content ); @@ -1951,7 +1951,7 @@ class WikiPage implements Page, IDBAccessObject { 'user_text' => $user->getName(), 'timestamp' => $now, 'content_model' => $content->getModel(), - 'content_format' => $serialisation_format, + 'content_format' => $serialFormat, ) ); $revisionId = $revision->insertOn( $dbw ); @@ -2068,49 +2068,71 @@ class WikiPage implements Page, IDBAccessObject { * @param Content $content * @param int|null $revid * @param User|null $user - * @param string|null $serialization_format + * @param string|null $serialFormat + * @param bool $useCache Check shared prepared edit cache * - * @return bool|object + * @return object * * @since 1.21 */ - public function prepareContentForEdit( Content $content, $revid = null, User $user = null, - $serialization_format = null + public function prepareContentForEdit( + Content $content, $revid = null, User $user = null, $serialFormat = null, $useCache = false ) { global $wgContLang, $wgUser; + $user = is_null( $user ) ? $wgUser : $user; //XXX: check $user->getId() here??? - // Use a sane default for $serialization_format, see bug 57026 - if ( $serialization_format === null ) { - $serialization_format = $content->getContentHandler()->getDefaultFormat(); + // Use a sane default for $serialFormat, see bug 57026 + if ( $serialFormat === null ) { + $serialFormat = $content->getContentHandler()->getDefaultFormat(); } if ( $this->mPreparedEdit && $this->mPreparedEdit->newContent && $this->mPreparedEdit->newContent->equals( $content ) && $this->mPreparedEdit->revid == $revid - && $this->mPreparedEdit->format == $serialization_format + && $this->mPreparedEdit->format == $serialFormat // XXX: also check $user here? ) { // Already prepared return $this->mPreparedEdit; } + // The edit may have already been prepared via api.php?action=stashedit + $cachedEdit = $useCache + ? ApiStashEdit::checkCache( $this->getTitle(), $content, $user ) + : false; + $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang ); wfRunHooks( 'ArticlePrepareTextForEdit', array( $this, $popts ) ); $edit = (object)array(); + if ( $cachedEdit ) { + $edit->timestamp = $cachedEdit->timestamp; + } else { + $edit->timestamp = wfTimestampNow(); + } + // @note: $cachedEdit is not used if the rev ID was referenced in the text $edit->revid = $revid; - $edit->timestamp = wfTimestampNow(); - $edit->pstContent = $content ? $content->preSaveTransform( $this->mTitle, $user, $popts ) : null; + if ( $cachedEdit ) { + $edit->pstContent = $cachedEdit->pstContent; + } else { + $edit->pstContent = $content + ? $content->preSaveTransform( $this->mTitle, $user, $popts ) + : null; + } - $edit->format = $serialization_format; + $edit->format = $serialFormat; $edit->popts = $this->makeParserOptions( 'canonical' ); - $edit->output = $edit->pstContent - ? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts ) - : null; + if ( $cachedEdit ) { + $edit->output = $cachedEdit->output; + } else { + $edit->output = $edit->pstContent + ? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts ) + : null; + } $edit->newContent = $content; $edit->oldContent = $this->getContent( Revision::RAW ); @@ -2118,7 +2140,7 @@ class WikiPage implements Page, IDBAccessObject { // NOTE: B/C for hooks! don't use these fields! $edit->newText = $edit->newContent ? ContentHandler::getContentText( $edit->newContent ) : ''; $edit->oldText = $edit->oldContent ? ContentHandler::getContentText( $edit->oldContent ) : ''; - $edit->pst = $edit->pstContent ? $edit->pstContent->serialize( $serialization_format ) : ''; + $edit->pst = $edit->pstContent ? $edit->pstContent->serialize( $serialFormat ) : ''; $this->mPreparedEdit = $edit; return $edit; @@ -2288,14 +2310,14 @@ class WikiPage implements Page, IDBAccessObject { * @param User $user The relevant user * @param string $comment Comment submitted * @param bool $minor Whereas it's a minor modification - * @param string $serialisation_format Format for storing the content in the database + * @param string $serialFormat Format for storing the content in the database */ public function doQuickEditContent( Content $content, User $user, $comment = '', $minor = false, - $serialisation_format = null + $serialFormat = null ) { wfProfileIn( __METHOD__ ); - $serialized = $content->serialize( $serialisation_format ); + $serialized = $content->serialize( $serialFormat ); $dbw = wfGetDB( DB_MASTER ); $revision = new Revision( array( diff --git a/resources/Resources.php b/resources/Resources.php index 9755d25a09..72cc2ef647 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1050,6 +1050,13 @@ return array( 'mediawiki.action.history.diff', ), ), + 'mediawiki.action.edit.stash' => array( + 'scripts' => 'resources/src/mediawiki.action/mediawiki.action.edit.stash.js', + 'dependencies' => array( + 'jquery.getAttrs', + 'mediawiki.api', + ), + ), 'mediawiki.action.history' => array( 'scripts' => 'resources/src/mediawiki.action/mediawiki.action.history.js', 'styles' => 'resources/src/mediawiki.action/mediawiki.action.history.css', diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.stash.js b/resources/src/mediawiki.action/mediawiki.action.edit.stash.js new file mode 100644 index 0000000000..895cb03783 --- /dev/null +++ b/resources/src/mediawiki.action/mediawiki.action.edit.stash.js @@ -0,0 +1,31 @@ +/*! + * Scripts for pre-emptive edit preparing on action=edit + */ +( function ( mw, $ ) { + $( function () { + var api = new mw.Api(), pending = null, $form = $( '#editform' ); + + function stashEdit( token ) { + var data = $form.serializeObject(); + + pending = api.post( { + action: 'stashedit', + token: token, + title: mw.config.get( 'wgPageName' ), + section: data.wpSection, + sectiontitle: data.wpSection === 'new' ? data.wpSummary : '', + text: data.wpTextbox1, + contentmodel: data.model, + contentformat: data.format, + baserevid: data.parentRevId + } ); + } + + $form.on( 'change', function () { + if ( pending ) { + pending.abort(); + } + api.getToken( 'edit' ).then( stashEdit ); + } ); + } ); +}( mediaWiki, jQuery ) ); -- 2.20.1