/*! * VisualEditor MediaWiki Initialization ArticleTarget class. * * @copyright 2011-2017 VisualEditor Team and others; see AUTHORS.txt * @license The MIT License (MIT); see LICENSE.txt */ /* global EasyDeflate, alert */ /** * Initialization MediaWiki article target. * * @class * @extends ve.init.mw.Target * * @constructor * @param {string} pageName Name of target page * @param {string} [revisionId] If the editor should load a revision of the page, pass the * revision id here. Defaults to loading the latest version (see #load). * @param {Object} [config] Configuration options */ ve.init.mw.ArticleTarget = function VeInitMwArticleTarget( pageName, revisionId, config ) { config = config || {}; config.toolbarConfig = $.extend( { shadow: true, actions: true, floatable: true }, config.toolbarConfig ); // Parent constructor ve.init.mw.ArticleTarget.super.call( this, config ); // Properties this.saveDialog = null; this.saveDeferred = null; this.captcha = null; this.docToSave = null; this.toolbarSaveButton = null; this.pageName = pageName; this.pageExists = mw.config.get( 'wgRelevantArticleId', 0 ) !== 0; this.toolbarScrollOffset = mw.config.get( 'wgVisualEditorToolbarScrollOffset', 0 ); this.section = null; this.sectionTitle = null; this.editSummaryValue = null; this.initialEditSummary = null; this.$templatesUsed = null; this.checkboxFields = null; this.checkboxesByName = null; this.$otherFields = null; // Sometimes we actually don't want to send a useful oldid // if we do, PostEdit will give us a 'page restored' message this.requestedRevId = revisionId && parseInt( revisionId ); this.currentRevisionId = mw.config.get( 'wgCurRevisionId' ); this.revid = this.requestedRevId || this.currentRevisionId; this.restoring = !!this.requestedRevId && this.requestedRevId !== this.currentRevisionId; this.pageDeletedWarning = false; this.editToken = mw.user.tokens.get( 'editToken' ); this.submitUrl = ( new mw.Uri( mw.util.getUrl( this.pageName ) ) ) .extend( { action: 'submit', veswitched: 1 } ); this.events = { track: $.noop, trackActivationStart: $.noop, trackActivationComplete: $.noop }; this.welcomeDialog = null; this.welcomeDialogPromise = null; this.preparedCacheKeyPromise = null; this.clearState(); // Initialization this.$element.addClass( 've-init-mw-articleTarget' ); }; /* Inheritance */ OO.inheritClass( ve.init.mw.ArticleTarget, ve.init.mw.Target ); /* Events */ /** * @event editConflict */ /** * @event save */ /** * @event showChanges */ /** * @event noChanges */ /** * @event saveErrorEmpty * Fired when save API returns no data object */ /** * @event saveErrorSpamBlacklist * Fired when save is considered spam or blacklisted */ /** * @event saveErrorAbuseFilter * Fired when AbuseFilter throws warnings */ /** * @event saveErrorBadToken * @param {boolean} willRetry Whether an automatic retry will occur * Fired on save if we have to fetch a new edit token. * This is mainly for analytical purposes. */ /** * @event saveErrorNewUser * Fired when user is logged in as a new user */ /** * @event saveErrorCaptcha * Fired when saveError indicates captcha field is required */ /** * @event saveErrorUnknown * @param {string} errorMsg Error message shown to the user * Fired for any other type of save error */ /** * @event saveErrorPageDeleted * Fired when user tries to save page that was deleted after opening VE */ /** * @event saveErrorTitleBlacklist * Fired when the user tries to save page in violation of the TitleBlacklist */ /** * @event saveErrorHookAborted * Fired when the user tries to save page in violation of an extension */ /** * @event saveErrorReadOnly * Fired when the user tries to save page but the database is locked */ /** * @event loadError */ /** * @event showChangesError */ /** * @event serializeError */ /** * @event serializeComplete * Fired when serialization is complete */ /* Static Properties */ /** * @inheritdoc */ ve.init.mw.ArticleTarget.static.name = 'article'; /** * Tracking name of target class. Used by ArticleTargetEvents to identify which target we are tracking. * * @static * @property {string} * @inheritable */ ve.init.mw.ArticleTarget.static.trackingName = 'mwTarget'; /** * @inheritdoc */ ve.init.mw.ArticleTarget.static.integrationType = 'page'; /** * @inheritdoc */ ve.init.mw.ArticleTarget.static.platformType = 'other'; /* Methods */ /** * @inheritdoc */ ve.init.mw.ArticleTarget.prototype.setDefaultMode = function () { var oldDefaultMode = this.defaultMode; // Parent method ve.init.mw.ArticleTarget.super.prototype.setDefaultMode.apply( this, arguments ); if ( this.defaultMode !== oldDefaultMode ) { this.updateTabs( true ); if ( mw.libs.ve.setEditorPreference ) { // only set up by DAT.init mw.libs.ve.setEditorPreference( this.defaultMode === 'visual' ? 'visualeditor' : 'wikitext' ); } } }; /** * Update state of editing tabs * * @param {boolean} editing Whether the editor is loaded. */ ve.init.mw.ArticleTarget.prototype.updateTabs = function ( editing ) { var tab; // Deselect current mode (e.g. "view" or "history"). In skins like monobook that don't have // separate tab sections for content actions and namespaces the below is a no-op. $( '#p-views' ).find( 'li.selected' ).removeClass( 'selected' ); if ( editing ) { if ( this.section === 'new' ) { tab = 'addsection'; } else if ( $( '#ca-ve-edit' ).length ) { if ( this.getDefaultMode() === 'visual' ) { tab = 've-edit'; } else { tab = 'edit'; } } else { // Single edit tab tab = 'edit'; } } else { tab = 'view'; } $( '#ca-' + tab ).addClass( 'selected' ); }; /** * Handle response to a successful load request. * * This method is called within the context of a target instance. If successful the DOM from the * server will be parsed, stored in {this.doc} and then {this.documentReady} will be called. * * @method * @param {Object} response API response data * @param {string} status Text status message */ ve.init.mw.ArticleTarget.prototype.loadSuccess = function ( response ) { var i, len, linkData, aboutDoc, docRevId, docRevIdMatches, $checkboxes, defaults, target = this, data = response ? ( response.visualeditor || response.visualeditoredit ) : null; if ( !data || typeof data.content !== 'string' ) { this.loadFail( 've-api', 'No HTML content in response from server' ); } else { ve.track( 'trace.parseResponse.enter' ); this.originalHtml = data.content; this.etag = data.etag; this.fromEditedState = data.fromEditedState; this.switched = data.switched || 'wteswitched' in new mw.Uri( location.href ).query; this.doc = this.parseDocument( this.originalHtml, this.getDefaultMode() ); this.remoteNotices = ve.getObjectValues( data.notices ); this.protectedClasses = data.protectedClasses; this.baseTimeStamp = data.basetimestamp; this.startTimeStamp = data.starttimestamp; this.revid = data.oldid; this.checkboxes = data.checkboxes; this.$templatesUsed = $( data.templates ); aboutDoc = this.doc.documentElement.getAttribute( 'about' ); if ( aboutDoc ) { docRevIdMatches = aboutDoc.match( /revision\/([0-9]*)$/ ); if ( docRevIdMatches.length >= 2 ) { docRevId = parseInt( docRevIdMatches[ 1 ] ); } } if ( docRevId && docRevId !== this.revid ) { if ( this.retriedRevIdConflict ) { // Retried already, just error the second time. this.loadFail( 've-api', 'Revision IDs (doc=' + docRevId + ',api=' + this.revid + ') ' + 'returned by server do not match' ); } else { this.retriedRevIdConflict = true; // TODO this retries both requests, in RESTbase mode we should only retry // the request that gave us the lower revid this.loading = false; // HACK: Load with explicit revid to hopefully prevent this from happening again this.requestedRevId = Math.max( docRevId, this.revid ); this.load(); } return; } else { // Set this to false after a successful load, so we don't immediately give up // if a subsequent load mismatches again this.retriedRevIdConflict = false; } // Populate link cache if ( data.links ) { // Format from the API: { missing: [titles], known: 1|[titles] } // Format expected by LinkCache: { title: { missing: true|false } } linkData = {}; for ( i = 0, len = data.links.missing.length; i < len; i++ ) { linkData[ data.links.missing[ i ] ] = { missing: true }; } if ( data.links.known === 1 ) { // Set back to false by surfaceReady() ve.init.platform.linkCache.setAssumeExistence( true ); } else { for ( i = 0, len = data.links.known.length; i < len; i++ ) { linkData[ data.links.known[ i ] ] = { missing: false }; } } ve.init.platform.linkCache.setMissing( linkData ); } ve.track( 'trace.parseResponse.exit' ); // Everything worked, the page was loaded, continue initializing the editor this.documentReady( this.doc ); } this.checkboxFields = []; this.checkboxesByName = {}; this.$otherFields = $( [] ); if ( [ 'edit', 'submit' ].indexOf( mw.util.getParamValue( 'action' ) ) !== -1 ) { $( '#content #firstHeading' ).text( mw.Title.newFromText( mw.config.get( 'wgPageName' ) ).getPrefixedText() ); } if ( this.checkboxes ) { defaults = {}; $( '.editCheckboxes input' ).each( function () { defaults[ this.name ] = this.checked; } ); $checkboxes = $( '
' + ve.init.platform.getParsedMessage( msgKey ) + '
'; } ) ); this.loading = false; this.edited = this.fromEditedState; // Parent method ve.init.mw.ArticleTarget.super.prototype.documentReady.apply( this, arguments ); }; /** * @inheritdoc */ ve.init.mw.ArticleTarget.prototype.surfaceReady = function () { var name, i, triggers, accessKeyPrefix = $.fn.updateTooltipAccessKeys.getAccessKeyPrefix().replace( /-/g, '+' ), accessKeyModifiers = new ve.ui.Trigger( accessKeyPrefix + '-' ).modifiers; // loadSuccess() may have called setAssumeExistence( true ); ve.init.platform.linkCache.setAssumeExistence( false ); this.getSurface().getModel().connect( this, { history: 'updateToolbarSaveButtonState' } ); this.restoreEditSection(); // Iterate over the trigger registry and resolve any access key conflicts for ( name in ve.ui.triggerRegistry.registry ) { triggers = ve.ui.triggerRegistry.registry[ name ]; for ( i = 0; i < triggers.length; i++ ) { if ( ve.compare( triggers[ i ].modifiers, accessKeyModifiers ) ) { this.disableAccessKey( triggers[ i ].primary ); } } } // Parent method ve.init.mw.ArticleTarget.super.prototype.surfaceReady.apply( this, arguments ); }; /** * Disable an access key by removing the attribute from any element containing it * * @param {string} key Access key */ ve.init.mw.ArticleTarget.prototype.disableAccessKey = function ( key ) { $( '[accesskey=' + key + ']' ).each( function () { var $this = $( this ); $this .attr( 'data-old-accesskey', $this.attr( 'accesskey' ) ) .removeAttr( 'accesskey' ); } ); }; /** * Re-enable all access keys */ ve.init.mw.ArticleTarget.prototype.restoreAccessKeys = function () { $( '[data-old-accesskey]' ).each( function () { var $this = $( this ); $this .attr( 'accesskey', $this.attr( 'data-old-accesskey' ) ) .removeAttr( 'data-old-accesskey' ); } ); }; /** * Handle an unsuccessful load request. * * This method is called within the context of a target instance. * * @method * @param {string} code Error type text from mw.Api * @param {Object|string} errorDetails Either an object containing xhr, textStatus and exception keys, or a string. * @fires loadError */ ve.init.mw.ArticleTarget.prototype.loadFail = function () { this.loading = false; this.emit( 'loadError' ); }; /** * Handle a successful save request. * * This method is called within the context of a target instance. * * @method * @param {HTMLDocument} doc HTML document we tried to save * @param {Object} saveData Options that were used * @param {Object} response Response data * @param {string} status Text status message */ ve.init.mw.ArticleTarget.prototype.saveSuccess = function ( doc, saveData, response ) { var data = response.visualeditoredit; this.saving = false; if ( !data ) { this.saveFail( doc, saveData, null, 'Invalid response from server', response ); } else if ( data.result !== 'success' ) { // Note, this could be any of db failure, hookabort, badtoken or even a captcha this.saveFail( doc, saveData, null, 'Save failure', response ); } else if ( typeof data.content !== 'string' ) { this.saveFail( doc, saveData, null, 'Invalid HTML content in response from server', response ); } else { this.saveComplete( data.content, data.categorieshtml, data.newrevid, data.isRedirect, data.displayTitleHtml, data.lastModified, data.contentSub, data.modules, data.jsconfigvars ); } }; /** * Handle successful DOM save event. * * @method * @param {string} html Rendered page HTML from server * @param {string} categoriesHtml Rendered categories HTML from server * @param {number} newid New revision id, undefined if unchanged * @param {boolean} isRedirect Whether this page is a redirect or not * @param {string} displayTitle What HTML to show as the page title * @param {Object} lastModified Object containing user-formatted date * and time strings, or undefined if we made no change. * @param {string} contentSub HTML to show as the content subtitle * @param {Array} modules The modules to be loaded on the page * @param {Object} jsconfigvars The mw.config values needed on the page * @fires save */ ve.init.mw.ArticleTarget.prototype.saveComplete = function () { this.editSummaryValue = null; this.initialEditSummary = null; this.saveDeferred.resolve(); this.emit( 'save' ); }; /** * Handle an unsuccessful save request. * * @method * @param {HTMLDocument} doc HTML document we tried to save * @param {Object} saveData Options that were used * @param {Object} jqXHR * @param {string} status Text status message * @param {Object|null} data API response data */ ve.init.mw.ArticleTarget.prototype.saveFail = function ( doc, saveData, jqXHR, status, data ) { var editApi, target = this; this.saving = false; this.pageDeletedWarning = false; // Handle empty response if ( !data ) { this.saveErrorEmpty(); return; } editApi = data && data.visualeditoredit && data.visualeditoredit.edit; // Handle spam blacklist error (either from core or from Extension:SpamBlacklist) if ( editApi && editApi.spamblacklist ) { this.saveErrorSpamBlacklist( editApi ); return; } // Handle warnings/errors from Extension:AbuseFilter // TODO: Move this to a plugin if ( editApi && editApi.info && editApi.info.indexOf( 'Hit AbuseFilter:' ) === 0 && editApi.warning ) { this.saveErrorAbuseFilter( editApi ); return; } // Handle token errors if ( data.error && data.error.code === 'badtoken' ) { this.refreshEditToken().done( function ( userChanged ) { // target.editToken has been refreshed if ( userChanged ) { target.saveErrorBadToken( mw.user.isAnon() ? null : mw.user.getName(), false ); } else { // New session is the same user still; retry target.emit( 'saveErrorBadToken', true ); target.save( doc, saveData ); } } ).fail( function () { target.saveErrorBadToken( null, true ); } ); return; } else if ( data.error && data.error.code === 'editconflict' ) { this.editConflict(); return; } else if ( data.error && data.error.code === 'pagedeleted' ) { this.saveErrorPageDeleted(); return; } else if ( data.error && data.error.code === 'titleblacklist-forbidden' ) { this.saveErrorTitleBlacklist(); return; } else if ( data.error && data.error.code === 'hookaborted' ) { this.saveErrorHookAborted(); return; } else if ( data.error && data.error.code === 'readonly' ) { this.saveErrorReadOnly( data.error.readonlyreason ); return; } // Handle captcha // Captcha "errors" usually aren't errors. We simply don't know about them ahead of time, // so we save once, then (if required) we get an error with a captcha back and try again after // the user solved the captcha. // TODO: ConfirmEdit API is horrible, there is no reliable way to know whether it is a "math", // "question" or "fancy" type of captcha. They all expose differently named properties in the // API for different things in the UI. At this point we only support the SimpleCaptcha and FancyCaptcha // which we very intuitively detect by the presence of a "url" property. if ( editApi && editApi.captcha && ( editApi.captcha.url || editApi.captcha.type === 'simple' || editApi.captcha.type === 'math' || editApi.captcha.type === 'question' ) ) { this.saveErrorCaptcha( editApi ); return; } // Handle (other) unknown and/or unrecoverable errors this.saveErrorUnknown( editApi, data ); }; /** * Refresh our stored edit/csrf token * * This should be called in response to a badtoken error, to resolve whether the * token was expired / the user changed. If the user did change, this updates * the current user. * * @return {jQuery.Promise} Promise resolved with whether we switched users */ ve.init.mw.ArticleTarget.prototype.refreshEditToken = function () { var api = new mw.Api(), deferred = $.Deferred(), target = this; api.get( { action: 'query', meta: 'tokens|userinfo', type: 'csrf' } ) .done( function ( data ) { var userInfo = data.query && data.query.userinfo, editToken = data.query && data.query.tokens && data.query.tokens.csrftoken, isAnon = mw.user.isAnon(); if ( userInfo && editToken ) { target.editToken = editToken; if ( ( isAnon && userInfo.anon !== undefined ) || // Comparing id instead of name to protect against possible // normalisation and against case where the user got renamed. mw.config.get( 'wgUserId' ) === userInfo.id ) { // New session is the same user still deferred.resolve( false ); } else { // The now current session is a different user if ( userInfo.anon !== undefined ) { // New session is an anonymous user mw.config.set( { // wgUserId is unset for anonymous users, not set to null wgUserId: undefined, // wgUserName is explicitly set to null for anonymous users, // functions like mw.user.isAnon rely on this. wgUserName: null } ); } else { // New session is a different user mw.config.set( { wgUserId: userInfo.id, wgUserName: userInfo.name } ); } deferred.resolve( true ); } } else { deferred.reject(); } } ) .fail( function () { deferred.reject(); } ); return deferred.promise(); }; /** * Handle a successful show changes request. * * @method * @param {Object} response API response data * @param {string} status Text status message */ ve.init.mw.ArticleTarget.prototype.showChangesSuccess = function ( response ) { var data = response.visualeditoredit; this.diffing = false; if ( !data && !response.error ) { this.showChangesFail( null, 'Invalid response from server', null ); } else if ( response.error ) { this.showChangesFail( null, 'Unsuccessful request: ' + response.error.info, null ); } else if ( data.result === 'nochanges' ) { this.noChanges(); } else if ( data.result !== 'success' ) { this.showChangesFail( null, 'Failed request: ' + data.result, null ); } else if ( typeof data.diff !== 'string' ) { this.showChangesFail( null, 'Invalid HTML content in response from server', null ); } else { this.showChangesDiff( data.diff ); } }; /** * Show changes diff HTML * * @param {string} diffHtml Diff HTML * @fires showChanges */ ve.init.mw.ArticleTarget.prototype.showChangesDiff = function ( diffHtml ) { this.emit( 'showChanges' ); // Invalidate the viewer diff on next change this.getSurface().getModel().getDocument().once( 'transact', this.saveDialog.clearDiff.bind( this.saveDialog ) ); this.saveDialog.setDiffAndReview( diffHtml ); }; /** * Handle errors during showChanges action. * * @method * @this ve.init.mw.ArticleTarget * @param {Object} jqXHR * @param {string} status Text status message * @param {Mixed} error HTTP status text * @fires showChangesError */ ve.init.mw.ArticleTarget.prototype.showChangesFail = function ( jqXHR, status ) { this.diffing = false; this.emit( 'showChangesError' ); OO.ui.alert( ve.msg( 'visualeditor-differror', status ) ); this.saveDialog.popPending(); }; /** * Show an save process error message * * @method * @param {string|jQuery|Node[]} msg Message content (string of HTML, jQuery object or array of * Node objects) * @param {boolean} [allowReapply=true] Whether or not to allow the user to reapply. * Reset when swapping panels. Assumed to be true unless explicitly set to false. * @param {boolean} [warning=false] Whether or not this is a warning. */ ve.init.mw.ArticleTarget.prototype.showSaveError = function ( msg, allowReapply, warning ) { this.saveDeferred.reject( [ new OO.ui.Error( msg, { recoverable: allowReapply, warning: warning } ) ] ); }; /** * Handle general save error * * @method * @fires saveErrorEmpty */ ve.init.mw.ArticleTarget.prototype.saveErrorEmpty = function () { this.showSaveError( ve.msg( 'visualeditor-saveerror', 'Empty server response' ), false /* prevents reapply */ ); this.emit( 'saveErrorEmpty' ); }; /** * Handle spam blacklist error * * @method * @param {Object} editApi * @fires saveErrorSpamBlacklist */ ve.init.mw.ArticleTarget.prototype.saveErrorSpamBlacklist = function ( editApi ) { this.showSaveError( $( $.parseHTML( editApi.sberrorparsed ) ), false // prevents reapply ); this.emit( 'saveErrorSpamBlacklist' ); }; /** * Handel abuse filter error * * @method * @param {Object} editApi * @fires saveErrorAbuseFilter */ ve.init.mw.ArticleTarget.prototype.saveErrorAbuseFilter = function ( editApi ) { this.showSaveError( $( $.parseHTML( editApi.warning ) ) ); // Don't disable the save button. If the action is not disallowed the user may save the // edit by pressing Save again. The AbuseFilter API currently has no way to distinguish // between filter triggers that are and aren't disallowing the action. this.emit( 'saveErrorAbuseFilter' ); }; /** * Handle title blacklist save error * * @method * @fires saveErrorTitleBlacklist */ ve.init.mw.ArticleTarget.prototype.saveErrorTitleBlacklist = function () { this.showSaveError( mw.msg( 'visualeditor-saveerror-titleblacklist' ) ); this.emit( 'saveErrorTitleBlacklist' ); }; /** * Handle hook abort save error * * @method * @fires saveErrorHookAborted */ ve.init.mw.ArticleTarget.prototype.saveErrorHookAborted = function () { this.showSaveError( mw.msg( 'visualeditor-saveerror-hookaborted' ) ); this.emit( 'saveErrorHookAborted' ); }; /** * Handle token fetch indicating another user is logged in, and token fetch errors. * * @method * @param {string|null} username Name of newly logged-in user, or null if anonymous * @param {boolean} [error=false] Whether there was an error trying to figure out who we're logged in as * @fires saveErrorBadToken * @fires saveErrorNewUser */ ve.init.mw.ArticleTarget.prototype.saveErrorBadToken = function ( username, error ) { var userMsg, $msg = $( document.createTextNode( mw.msg( 'visualeditor-savedialog-error-badtoken' ) + ' ' ) ); if ( error ) { this.emit( 'saveErrorBadToken', false ); $msg = $msg.add( document.createTextNode( mw.msg( 'visualeditor-savedialog-identify-trylogin' ) ) ); } else { this.emit( 'saveErrorNewUser' ); if ( username === null ) { userMsg = 'visualeditor-savedialog-identify-anon'; } else { userMsg = 'visualeditor-savedialog-identify-user'; } $msg = $msg.add( mw.message( userMsg, username ).parseDom() ); } this.showSaveError( $msg ); }; /** * Handle unknown save error * * @method * @param {Object} editApi * @param {Object|null} data API response data * @fires saveErrorUnknown */ ve.init.mw.ArticleTarget.prototype.saveErrorUnknown = function ( editApi, data ) { var errorMsg = ( editApi && editApi.info ) || ( data && data.error && data.error.info ), errorCode = ( editApi && editApi.code ) || ( data && data.error && data.error.code ), unknown = 'Unknown error'; if ( data.xhr && data.xhr.status !== 200 ) { unknown += ', HTTP status ' + data.xhr.status; } this.showSaveError( $( document.createTextNode( errorMsg || errorCode || unknown ) ), false // prevents reapply ); this.emit( 'saveErrorUnknown', errorCode || errorMsg || unknown ); }; /** * Handle captcha error * * @method * @param {Object} editApi * @fires saveErrorCaptcha */ ve.init.mw.ArticleTarget.prototype.saveErrorCaptcha = function ( editApi ) { var $captchaDiv = $( '