mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-11-30 00:55:00 +00:00
c440a3576f
Can't support it in visual mode, since there's no new-section handling at all. Bug: T51622 Change-Id: I763d5fff156e9ef3893bd882fab1dc2d47121728
2314 lines
69 KiB
JavaScript
2314 lines
69 KiB
JavaScript
/*!
|
|
* 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 {Object} [config] Configuration options
|
|
*/
|
|
ve.init.mw.ArticleTarget = function VeInitMwArticleTarget( 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.originalDmDoc = null;
|
|
this.toolbarSaveButton = null;
|
|
this.pageName = mw.config.get( 'wgRelevantPageName' );
|
|
this.pageExists = mw.config.get( 'wgRelevantArticleId', 0 ) !== 0;
|
|
this.toolbarScrollOffset = mw.config.get( 'wgVisualEditorToolbarScrollOffset', 0 );
|
|
// A workaround, as default URI does not get updated after pushState (bug 72334)
|
|
this.currentUri = new mw.Uri( location.href );
|
|
this.section = null;
|
|
this.sectionTitle = null;
|
|
this.editSummaryValue = null;
|
|
this.initialEditSummary = null;
|
|
|
|
this.$templatesUsed = null;
|
|
this.checkboxFields = null;
|
|
this.checkboxesByName = 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 = mw.config.get( 'wgRevisionId' );
|
|
this.currentRevisionId = mw.config.get( 'wgCurRevisionId' );
|
|
this.revid = this.requestedRevId || this.currentRevisionId;
|
|
|
|
this.restoring = !!this.requestedRevId && this.requestedRevId !== this.currentRevisionId;
|
|
this.pageDeletedWarning = false;
|
|
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';
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.documentCommands = ve.init.mw.ArticleTarget.super.static.documentCommands.concat( [
|
|
// Make save commands triggerable from anywhere
|
|
'showSave',
|
|
'showChanges',
|
|
'showPreview',
|
|
'showMinoredit',
|
|
'showWatchthis'
|
|
] );
|
|
|
|
/* 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 = $( '#ca-addsection' );
|
|
} else if ( $( '#ca-ve-edit' ).length ) {
|
|
if ( this.getDefaultMode() === 'visual' ) {
|
|
$tab = $( '#ca-ve-edit' );
|
|
} else {
|
|
$tab = $( '#ca-edit' );
|
|
}
|
|
} else {
|
|
// Single edit tab
|
|
$tab = $( '#ca-edit' );
|
|
}
|
|
} else {
|
|
$tab = $( '#ca-view' );
|
|
}
|
|
$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,
|
|
name, options, accesskey, title, $label, checkbox,
|
|
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.constructor.static.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.checkboxesDef = data.checkboxesDef;
|
|
mw.messages.set( data.checkboxesMessages );
|
|
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 = {};
|
|
if ( [ 'edit', 'submit' ].indexOf( mw.util.getParamValue( 'action' ) ) !== -1 ) {
|
|
$( '#firstHeading' ).text(
|
|
mw.Title.newFromText( mw.config.get( 'wgPageName' ) ).getPrefixedText()
|
|
);
|
|
}
|
|
|
|
if ( this.checkboxesDef ) {
|
|
for ( name in this.checkboxesDef ) {
|
|
options = this.checkboxesDef[ name ];
|
|
|
|
accesskey = null;
|
|
title = null;
|
|
if ( options.tooltip ) {
|
|
accesskey = mw.message( 'accesskey-' + options.tooltip ).text();
|
|
title = mw.message( 'tooltip-' + options.tooltip ).text();
|
|
}
|
|
if ( options[ 'title-message' ] ) {
|
|
title = mw.message( options[ 'title-message' ] ).text();
|
|
}
|
|
$label = $( '<span>' ).append( mw.message( options[ 'label-message' ] ).parseDom() );
|
|
ve.targetLinksToNewWindow( $label[ 0 ] );
|
|
|
|
checkbox = new OO.ui.CheckboxInputWidget( {
|
|
accessKey: accesskey,
|
|
selected: options.default,
|
|
classes: [ 've-ui-mwSaveDialog-checkbox-' + name ]
|
|
} );
|
|
|
|
this.checkboxFields.push(
|
|
new OO.ui.FieldLayout( checkbox, {
|
|
align: 'inline',
|
|
label: $label.contents(),
|
|
title: title
|
|
} )
|
|
);
|
|
this.checkboxesByName[ name ] = checkbox;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.documentReady = function () {
|
|
// We need to wait until documentReady as local notices may require special messages
|
|
this.editNotices = this.remoteNotices.concat(
|
|
this.localNoticeMessages.map( function ( msgKey ) {
|
|
return '<p>' + ve.init.platform.getParsedMessage( msgKey ) + '</p>';
|
|
} )
|
|
);
|
|
|
|
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, false, 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, false, null, 'Save failure', response );
|
|
} else if ( typeof data.content !== 'string' ) {
|
|
this.saveFail( doc, saveData, false, 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 {boolean} wasRetry Whether this was a retry after a 'badtoken' error
|
|
* @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, wasRetry, 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' ) {
|
|
if ( wasRetry ) {
|
|
this.saveErrorBadToken( null, true );
|
|
return;
|
|
}
|
|
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, true );
|
|
}
|
|
} ).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();
|
|
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 );
|
|
};
|
|
|
|
/**
|
|
* 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 $captchaImg, msg, question,
|
|
captchaData = editApi.captcha,
|
|
captchaInput = new OO.ui.TextInputWidget( { classes: [ 've-ui-saveDialog-captchaInput' ] } ),
|
|
$captchaDiv = $( '<div>' ),
|
|
$captchaParagraph = $( '<p>' ),
|
|
target = this;
|
|
|
|
function onCaptchaLoad() {
|
|
target.saveDialog.updateSize();
|
|
captchaInput.focus();
|
|
captchaInput.scrollElementIntoView();
|
|
}
|
|
|
|
// Save when pressing 'Enter' in captcha field as it is single line.
|
|
captchaInput.on( 'enter', function () {
|
|
target.saveDialog.executeAction( 'save' );
|
|
} );
|
|
|
|
this.captcha = {
|
|
input: captchaInput,
|
|
id: captchaData.id
|
|
};
|
|
$captchaDiv.append( $captchaParagraph );
|
|
$captchaParagraph.append(
|
|
$( '<strong>' ).text( mw.msg( 'captcha-label' ) ),
|
|
document.createTextNode( mw.msg( 'colon-separator' ) )
|
|
);
|
|
|
|
if ( captchaData.url ) {
|
|
// FancyCaptcha
|
|
// Based on FancyCaptcha::getFormInformation() (https://git.io/v6mml) and
|
|
// ext.confirmEdit.fancyCaptcha.js in the ConfirmEdit extension.
|
|
mw.loader.load( 'ext.confirmEdit.fancyCaptcha' );
|
|
$captchaDiv.addClass( 'fancycaptcha-captcha-container' );
|
|
$captchaParagraph.append(
|
|
$( $.parseHTML( mw.message( 'fancycaptcha-edit' ).parse() ) )
|
|
.filter( 'a' ).attr( 'target', '_blank' ).end()
|
|
);
|
|
$captchaImg = $( '<img>' )
|
|
.attr( 'src', captchaData.url )
|
|
.addClass( 'fancycaptcha-image' )
|
|
.on( 'load', onCaptchaLoad );
|
|
$captchaDiv.append(
|
|
$captchaImg,
|
|
' ',
|
|
$( '<a>' ).addClass( 'fancycaptcha-reload' ).text( mw.msg( 'fancycaptcha-reload-text' ) )
|
|
);
|
|
} else {
|
|
if ( captchaData.type === 'simple' || captchaData.type === 'math' ) {
|
|
// SimpleCaptcha and MathCaptcha
|
|
msg = 'captcha-edit';
|
|
} else if ( captchaData.type === 'question' ) {
|
|
// QuestyCaptcha
|
|
msg = 'questycaptcha-edit';
|
|
}
|
|
|
|
if ( msg ) {
|
|
switch ( captchaData.mime ) {
|
|
case 'text/html':
|
|
question = $.parseHTML( captchaData.question );
|
|
// TODO: Search for images and wait for them to load
|
|
setTimeout( onCaptchaLoad );
|
|
break;
|
|
case 'text/plain':
|
|
question = document.createTextNode( captchaData.question );
|
|
setTimeout( onCaptchaLoad );
|
|
break;
|
|
}
|
|
$captchaParagraph.append( mw.message( msg ).parse(), '<br>', question );
|
|
}
|
|
}
|
|
|
|
$captchaDiv.append( captchaInput.$element );
|
|
|
|
// ProcessDialog's error system isn't great for this yet.
|
|
this.saveDialog.clearMessage( 'api-save-error' );
|
|
this.saveDialog.showMessage( 'api-save-error', $captchaDiv );
|
|
this.saveDialog.popPending();
|
|
|
|
this.emit( 'saveErrorCaptcha' );
|
|
};
|
|
|
|
/**
|
|
* Handle page deleted error
|
|
*
|
|
* @method
|
|
* @fires saveErrorPageDeleted
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveErrorPageDeleted = function () {
|
|
this.pageDeletedWarning = true;
|
|
this.showSaveError( mw.msg( 'visualeditor-recreate', mw.msg( 'ooui-dialog-process-continue' ) ), true, true );
|
|
this.emit( 'saveErrorPageDeleted' );
|
|
};
|
|
|
|
/**
|
|
* Handle read only error
|
|
*
|
|
* @method
|
|
* @fires saveErrorReadOnly
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveErrorReadOnly = function () {
|
|
this.showSaveError( $( $.parseHTML( mw.message( 'apierror-readonly' ).parse() ) ), true, true );
|
|
this.emit( 'saveErrorReadOnly' );
|
|
};
|
|
|
|
/**
|
|
* Handle an edit conflict
|
|
*
|
|
* @method
|
|
* @fires editConflict
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.editConflict = function () {
|
|
this.emit( 'editConflict' );
|
|
this.saveDialog.popPending();
|
|
this.saveDialog.swapPanel( 'conflict' );
|
|
};
|
|
|
|
/**
|
|
* Handle a successful serialize request.
|
|
*
|
|
* This method is called within the context of a target instance.
|
|
*
|
|
* @static
|
|
* @method
|
|
* @param {Object} response API response data
|
|
* @param {string} status Text status message
|
|
* @fires serializeComplete
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.serializeSuccess = function ( response ) {
|
|
var data = response.visualeditoredit;
|
|
this.serializing = false;
|
|
if ( !data && !response.error ) {
|
|
this.serializeFail( null, 'Invalid response from server', null );
|
|
} else if ( response.error ) {
|
|
this.serializeFail(
|
|
null, 'Unsuccessful request: ' + response.error.info, null
|
|
);
|
|
} else if ( data.result === 'error' ) {
|
|
this.serializeFail( null, 'Server error', null );
|
|
} else if ( typeof data.content !== 'string' ) {
|
|
this.serializeFail(
|
|
null, 'No Wikitext content in response from server', null
|
|
);
|
|
} else {
|
|
if ( typeof this.serializeCallback === 'function' ) {
|
|
this.serializeCallback( data.content );
|
|
this.emit( 'serializeComplete' );
|
|
delete this.serializeCallback;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle an unsuccessful serialize request.
|
|
*
|
|
* This method is called within the context of a target instance.
|
|
*
|
|
* @method
|
|
* @param {jqXHR|null} jqXHR
|
|
* @param {string} status Text status message
|
|
* @param {Mixed|null} error HTTP status text
|
|
* @fires serializeError
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.serializeFail = function () {
|
|
this.serializing = false;
|
|
this.emit( 'serializeError' );
|
|
};
|
|
|
|
/**
|
|
* Handle clicks on the review button in the save dialog.
|
|
*
|
|
* @method
|
|
* @fires saveReview
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogReview = function () {
|
|
if ( !this.saveDialog.hasDiff ) {
|
|
this.emit( 'saveReview' );
|
|
this.saveDialog.pushPending();
|
|
if ( this.pageExists ) {
|
|
// Has no callback, handled via target.showChangesDiff
|
|
this.showChanges( this.getDocToSave() );
|
|
} else {
|
|
this.serialize( this.getDocToSave(), this.onSaveDialogReviewComplete.bind( this ) );
|
|
}
|
|
} else {
|
|
this.saveDialog.swapPanel( 'review' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle clicks on the show preview button in the save dialog.
|
|
*
|
|
* @method
|
|
* @fires savePreview
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogPreview = function () {
|
|
var wikitext,
|
|
target = this;
|
|
|
|
if ( !this.saveDialog.$previewViewer.children().length ) {
|
|
this.emit( 'savePreview' );
|
|
this.saveDialog.pushPending();
|
|
|
|
wikitext = this.getDocToSave();
|
|
if ( this.sectionTitle && this.sectionTitle.getValue() ) {
|
|
wikitext = '== ' + this.sectionTitle.getValue() + ' ==\n\n' + wikitext;
|
|
}
|
|
|
|
new mw.Api().post( {
|
|
action: 'visualeditor',
|
|
paction: 'parsefragment',
|
|
page: mw.config.get( 'wgRelevantPageName' ),
|
|
wikitext: wikitext,
|
|
pst: true
|
|
} ).always( function ( response, details ) {
|
|
var html, doc,
|
|
baseDoc = target.getSurface().getModel().getDocument().getHtmlDocument();
|
|
|
|
if ( ve.getProp( response, 'visualeditor', 'result' ) === 'success' ) {
|
|
// Support: IE 11
|
|
// Wrap in a complete document for parseDocument() to not explode
|
|
html = '<!DOCTYPE html><html><body>' + response.visualeditor.content + '</body></html>';
|
|
doc = target.constructor.static.parseDocument( html, 'visual' );
|
|
target.saveDialog.showPreview( doc, baseDoc );
|
|
|
|
} else {
|
|
target.saveDialog.showPreview(
|
|
ve.msg(
|
|
'visualeditor-loaderror-message',
|
|
ve.getProp( details, 'error', 'info' ) || 'Failed to connect'
|
|
)
|
|
);
|
|
}
|
|
target.bindSaveDialogClearDiff();
|
|
} );
|
|
} else {
|
|
this.saveDialog.swapPanel( 'preview' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Clear the save dialog's diff cache when the document changes
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.bindSaveDialogClearDiff = function () {
|
|
// Invalidate the viewer wikitext on next change
|
|
this.getSurface().getModel().getDocument().once( 'transact',
|
|
this.saveDialog.clearDiff.bind( this.saveDialog )
|
|
);
|
|
if ( this.sectionTitle ) {
|
|
this.sectionTitle.once( 'change', this.saveDialog.clearDiff.bind( this.saveDialog ) );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle completed serialize request for diff views for new page creations.
|
|
*
|
|
* @method
|
|
* @param {string} wikitext
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogReviewComplete = function ( wikitext ) {
|
|
this.bindSaveDialogClearDiff();
|
|
this.saveDialog.setDiffAndReview(
|
|
$.Deferred().resolve( $( '<pre>' ).text( wikitext ) ).promise(),
|
|
this.getVisualDiffGeneratorPromise(),
|
|
this.getSurface().getModel().getDocument().getHtmlDocument()
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Get a visual diff object for the current document state
|
|
*
|
|
* @return {jQuery.Promise|null} Promise resolving with a generator for a ve.dm.VisualDiff visual diff, or null if not known
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getVisualDiffGeneratorPromise = function () {
|
|
var deferred, dmDoc,
|
|
target = this;
|
|
|
|
if ( this.getSurface().getMode() === 'source' ) {
|
|
return null;
|
|
}
|
|
deferred = $.Deferred();
|
|
dmDoc = this.getSurface().getModel().getDocument();
|
|
|
|
if ( !this.originalDmDoc ) {
|
|
if ( !this.fromEditedState ) {
|
|
this.originalDmDoc = this.constructor.static.createModelFromDom( this.doc, 'visual' );
|
|
} else {
|
|
mw.libs.ve.targetLoader.requestParsoidData( this.pageName, {
|
|
oldId: this.revid,
|
|
targetName: 'diff'
|
|
} ).then( function ( response ) {
|
|
var doc, data = response ? ( response.visualeditor || response.visualeditoredit ) : null;
|
|
if ( data && typeof data.content === 'string' ) {
|
|
doc = target.constructor.static.parseDocument( data.content, 'visual' );
|
|
target.originalDmDoc = target.constructor.static.createModelFromDom( doc, 'visual' );
|
|
deferred.resolve( function () {
|
|
return new ve.dm.VisualDiff( target.originalDmDoc, dmDoc );
|
|
} );
|
|
}
|
|
} );
|
|
}
|
|
}
|
|
if ( this.originalDmDoc ) {
|
|
deferred.resolve( function () {
|
|
return new ve.dm.VisualDiff( target.originalDmDoc, dmDoc );
|
|
} );
|
|
}
|
|
return deferred.promise();
|
|
};
|
|
|
|
/**
|
|
* Handle clicks on the resolve conflict button in the conflict dialog.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogResolveConflict = function () {
|
|
var fields = { wpSave: 1 };
|
|
|
|
if ( this.getSurface().getMode() === 'source' && this.section !== null ) {
|
|
fields.section = this.section;
|
|
}
|
|
// Get Wikitext from the DOM, and set up a submit call when it's done
|
|
this.serialize(
|
|
this.getDocToSave(),
|
|
this.submitWithSaveFields.bind( this, fields )
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Handle dialog retry events
|
|
* So we can handle trying to save again after page deletion warnings
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogRetry = function () {
|
|
if ( this.pageDeletedWarning ) {
|
|
this.recreating = true;
|
|
this.pageExists = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle dialog close events.
|
|
*
|
|
* @fires saveWorkflowEnd
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogClose = function () {
|
|
this.emit( 'saveWorkflowEnd' );
|
|
};
|
|
|
|
/**
|
|
* Get deflated HTML. This function is async because easy-deflate may not have finished loading yet.
|
|
*
|
|
* @param {HTMLDocument} newDoc Document to get HTML for
|
|
* @return {jQuery.Promise} Promise resolved with deflated HTML
|
|
* @see #getHtml
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.deflateHtml = function ( newDoc ) {
|
|
var html = this.getHtml( newDoc, this.doc );
|
|
return mw.loader.using( 'easy-deflate.deflate' )
|
|
.then( function () {
|
|
return EasyDeflate.deflate( html );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Load the editor.
|
|
*
|
|
* This method initiates an API request for the page data unless dataPromise is passed in,
|
|
* in which case it waits for that promise instead.
|
|
*
|
|
* @param {jQuery.Promise} [dataPromise] Promise for pending request, if any
|
|
* @return {boolean} Loading has been started
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.load = function ( dataPromise ) {
|
|
// Prevent duplicate requests
|
|
if ( this.loading ) {
|
|
return false;
|
|
}
|
|
this.events.trackActivationStart( mw.libs.ve.activationStart );
|
|
mw.libs.ve.activationStart = null;
|
|
|
|
this.loading = dataPromise || mw.libs.ve.targetLoader.requestPageData( this.getDefaultMode(), this.pageName, {
|
|
section: this.section,
|
|
oldId: this.requestedRevId,
|
|
targetName: this.constructor.static.name
|
|
} );
|
|
this.loading
|
|
.done( this.loadSuccess.bind( this ) )
|
|
.fail( this.loadFail.bind( this ) );
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Clear the state of this target, preparing it to be reactivated later.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.clearState = function () {
|
|
this.restoreAccessKeys();
|
|
this.clearPreparedCacheKey();
|
|
this.loading = false;
|
|
this.saving = false;
|
|
this.clearDiff();
|
|
this.serializing = false;
|
|
this.submitting = false;
|
|
this.baseTimeStamp = null;
|
|
this.startTimeStamp = null;
|
|
this.checkboxes = null;
|
|
this.doc = null;
|
|
this.originalDmDoc = null;
|
|
this.originalHtml = null;
|
|
this.section = null;
|
|
this.editNotices = [];
|
|
this.remoteNotices = [];
|
|
this.localNoticeMessages = [];
|
|
};
|
|
|
|
/**
|
|
* Switch to edit source mode
|
|
*
|
|
* @abstract
|
|
* @method
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.editSource = null;
|
|
|
|
/**
|
|
* Get a document to save, cached until the surface is modified
|
|
*
|
|
* The default implementation returns an HTMLDocument, but other targets
|
|
* may use a different document model (e.g. plain text for source mode).
|
|
*
|
|
* @return {Object} Document to save
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getDocToSave = function () {
|
|
var surface;
|
|
if ( !this.docToSave ) {
|
|
this.docToSave = this.createDocToSave();
|
|
// Cache clearing events
|
|
surface = this.getSurface();
|
|
surface.getModel().getDocument().once( 'transact', this.clearDocToSave.bind( this ) );
|
|
surface.once( 'destroy', this.clearDocToSave.bind( this ) );
|
|
}
|
|
return this.docToSave;
|
|
};
|
|
|
|
/**
|
|
* Create a document to save
|
|
*
|
|
* @return {Object} Document to save
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.createDocToSave = function () {
|
|
return this.getSurface().getDom();
|
|
};
|
|
|
|
/**
|
|
* Clear the document to save from the cache
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.clearDocToSave = function () {
|
|
this.docToSave = null;
|
|
this.clearPreparedCacheKey();
|
|
};
|
|
|
|
/**
|
|
* Serialize the current document and store the result in the serialization cache on the server.
|
|
*
|
|
* This function returns a promise that is resolved once serialization is complete, with the
|
|
* cache key passed as the first parameter.
|
|
*
|
|
* If there's already a request pending for the same (reference-identical) HTMLDocument, this
|
|
* function will not initiate a new request but will return the promise for the pending request.
|
|
* If a request for the same document has already been completed, this function will keep returning
|
|
* the same promise (which will already have been resolved) until clearPreparedCacheKey() is called.
|
|
*
|
|
* @param {HTMLDocument} doc Document to serialize
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.prepareCacheKey = function ( doc ) {
|
|
var xhr, deflated,
|
|
aborted = false,
|
|
start = ve.now(),
|
|
target = this;
|
|
|
|
if ( this.getSurface().getMode() === 'source' ) {
|
|
return;
|
|
}
|
|
|
|
if ( this.preparedCacheKeyPromise && this.preparedCacheKeyPromise.doc === doc ) {
|
|
return this.preparedCacheKeyPromise;
|
|
}
|
|
this.clearPreparedCacheKey();
|
|
|
|
this.preparedCacheKeyPromise = this.deflateHtml( doc )
|
|
.then( function ( deflatedHtml ) {
|
|
deflated = deflatedHtml;
|
|
if ( aborted ) {
|
|
return $.Deferred().reject();
|
|
}
|
|
xhr = new mw.Api().postWithToken( 'csrf',
|
|
{
|
|
action: 'visualeditoredit',
|
|
paction: 'serializeforcache',
|
|
html: deflatedHtml,
|
|
page: target.pageName,
|
|
oldid: target.revid,
|
|
etag: target.etag
|
|
},
|
|
{ contentType: 'multipart/form-data' }
|
|
);
|
|
return xhr.then(
|
|
function ( response ) {
|
|
var trackData = { duration: ve.now() - start };
|
|
if ( response.visualeditoredit && typeof response.visualeditoredit.cachekey === 'string' ) {
|
|
target.events.track( 'performance.system.serializeforcache', trackData );
|
|
return response.visualeditoredit.cachekey;
|
|
} else {
|
|
target.events.track( 'performance.system.serializeforcache.nocachekey', trackData );
|
|
return $.Deferred().reject();
|
|
}
|
|
},
|
|
function () {
|
|
target.events.track( 'performance.system.serializeforcache.fail', { duration: ve.now() - start } );
|
|
}
|
|
);
|
|
} )
|
|
.promise( {
|
|
abort: function () {
|
|
if ( xhr ) {
|
|
xhr.abort();
|
|
}
|
|
aborted = true;
|
|
},
|
|
getDeflatedHtml: function () {
|
|
return deflated;
|
|
},
|
|
doc: doc
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Get the prepared wikitext, if any. Same as prepareWikitext() but does not initiate a request
|
|
* if one isn't already pending or finished. Instead, it returns a rejected promise in that case.
|
|
*
|
|
* @param {HTMLDocument} doc Document to serialize
|
|
* @return {jQuery.Promise} Abortable promise, resolved with the cache key.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getPreparedCacheKey = function ( doc ) {
|
|
var deferred;
|
|
if ( this.preparedCacheKeyPromise && this.preparedCacheKeyPromise.doc === doc ) {
|
|
return this.preparedCacheKeyPromise;
|
|
}
|
|
deferred = $.Deferred();
|
|
deferred.reject();
|
|
return deferred.promise();
|
|
};
|
|
|
|
/**
|
|
* Clear the promise for the prepared wikitext cache key, and abort it if it's still in progress.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.clearPreparedCacheKey = function () {
|
|
if ( this.preparedCacheKeyPromise ) {
|
|
this.preparedCacheKeyPromise.abort();
|
|
this.preparedCacheKeyPromise = null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Try submitting an API request with a cache key for prepared wikitext, falling back to submitting
|
|
* HTML directly if there is no cache key present or pending, or if the request for the cache key
|
|
* fails, or if using the cache key fails with a badcachekey error.
|
|
*
|
|
* If options.token is set, this function will use mw.Api#post and let the caller handle badtoken
|
|
* errors. If options.token is not set, this function will use mw.Api#postWithToken which retries
|
|
* automatically when encountering a badtoken error. If you do not want the automatic retry behavior
|
|
* and want to control badtoken retries, you have to set options.token.
|
|
*
|
|
* @param {HTMLDocument|string} doc Document to submit or string in source mode
|
|
* @param {Object} options POST parameters to send. Do not include 'html', 'cachekey' or 'format'.
|
|
* @param {string} [eventName] If set, log an event when the request completes successfully. The
|
|
* full event name used will be 'performance.system.{eventName}.withCacheKey' or .withoutCacheKey
|
|
* depending on whether or not a cache key was used.
|
|
* @return {jQuery.Promise}
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.tryWithPreparedCacheKey = function ( doc, options, eventName ) {
|
|
var data, postData, preparedCacheKey,
|
|
target = this;
|
|
|
|
if ( this.getSurface().getMode() === 'source' ) {
|
|
data = {
|
|
wikitext: doc,
|
|
format: 'json'
|
|
};
|
|
postData = ve.extendObject( {}, options, data );
|
|
if ( this.section !== null ) {
|
|
postData.section = this.section;
|
|
}
|
|
if ( this.sectionTitle ) {
|
|
postData.sectiontitle = this.sectionTitle.getValue();
|
|
postData.summary = undefined;
|
|
}
|
|
if ( postData.token ) {
|
|
return new mw.Api().post( postData, { contentType: 'multipart/form-data' } );
|
|
}
|
|
return new mw.Api().postWithToken( 'csrf', postData, { contentType: 'multipart/form-data' } );
|
|
}
|
|
|
|
preparedCacheKey = this.getPreparedCacheKey( doc );
|
|
data = ve.extendObject( {}, options, { format: 'json' } );
|
|
|
|
function ajaxRequest( cachekey, isRetried ) {
|
|
var fullEventName,
|
|
start = ve.now(),
|
|
deflatePromise = $.Deferred().resolve().promise();
|
|
|
|
if ( typeof cachekey === 'string' ) {
|
|
data.cachekey = cachekey;
|
|
} else {
|
|
// Getting a cache key failed, fall back to sending the HTML
|
|
data.html = preparedCacheKey && preparedCacheKey.getDeflatedHtml && preparedCacheKey.getDeflatedHtml();
|
|
if ( !data.html ) {
|
|
deflatePromise = target.deflateHtml( doc ).then( function ( deflatedHtml ) {
|
|
data.html = deflatedHtml;
|
|
} );
|
|
}
|
|
// If using the cache key fails, we'll come back here with cachekey still set
|
|
delete data.cachekey;
|
|
}
|
|
return deflatePromise
|
|
.then( function () {
|
|
if ( data.token ) {
|
|
return new mw.Api().post( data, { contentType: 'multipart/form-data' } );
|
|
}
|
|
return new mw.Api().postWithToken( 'csrf', data, { contentType: 'multipart/form-data' } );
|
|
} )
|
|
.then(
|
|
function ( response, jqxhr ) {
|
|
var eventData = {
|
|
bytes: $.byteLength( jqxhr.responseText ),
|
|
duration: ve.now() - start
|
|
};
|
|
|
|
// Log data about the request if eventName was set
|
|
if ( eventName ) {
|
|
fullEventName = 'performance.system.' + eventName +
|
|
( typeof cachekey === 'string' ? '.withCacheKey' : '.withoutCacheKey' );
|
|
target.events.track( fullEventName, eventData );
|
|
}
|
|
return jqxhr;
|
|
},
|
|
function ( errorName, errorObject ) {
|
|
var responseText = ve.getProp( errorObject, 'xhr', 'responseText' ),
|
|
eventData;
|
|
if ( responseText ) {
|
|
eventData = {
|
|
bytes: $.byteLength( responseText ),
|
|
duration: ve.now() - start
|
|
};
|
|
|
|
if ( eventName ) {
|
|
if ( errorName === 'badcachekey' ) {
|
|
fullEventName = 'performance.system.' + eventName + '.badCacheKey';
|
|
} else {
|
|
fullEventName = 'performance.system.' + eventName + '.withoutCacheKey';
|
|
}
|
|
target.events.track( fullEventName, eventData );
|
|
}
|
|
}
|
|
// This cache key is evidently bad, clear it
|
|
target.clearPreparedCacheKey();
|
|
if ( !isRetried && errorName === 'badcachekey' ) {
|
|
// Try again without a cache key
|
|
return ajaxRequest( null, true );
|
|
} else {
|
|
// Failed twice in a row, must be some other error - let caller handle it.
|
|
// FIXME Can't just `return this` because all callers are broken.
|
|
return $.Deferred().reject( null, errorName, errorObject ).promise();
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// If we successfully get prepared wikitext, then invoke ajaxRequest() with the cache key,
|
|
// otherwise invoke it without.
|
|
return preparedCacheKey.then( ajaxRequest, ajaxRequest );
|
|
};
|
|
|
|
/**
|
|
* Handle the save dialog's save event
|
|
*
|
|
* Validates the inputs then starts the save process
|
|
*
|
|
* @param {jQuery.Deferred} saveDeferred Deferred object to resolve/reject when the save
|
|
* succeeds/fails.
|
|
* @fires saveInitiated
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogSave = function ( saveDeferred ) {
|
|
var saveOptions;
|
|
|
|
if ( this.deactivating ) {
|
|
return;
|
|
}
|
|
|
|
saveOptions = this.getSaveOptions();
|
|
|
|
// Reset any old captcha data
|
|
if ( this.captcha ) {
|
|
delete this.captcha;
|
|
}
|
|
|
|
if (
|
|
+mw.user.options.get( 'forceeditsummary' ) &&
|
|
( saveOptions.summary === '' || saveOptions.summary === this.initialEditSummary ) &&
|
|
!this.saveDialog.messages.missingsummary
|
|
) {
|
|
this.saveDialog.showMessage(
|
|
'missingsummary',
|
|
// Wrap manually since this core message already includes a bold "Warning:" label
|
|
$( '<p>' ).append( ve.init.platform.getParsedMessage( 'missingsummary' ) ),
|
|
{ wrap: false }
|
|
);
|
|
this.saveDialog.popPending();
|
|
} else {
|
|
this.emit( 'saveInitiated' );
|
|
this.startSave( saveOptions );
|
|
this.saveDeferred = saveDeferred;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Start the save process
|
|
*
|
|
* @param {Object} saveOptions Save options
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.startSave = function ( saveOptions ) {
|
|
this.save( this.getDocToSave(), saveOptions );
|
|
};
|
|
|
|
/**
|
|
* Get save form fields from the save dialog form.
|
|
*
|
|
* @return {Object} Form data for submission to the MediaWiki action=edit UI
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSaveFields = function () {
|
|
var name,
|
|
fields = {
|
|
wpSummary: this.saveDialog ? this.saveDialog.editSummaryInput.getValue() : ( this.editSummaryValue || this.initialEditSummary ),
|
|
wpCaptchaId: this.captcha && this.captcha.id,
|
|
wpCaptchaWord: this.captcha && this.captcha.input.getValue()
|
|
};
|
|
if ( this.recreating ) {
|
|
fields.wpRecreate = true;
|
|
}
|
|
|
|
for ( name in this.checkboxesByName ) {
|
|
if ( this.checkboxesByName[ name ].isSelected() ) {
|
|
fields[ name ] = this.checkboxesByName[ name ].getValue();
|
|
}
|
|
}
|
|
|
|
return fields;
|
|
};
|
|
|
|
/**
|
|
* Invoke #submit with the data from #getSaveFields
|
|
*
|
|
* @param {Object} fields Fields to add in addition to those from #getSaveFields
|
|
* @param {string} wikitext Wikitext to submit
|
|
* @return {boolean} Whether submission was started
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.submitWithSaveFields = function ( fields, wikitext ) {
|
|
return this.submit( wikitext, $.extend( this.getSaveFields(), fields ) );
|
|
};
|
|
|
|
/**
|
|
* Get edit API options from the save dialog form.
|
|
*
|
|
* @return {Object} Save options for submission to the MediaWiki API
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSaveOptions = function () {
|
|
var key,
|
|
options = this.getSaveFields(),
|
|
fieldMap = {
|
|
wpSummary: 'summary',
|
|
wpMinoredit: 'minor',
|
|
wpWatchthis: 'watch',
|
|
wpCaptchaId: 'captchaid',
|
|
wpCaptchaWord: 'captchaword'
|
|
};
|
|
|
|
for ( key in fieldMap ) {
|
|
if ( options[ key ] !== undefined ) {
|
|
options[ fieldMap[ key ] ] = options[ key ];
|
|
delete options[ key ];
|
|
}
|
|
}
|
|
|
|
return options;
|
|
};
|
|
|
|
/**
|
|
* Post DOM data to the Parsoid API.
|
|
*
|
|
* This method performs an asynchronous action and uses a callback function to handle the result.
|
|
*
|
|
* target.save( dom, { summary: 'test', minor: true, watch: false } );
|
|
*
|
|
* @method
|
|
* @param {HTMLDocument} doc Document to save
|
|
* @param {Object} options Saving options. All keys are passed through, including unrecognized ones.
|
|
* - {string} summary Edit summary
|
|
* - {boolean} minor Edit is a minor edit
|
|
* - {boolean} watch Watch the page
|
|
* @param {boolean} [isRetry=false] Whether this is a retry after a 'badtoken' error
|
|
* @return {boolean} Saving has been started
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.save = function ( doc, options, isRetry ) {
|
|
var data;
|
|
// Prevent duplicate requests
|
|
if ( this.saving ) {
|
|
return false;
|
|
}
|
|
|
|
data = ve.extendObject( {}, options, {
|
|
action: 'visualeditoredit',
|
|
paction: 'save',
|
|
page: this.pageName,
|
|
oldid: this.revid,
|
|
basetimestamp: this.baseTimeStamp,
|
|
starttimestamp: this.startTimeStamp,
|
|
etag: this.etag,
|
|
// Pass in token to prevent automatic badtoken retries
|
|
token: this.editToken
|
|
} );
|
|
|
|
this.saving = this.tryWithPreparedCacheKey( doc, data, 'save' )
|
|
.done( this.saveSuccess.bind( this, doc, data ) )
|
|
.fail( this.saveFail.bind( this, doc, data, !!isRetry ) );
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Show changes in the save dialog
|
|
*
|
|
* @param {Object} doc Document
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.showChanges = function ( doc ) {
|
|
var target = this;
|
|
// Invalidate the viewer diff on next change
|
|
this.getSurface().getModel().getDocument().once( 'transact', function () {
|
|
target.clearDiff();
|
|
} );
|
|
this.saveDialog.setDiffAndReview(
|
|
this.getWikitextDiffPromise( doc ),
|
|
this.getVisualDiffGeneratorPromise(),
|
|
this.getSurface().getModel().getDocument().getHtmlDocument()
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Clear all state associated with the diff
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.clearDiff = function () {
|
|
if ( this.saveDialog ) {
|
|
this.saveDialog.clearDiff();
|
|
}
|
|
this.wikitextDiffPromise = null;
|
|
};
|
|
|
|
/**
|
|
* Post DOM data to the Parsoid API to retrieve wikitext diff.
|
|
*
|
|
* @method
|
|
* @param {HTMLDocument} doc Document to compare against (via wikitext)
|
|
* @return {jQuery.Promise} Promise which resolves with the wikitext diff, or rejects with an error
|
|
* @fires showChanges
|
|
* @fires showChangesError
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getWikitextDiffPromise = function ( doc ) {
|
|
var target = this;
|
|
if ( !this.wikitextDiffPromise ) {
|
|
this.wikitextDiffPromise = this.tryWithPreparedCacheKey( doc, {
|
|
action: 'visualeditoredit',
|
|
paction: 'diff',
|
|
page: this.pageName,
|
|
oldid: this.revid,
|
|
etag: this.etag
|
|
}, 'diff' ).then( function ( response ) {
|
|
var data = response.visualeditoredit;
|
|
if ( !data && !response.error ) {
|
|
return $.Deferred().reject( 'Invalid response from server' ).promise();
|
|
} else if ( response.error ) {
|
|
return $.Deferred().reject( response.error.info ).promise();
|
|
} else if ( data.result === 'nochanges' ) {
|
|
target.emit( 'noChanges' );
|
|
return null;
|
|
} else if ( data.result !== 'success' ) {
|
|
return $.Deferred().reject( 'Failed request: ' + data.result ).promise();
|
|
} else if ( typeof data.diff !== 'string' ) {
|
|
return $.Deferred().reject( 'Invalid HTML content in response from server' ).promise();
|
|
} else {
|
|
return data.diff;
|
|
}
|
|
} );
|
|
this.wikitextDiffPromise
|
|
.done( this.emit.bind( this, 'showChanges' ) )
|
|
.fail( this.emit.bind( this, 'showChangesError' ) );
|
|
}
|
|
return this.wikitextDiffPromise;
|
|
};
|
|
|
|
/**
|
|
* Post wikitext to MediaWiki.
|
|
*
|
|
* This method performs a synchronous action and will take the user to a new page when complete.
|
|
*
|
|
* target.submit( wikitext, { wpSummary: 'test', wpMinorEdit: 1, wpSave: 1 } );
|
|
*
|
|
* @method
|
|
* @param {string} wikitext Wikitext to submit
|
|
* @param {Object} fields Other form fields to add (e.g. wpSummary, wpWatchthis, etc.). To actually
|
|
* save the wikitext, add { wpSave: 1 }. To go to the diff view, add { wpDiff: 1 }.
|
|
* @return {boolean} Submitting has been started
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.submit = function ( wikitext, fields ) {
|
|
var key, $form, params;
|
|
|
|
// Prevent duplicate requests
|
|
if ( this.submitting ) {
|
|
return false;
|
|
}
|
|
// Save DOM
|
|
this.submitting = true;
|
|
$form = $( '<form method="post" enctype="multipart/form-data" style="display: none;"></form>' );
|
|
params = ve.extendObject( {
|
|
format: 'text/x-wiki',
|
|
model: 'wikitext',
|
|
oldid: this.requestedRevId,
|
|
wpStarttime: this.startTimeStamp,
|
|
wpEdittime: this.baseTimeStamp,
|
|
wpTextbox1: wikitext,
|
|
wpEditToken: this.editToken
|
|
}, fields );
|
|
// Add params as hidden fields
|
|
for ( key in params ) {
|
|
$form.append( $( '<input>' ).attr( { type: 'hidden', name: key, value: params[ key ] } ) );
|
|
}
|
|
// Submit the form, mimicking a traditional edit
|
|
// Firefox requires the form to be attached
|
|
$form.attr( 'action', this.submitUrl ).appendTo( 'body' ).submit();
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Get Wikitext data from the Parsoid API.
|
|
*
|
|
* This method performs an asynchronous action and uses a callback function to handle the result.
|
|
*
|
|
* target.serialize(
|
|
* dom,
|
|
* function ( wikitext ) {
|
|
* // Do something with the loaded DOM
|
|
* }
|
|
* );
|
|
*
|
|
* @method
|
|
* @param {HTMLDocument} doc Document to serialize
|
|
* @param {Function} callback Function to call when complete, accepts error and wikitext arguments
|
|
* @return {boolean} Serializing has been started
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.serialize = function ( doc, callback ) {
|
|
// Prevent duplicate requests
|
|
if ( this.serializing ) {
|
|
return false;
|
|
}
|
|
this.serializeCallback = callback;
|
|
this.serializing = this.tryWithPreparedCacheKey( doc, {
|
|
action: 'visualeditoredit',
|
|
paction: 'serialize',
|
|
page: this.pageName,
|
|
oldid: this.revid,
|
|
etag: this.etag
|
|
}, 'serialize' )
|
|
.done( this.serializeSuccess.bind( this ) )
|
|
.fail( this.serializeFail.bind( this ) );
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Get list of edit notices.
|
|
*
|
|
* @return {Array} List of edit notices
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getEditNotices = function () {
|
|
return this.editNotices;
|
|
};
|
|
|
|
// FIXME: split out view specific functionality, emit to subclass
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.track = function ( name ) {
|
|
ve.track( name );
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.createSurface = function () {
|
|
// Parent method
|
|
var surface = ve.init.mw.ArticleTarget.super.prototype.createSurface.apply( this, arguments );
|
|
|
|
surface.$element.addClass( this.protectedClasses );
|
|
|
|
return surface;
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.setupToolbar = function () {
|
|
// Parent method
|
|
ve.init.mw.ArticleTarget.super.prototype.setupToolbar.apply( this, arguments );
|
|
|
|
this.setupToolbarSaveButton();
|
|
this.attachToolbarSaveButton();
|
|
|
|
if ( this.saveDialog ) {
|
|
this.editSummaryValue = this.saveDialog.editSummaryInput.getValue();
|
|
this.saveDialog.disconnect( this );
|
|
this.saveDialog = null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Getting the message for the toolbar / save dialog save / publish button
|
|
*
|
|
* @return {Function|string} An i18n message or resolveable function
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSaveButtonLabel = function () {
|
|
if ( mw.config.get( 'wgEditSubmitButtonLabelPublish' ) ) {
|
|
return OO.ui.deferMsg( !this.pageExists ? 'publishpage' : 'publishchanges' );
|
|
}
|
|
|
|
return OO.ui.deferMsg( !this.pageExists ? 'savearticle' : 'savechanges' );
|
|
};
|
|
|
|
/**
|
|
* Add content and event bindings to toolbar save button.
|
|
*
|
|
* @param {Object} [config] Configuration options for the button
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.setupToolbarSaveButton = function ( config ) {
|
|
if ( !this.toolbarSaveButton ) {
|
|
this.toolbarSaveButton = new OO.ui.ButtonWidget( ve.extendObject( {
|
|
label: this.getSaveButtonLabel(),
|
|
flags: [ 'progressive', 'primary' ],
|
|
disabled: !this.restoring
|
|
}, config ) );
|
|
|
|
// NOTE (phuedx, 2014-08-20): This class is used by the firsteditve guided
|
|
// tour to attach a guider to the "Save page" button.
|
|
this.toolbarSaveButton.$element.addClass( 've-ui-toolbar-saveButton' );
|
|
|
|
if ( ve.msg( 'accesskey-save' ) !== '-' && ve.msg( 'accesskey-save' ) !== '' ) {
|
|
// FlaggedRevs tries to use this - it's useless on VE pages because all that stuff gets hidden, but it will still conflict so get rid of it
|
|
this.elementsThatHadOurAccessKey = $( '[accesskey="' + ve.msg( 'accesskey-save' ) + '"]' ).removeAttr( 'accesskey' );
|
|
this.toolbarSaveButton.$button.attr( 'accesskey', ve.msg( 'accesskey-save' ) );
|
|
}
|
|
|
|
this.toolbarSaveButton.connect( this, { click: 'onToolbarSaveButtonClick' } );
|
|
}
|
|
this.updateToolbarSaveButtonState();
|
|
};
|
|
|
|
/**
|
|
* Add the save button to the user interface.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.attachToolbarSaveButton = function () {
|
|
this.toolbar.$actions.append( this.toolbarSaveButton.$element );
|
|
};
|
|
|
|
/**
|
|
* Re-evaluate whether the toolbar save button should be disabled or not.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.updateToolbarSaveButtonState = function () {
|
|
var isDisabled;
|
|
|
|
if ( !this.getSurface() ) {
|
|
// Called before we're attached, so meaningless; abandon for now
|
|
return;
|
|
}
|
|
|
|
this.edited = this.getSurface().getModel().hasBeenModified() || this.fromEditedState;
|
|
if ( this.sectionTitle ) {
|
|
this.edited = this.edited || this.sectionTitle.getValue() !== '';
|
|
}
|
|
// Disable the save button if we have no history
|
|
isDisabled = !this.edited && !this.restoring;
|
|
this.toolbarSaveButton.setDisabled( isDisabled );
|
|
mw.hook( 've.toolbarSaveButton.stateChanged' ).fire( isDisabled );
|
|
};
|
|
|
|
/**
|
|
* Handle clicks on the save button in the toolbar.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onToolbarSaveButtonClick = function () {
|
|
this.showSaveDialog();
|
|
};
|
|
|
|
/**
|
|
* Show a save dialog
|
|
*
|
|
* @param {string} [action] Window action to trigger after opening
|
|
* @param {string} [checkboxName] Checkbox to toggle after opening
|
|
*
|
|
* @fires saveWorkflowBegin
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.showSaveDialog = function ( action, checkboxName ) {
|
|
var checkbox,
|
|
target = this;
|
|
|
|
if ( !( this.edited || this.restoring ) ) {
|
|
return;
|
|
}
|
|
|
|
this.emit( 'saveWorkflowBegin' );
|
|
|
|
// Preload the serialization
|
|
this.prepareCacheKey( this.getDocToSave() );
|
|
|
|
// Get the save dialog
|
|
this.getSurface().getDialogs().getWindow( 'mwSave' ).done( function ( win ) {
|
|
var data, checked,
|
|
windowAction = ve.ui.actionFactory.create( 'window', target.getSurface() );
|
|
|
|
if ( !target.saveDialog ) {
|
|
target.saveDialog = win;
|
|
|
|
// Connect to save dialog
|
|
target.saveDialog.connect( target, {
|
|
save: 'onSaveDialogSave',
|
|
review: 'onSaveDialogReview',
|
|
preview: 'onSaveDialogPreview',
|
|
resolve: 'onSaveDialogResolveConflict',
|
|
retry: 'onSaveDialogRetry',
|
|
close: 'onSaveDialogClose'
|
|
} );
|
|
}
|
|
|
|
data = target.getSaveDialogOpeningData();
|
|
|
|
if (
|
|
( action === 'review' && !data.canReview ) ||
|
|
( action === 'preview' && !data.canPreview )
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if ( checkboxName && ( checkbox = target.checkboxesByName[ checkboxName ] ) ) {
|
|
checked = !checkbox.isSelected();
|
|
// Wait for native access key change to happen
|
|
setTimeout( function () {
|
|
checkbox.setSelected( checked );
|
|
} );
|
|
}
|
|
|
|
// When calling review/preview action, switch to those panels immediately
|
|
if ( action === 'review' || action === 'preview' ) {
|
|
data.initialPanel = action;
|
|
}
|
|
|
|
// Open the dialog
|
|
windowAction.open( 'mwSave', data, action );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Get opening data to pass to the save dialog
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSaveDialogOpeningData = function () {
|
|
var mode = this.getSurface().getMode();
|
|
return {
|
|
canPreview: mode === 'source',
|
|
canReview: !( mode === 'source' && this.section === 'new' ),
|
|
sectionTitle: this.sectionTitle && this.sectionTitle.getValue(),
|
|
saveButtonLabel: this.getSaveButtonLabel(),
|
|
checkboxFields: this.checkboxFields,
|
|
checkboxesByName: this.checkboxesByName
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Move the cursor in the editor to section specified by this.section.
|
|
* Do nothing if this.section is undefined.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.restoreEditSection = function () {
|
|
var headingText,
|
|
section,
|
|
surface = this.getSurface(),
|
|
mode = surface.getMode(),
|
|
surfaceView, $documentNode, $section, headingNode;
|
|
|
|
if ( this.section !== null && this.section !== 'new' && this.section !== 0 && this.section !== 'T-0' ) {
|
|
if ( mode === 'visual' ) {
|
|
// Get numerical part of section (strip 'T-'' if present)
|
|
section = this.section.toString().indexOf( 'T-' ) === 0 ? +this.section.slice( 2 ) : this.section;
|
|
surfaceView = surface.getView();
|
|
$documentNode = surfaceView.getDocument().getDocumentNode().$element;
|
|
// Find all headings including those inside templates, not just HeadingNodes
|
|
$section = $documentNode.find( 'h1, h2, h3, h4, h5, h6' )
|
|
// Ignore headings inside TOC
|
|
.filter( function () {
|
|
return $( this ).closest( '.ve-ui-mwTocWidget' ).length === 0;
|
|
} )
|
|
.eq( section - 1 );
|
|
headingNode = $section.data( 'view' );
|
|
|
|
if ( $section.length && new mw.Uri().query.summary === undefined ) {
|
|
// Due to interactions with Translate, strip out mw-
|
|
// editsection from the heading.
|
|
headingText = $section.clone().find( 'span.mw-editsection' ).remove().end().text();
|
|
}
|
|
|
|
if ( headingNode ) {
|
|
this.goToHeading( headingNode );
|
|
}
|
|
} else if ( mode === 'source' ) {
|
|
// With elements of extractSectionTitle + stripSectionName TODO:
|
|
// Arguably, we should just throw this through the API and then do
|
|
// the same extract-text pass we do in visual mode. Would save us
|
|
// having to think about wikitext here.
|
|
headingText = surface.getModel().getDocument().data.getText(
|
|
false,
|
|
surface.getModel().getDocument().getDocumentNode().children[ 0 ].getRange()
|
|
)
|
|
// Extract the title
|
|
.replace( /^\s*=+\s*(.*?)\s*=+\s*$/, '$1' )
|
|
// Remove links
|
|
.replace( /\[\[:?([^[|]+)\|([^[]+)\]\]/, '$2' )
|
|
.replace( /\[\[:?([^[]+)\|?\]\]/, '$1' )
|
|
.replace( new RegExp( '\\[(?:' + ve.init.platform.getUnanchoredExternalLinkUrlProtocolsRegExp().source + ')([^ ]+?) ([^\\[]+)\\]', 'i' ), '$3' )
|
|
// Cheap HTML removal
|
|
.replace( /<[^>]+?>/g, '' )
|
|
;
|
|
}
|
|
if ( headingText ) {
|
|
this.initialEditSummary =
|
|
'/* ' +
|
|
ve.graphemeSafeSubstring( headingText, 0, 244 ) +
|
|
' */ ';
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Move the cursor to a given heading and scroll to it.
|
|
*
|
|
* @method
|
|
* @param {ve.ce.HeadingNode} headingNode Heading node to scroll to
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.goToHeading = function ( headingNode ) {
|
|
var nextNode, offset,
|
|
target = this,
|
|
offsetNode = headingNode,
|
|
surface = this.getSurface(),
|
|
surfaceModel = surface.getModel(),
|
|
surfaceView = surface.getView(),
|
|
lastHeadingLevel = -1;
|
|
|
|
// Find next sibling which isn't a heading
|
|
while ( offsetNode instanceof ve.ce.HeadingNode && offsetNode.getModel().getAttribute( 'level' ) > lastHeadingLevel ) {
|
|
lastHeadingLevel = offsetNode.getModel().getAttribute( 'level' );
|
|
// Next sibling
|
|
nextNode = offsetNode.parent.children[ offsetNode.parent.children.indexOf( offsetNode ) + 1 ];
|
|
if ( !nextNode ) {
|
|
break;
|
|
}
|
|
offsetNode = nextNode;
|
|
}
|
|
offset = surfaceModel.getDocument().data.getNearestContentOffset(
|
|
offsetNode.getModel().getOffset(), 1
|
|
);
|
|
|
|
function scrollAndSetSelection() {
|
|
surfaceModel.setLinearSelection( new ve.Range( offset ) );
|
|
// Focussing the document triggers showSelection which calls scrollIntoView
|
|
// which uses a jQuery animation, so make sure this is aborted.
|
|
$( OO.ui.Element.static.getClosestScrollableContainer( surfaceView.$element[ 0 ] ) ).stop( true );
|
|
target.scrollToHeading( headingNode );
|
|
}
|
|
|
|
if ( surfaceView.isFocused() ) {
|
|
scrollAndSetSelection();
|
|
} else {
|
|
// onDocumentFocus is debounced, so wait for that to happen before setting
|
|
// the model selection, otherwise it will get reset
|
|
surfaceView.once( 'focus', scrollAndSetSelection );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Scroll to a given heading in the document.
|
|
*
|
|
* @method
|
|
* @param {ve.ce.HeadingNode} headingNode Heading node to scroll to
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.scrollToHeading = function ( headingNode ) {
|
|
var $window = $( OO.ui.Element.static.getWindow( this.$element ) );
|
|
|
|
$window.scrollTop( headingNode.$element.offset().top - this.getToolbar().$element.height() );
|
|
};
|
|
|
|
/**
|
|
* Show the beta dialog as needed
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.maybeShowWelcomeDialog = function () {
|
|
var usePrefs, prefSaysShow, urlSaysHide,
|
|
windowManager = this.getSurface().dialogs,
|
|
target = this,
|
|
welcomeDialogLocalStorageValue = null;
|
|
|
|
try {
|
|
welcomeDialogLocalStorageValue = localStorage.getItem( 've-beta-welcome-dialog' );
|
|
} catch ( e ) {}
|
|
|
|
this.welcomeDialogPromise = $.Deferred();
|
|
|
|
if ( mw.config.get( 'wgVisualEditorConfig' ).showBetaWelcome ) {
|
|
// Only use the preference value if the user is logged-in.
|
|
// If the user is anonymous, we can't save the preference
|
|
// after showing the dialog. And we don't intend to use this
|
|
// preference to influence anonymous users (use the config
|
|
// variable for that; besides the pref value would be stale if
|
|
// the wiki uses static html caching).
|
|
usePrefs = !mw.user.isAnon();
|
|
prefSaysShow = usePrefs && !mw.user.options.get( 'visualeditor-hidebetawelcome' );
|
|
urlSaysHide = 'vehidebetadialog' in new mw.Uri( location.href ).query;
|
|
|
|
if (
|
|
!urlSaysHide &&
|
|
(
|
|
prefSaysShow ||
|
|
(
|
|
!usePrefs &&
|
|
welcomeDialogLocalStorageValue === null &&
|
|
$.cookie( 've-beta-welcome-dialog' ) === null
|
|
)
|
|
)
|
|
) {
|
|
this.welcomeDialog = new mw.libs.ve.WelcomeDialog();
|
|
windowManager.addWindows( [ this.welcomeDialog ] );
|
|
windowManager.openWindow(
|
|
this.welcomeDialog,
|
|
{
|
|
switchable: this.constructor.static.trackingName !== 'mobile',
|
|
editor: this.getDefaultMode()
|
|
}
|
|
)
|
|
.closed.then( function ( data ) {
|
|
target.welcomeDialogPromise.resolve();
|
|
target.welcomeDialog = null;
|
|
// switchToWikitextEditor and switchToVisualEditor are actually
|
|
// only defined in subclasses :/
|
|
if ( data && data.action === 'switch-wte' ) {
|
|
// TODO: Make this work on mobile - right now we can only
|
|
// get away with it because the button which triggers this
|
|
// action is hidden on mobile
|
|
target.switchToWikitextEditor( true, true );
|
|
} else if ( data && data.action === 'switch-ve' ) {
|
|
target.switchToVisualEditor();
|
|
}
|
|
} );
|
|
} else {
|
|
this.welcomeDialogPromise.resolve();
|
|
}
|
|
|
|
if ( prefSaysShow ) {
|
|
new mw.Api().saveOption( 'visualeditor-hidebetawelcome', '1' );
|
|
mw.user.options.set( 'visualeditor-hidebetawelcome', '1' );
|
|
|
|
// No need to set a cookie every time for logged-in users that have already
|
|
// set the hidebetawelcome=1 preference, but only if this isn't a one-off
|
|
// view of the page via the hiding GET parameter.
|
|
} else if ( !usePrefs && !urlSaysHide ) {
|
|
try {
|
|
localStorage.setItem( 've-beta-welcome-dialog', 1 );
|
|
} catch ( e ) {
|
|
$.cookie( 've-beta-welcome-dialog', 1, { path: '/', expires: 30 } );
|
|
}
|
|
}
|
|
} else {
|
|
this.welcomeDialogPromise.reject();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Switches to the wikitext editor, either keeping (default) or discarding changes.
|
|
*
|
|
* @param {boolean} [discardChanges] Whether to discard changes or not.
|
|
* @param {boolean} [modified] Whether there were any changes at all.
|
|
* @param {boolean} [leaveVE] Leave VE, even if source mode is available
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.switchToWikitextEditor = function ( discardChanges, modified, leaveVE ) {
|
|
var dataPromise,
|
|
target = this;
|
|
|
|
// We may have this.section but VE is always full page at the moment
|
|
this.section = null;
|
|
|
|
if ( ve.init.target.isModeAvailable( 'source' ) && !leaveVE ) {
|
|
if ( discardChanges ) {
|
|
dataPromise = mw.libs.ve.targetLoader.requestPageData( 'source', this.pageName, {
|
|
section: this.section,
|
|
oldId: this.requestedRevId,
|
|
targetName: this.constructor.static.name
|
|
} ).then(
|
|
function ( response ) { return response; },
|
|
function () {
|
|
// TODO: Some sort of progress bar?
|
|
target.switchToWikitextEditor( discardChanges, modified, true );
|
|
// Keep everything else waiting so our error handler can do its business
|
|
return $.Deferred().promise();
|
|
}
|
|
);
|
|
} else {
|
|
this.serialize( this.getDocToSave() );
|
|
dataPromise = this.serializing.then( function ( response ) {
|
|
// HACK - add parameters the API doesn't provide for a VE->WT switch
|
|
var data = response.visualeditoredit;
|
|
data.etag = target.etag;
|
|
data.fromEditedState = modified;
|
|
data.notices = target.remoteNotices;
|
|
data.protectedClasses = target.protectedClasses;
|
|
data.basetimestamp = target.baseTimeStamp;
|
|
data.starttimestamp = target.startTimeStamp;
|
|
data.oldid = target.revid;
|
|
data.checkboxesDef = target.checkboxesDef;
|
|
return response;
|
|
} );
|
|
}
|
|
this.reloadSurface( 'source', dataPromise );
|
|
} else {
|
|
this.switchToFallbackWikitextEditor( discardChanges, modified );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Switches to the fallback wikitext editor, either keeping (default) or discarding changes.
|
|
*
|
|
* @param {boolean} [discardChanges] Whether to discard changes or not.
|
|
* @param {boolean} [modified] Whether there were any changes at all.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.switchToFallbackWikitextEditor = function () {
|
|
};
|
|
|
|
/**
|
|
* Switch to the visual editor.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.switchToVisualEditor = function () {
|
|
var dataPromise, windowManager, switchWindow,
|
|
target = this;
|
|
|
|
// Show a discard-only confirm dialog, and then reload the whole page, if:
|
|
if (
|
|
// * section editing in WT, as WT -> VE is not yet supported, or
|
|
this.section !== null ||
|
|
// * the server can't switch for us because that's not supported.
|
|
!mw.config.get( 'wgVisualEditorConfig' ).fullRestbaseUrl
|
|
) {
|
|
windowManager = new OO.ui.WindowManager();
|
|
switchWindow = new mw.libs.ve.SwitchConfirmDialog();
|
|
$( 'body' ).append( windowManager.$element );
|
|
windowManager.addWindows( [ switchWindow ] );
|
|
windowManager.openWindow( switchWindow, { mode: 'simple' } )
|
|
.closed.then( function ( data ) {
|
|
if ( data && data.action === 'discard' ) {
|
|
target.section = null;
|
|
target.reloadSurface( 'visual' );
|
|
}
|
|
windowManager.destroy();
|
|
} );
|
|
} else {
|
|
dataPromise = mw.libs.ve.targetLoader.requestParsoidData( this.pageName, {
|
|
oldId: this.revid,
|
|
targetName: this.constructor.static.name,
|
|
modified: this.edited,
|
|
wikitext: this.getDocToSave()
|
|
} );
|
|
|
|
this.reloadSurface( 'visual', dataPromise );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Switch to a different wikitext section
|
|
*
|
|
* @param {number|string|null} section New section, number, 'new' or null (whole document)
|
|
* @param {boolean} noConfirm Swtich without prompting (changes will be lost either way)
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.switchToWikitextSection = function ( section, noConfirm ) {
|
|
var promise,
|
|
target = this;
|
|
if ( section === this.section ) {
|
|
return;
|
|
}
|
|
if ( !noConfirm && this.edited && mw.user.options.get( 'useeditwarning' ) ) {
|
|
promise = OO.ui.confirm( mw.msg( 'visualeditor-viewpage-savewarning' ) );
|
|
} else {
|
|
promise = $.Deferred().resolve( true ).promise();
|
|
}
|
|
promise.then( function ( confirmed ) {
|
|
if ( confirmed ) {
|
|
target.section = section;
|
|
target.reloadSurface( 'source' );
|
|
target.updateTabs( true );
|
|
}
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Reload the target surface in the new editor mode
|
|
*
|
|
* @param {string} newMode New mode
|
|
* @param {jQuery.Promise} [dataPromise] Data promise, if any
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.reloadSurface = function ( newMode, dataPromise ) {
|
|
this.setDefaultMode( newMode );
|
|
this.clearDiff();
|
|
// Create progress - will be discarded when surface is destroyed.
|
|
this.getSurface().createProgress(
|
|
$.Deferred().promise(),
|
|
ve.msg( newMode === 'source' ? 'visualeditor-mweditmodesource-progress' : 'visualeditor-mweditmodeve-progress' ),
|
|
true /* non-cancellable */
|
|
);
|
|
this.load( dataPromise );
|
|
};
|