From: Timo Tijhof Date: Sun, 20 May 2018 13:39:47 +0000 (+0200) Subject: mediawiki.api: Merge modules into one X-Git-Tag: 1.34.0-rc.0~5363 X-Git-Url: http://git.cyclocoop.org//%27%40script%40/%27?a=commitdiff_plain;h=ecc812f06e7dff587b3f31dc18189adbf4616351;p=lhc%2Fweb%2Fwiklou.git mediawiki.api: Merge modules into one These are all quite tiny and not worth providing separately to the system as deliverable file bundles. Mark the other mediawiki.api.* modules as alias to 'mediawiki.api' for back-compat, with deprecation warning. Highlights: * Change mediawiki.api.edit.js to not use mw.user, because that causes a circular dependency, given mw.user also depends on mediawiki.api. Bug: T192623 Change-Id: I0afdc8ab50bc1354bb5099bf39923c07eab0b665 --- diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 62e3df88bf..470b9c3b57 100644 --- a/RELEASE-NOTES-1.32 +++ b/RELEASE-NOTES-1.32 @@ -112,6 +112,11 @@ because of Phabricator reports. in extending classes is deprecated. Extend related doSearch* methods instead. * CollationFa has been removed completely as it's not needed anymore +* The following 'mediawiki.api' plugin modules were merged into mediawiki.api + and deprecated: mediawiki.api.category, mediawiki.api.edit, + mediawiki.api.login, mediawiki.api.options, mediawiki.api.parse, + mediawiki.api.upload, mediawiki.api.user, mediawiki.api.watch, + mediawiki.api.messages, and mediawiki.api.rollback. === Other changes in 1.32 === * Soft hyphens (U+00AD) are now automatically removed from titles; these diff --git a/resources/Resources.php b/resources/Resources.php index 132a15abd2..d718fb6530 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -890,75 +890,73 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api' => [ - 'scripts' => 'resources/src/mediawiki.api.js', + 'scripts' => [ + 'resources/src/mediawiki.api/index.js', + 'resources/src/mediawiki.api/category.js', + 'resources/src/mediawiki.api/edit.js', + 'resources/src/mediawiki.api/login.js', + 'resources/src/mediawiki.api/messages.js', + 'resources/src/mediawiki.api/options.js', + 'resources/src/mediawiki.api/parse.js', + 'resources/src/mediawiki.api/rollback.js', + 'resources/src/mediawiki.api/upload.js', + 'resources/src/mediawiki.api/user.js', + 'resources/src/mediawiki.api/watch.js', + ], 'dependencies' => [ + 'mediawiki.Title', 'mediawiki.util', 'user.tokens', ], 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.category' => [ - 'scripts' => 'resources/src/mediawiki.api.category.js', - 'dependencies' => [ - 'mediawiki.api', - 'mediawiki.Title', - ], + 'deprecated' => 'Use "mediawiki.api" instead.', + 'dependencies' => 'mediawiki.api', ], 'mediawiki.api.edit' => [ - 'scripts' => 'resources/src/mediawiki.api.edit.js', + 'deprecated' => 'Use "mediawiki.api" instead.', 'dependencies' => [ 'mediawiki.api', - 'mediawiki.user', ], 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.login' => [ - 'scripts' => 'resources/src/mediawiki.api.login.js', + 'deprecated' => 'Use "mediawiki.api" instead.', 'dependencies' => 'mediawiki.api', ], 'mediawiki.api.options' => [ - 'scripts' => 'resources/src/mediawiki.api.options.js', + 'deprecated' => 'Use "mediawiki.api" instead.', 'dependencies' => 'mediawiki.api', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.parse' => [ - 'scripts' => 'resources/src/mediawiki.api.parse.js', + 'deprecated' => 'Use "mediawiki.api" instead.', 'dependencies' => 'mediawiki.api', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.upload' => [ - 'scripts' => 'resources/src/mediawiki.api.upload.js', - 'dependencies' => [ - 'mediawiki.api', - 'mediawiki.api.edit', - ], + 'deprecated' => 'Use "mediawiki.api" instead.', + 'dependencies' => 'mediawiki.api', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.user' => [ - 'scripts' => 'resources/src/mediawiki.api.user.js', - 'dependencies' => [ - 'mediawiki.api', - ], + 'deprecated' => 'Use "mediawiki.api" instead.', + 'dependencies' => 'mediawiki.api', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.watch' => [ - 'scripts' => 'resources/src/mediawiki.api.watch.js', - 'dependencies' => [ - 'mediawiki.api', - ], + 'deprecated' => 'Use "mediawiki.api" instead.', + 'dependencies' => 'mediawiki.api', ], 'mediawiki.api.messages' => [ - 'scripts' => 'resources/src/mediawiki.api.messages.js', - 'dependencies' => [ - 'mediawiki.api', - ], + 'deprecated' => 'Use "mediawiki.api" instead.', + 'dependencies' => 'mediawiki.api', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.rollback' => [ - 'scripts' => 'resources/src/mediawiki.api.rollback.js', - 'dependencies' => [ - 'mediawiki.api', - ], + 'deprecated' => 'Use "mediawiki.api" instead.', + 'dependencies' => 'mediawiki.api', ], 'mediawiki.content.json' => [ 'styles' => 'resources/src/mediawiki.content.json.less', @@ -1149,7 +1147,7 @@ return [ 'resources/src/mediawiki.messagePoster.wikitext/WikitextMessagePoster.js', ], 'dependencies' => [ - 'mediawiki.api.edit', + 'mediawiki.api', 'mediawiki.messagePoster', ], 'targets' => [ 'desktop', 'mobile' ], @@ -1230,7 +1228,7 @@ return [ 'mediawiki.Upload' => [ 'scripts' => 'resources/src/mediawiki.Upload.js', 'dependencies' => [ - 'mediawiki.api.upload', + 'mediawiki.api', ], ], 'mediawiki.ForeignUpload' => [ @@ -1330,7 +1328,7 @@ return [ 'mediawiki.widgets.CategoryMultiselectWidget', 'mediawiki.widgets.DateInputWidget', 'mediawiki.jqueryMsg', - 'mediawiki.api.messages', + 'mediawiki.api', 'moment', 'mediawiki.libs.jpegmeta', ], @@ -1371,7 +1369,6 @@ return [ 'scripts' => 'resources/src/mediawiki.user.js', 'dependencies' => [ 'mediawiki.api', - 'mediawiki.api.user', 'mediawiki.storage', 'user.options', 'user.tokens', @@ -1739,7 +1736,7 @@ return [ 'mediawiki.page.watch.ajax' => [ 'scripts' => 'resources/src/mediawiki.page.watch.ajax.js', 'dependencies' => [ - 'mediawiki.api.watch', + 'mediawiki.api', 'mediawiki.notify', 'mediawiki.util', 'mediawiki.Title', @@ -1764,7 +1761,7 @@ return [ 'mediawiki.page.rollback' => [ 'scripts' => 'resources/src/mediawiki.page.rollback.js', 'dependencies' => [ - 'mediawiki.api.rollback', + 'mediawiki.api', 'mediawiki.notify', 'mediawiki.util', 'jquery.spinner', @@ -1812,7 +1809,6 @@ return [ 'mediawiki.String', 'oojs', 'mediawiki.api', - 'mediawiki.api.options', 'mediawiki.jqueryMsg', 'mediawiki.Uri', 'mediawiki.user', @@ -2276,7 +2272,6 @@ return [ ], 'dependencies' => [ 'mediawiki.api', - 'mediawiki.api.watch', 'mediawiki.notify', 'mediawiki.Title', 'mediawiki.util', @@ -2303,7 +2298,6 @@ return [ 'jquery.spinner', 'mediawiki.jqueryMsg', 'mediawiki.api', - 'mediawiki.api.parse', 'mediawiki.libs.jpegmeta', 'mediawiki.Title', 'mediawiki.util', @@ -2364,7 +2358,7 @@ return [ 'watchlist-unwatch-undo', ], 'dependencies' => [ - 'mediawiki.api.watch', + 'mediawiki.api', 'mediawiki.jqueryMsg', 'mediawiki.Title', 'mediawiki.util', diff --git a/resources/src/mediawiki.api.category.js b/resources/src/mediawiki.api.category.js deleted file mode 100644 index 85df90e912..0000000000 --- a/resources/src/mediawiki.api.category.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * @class mw.Api.plugin.category - */ -( function ( mw, $ ) { - - $.extend( mw.Api.prototype, { - /** - * Determine if a category exists. - * - * @param {mw.Title|string} title - * @return {jQuery.Promise} - * @return {Function} return.done - * @return {boolean} return.done.isCategory Whether the category exists. - */ - isCategory: function ( title ) { - var apiPromise = this.get( { - formatversion: 2, - prop: 'categoryinfo', - titles: [ String( title ) ] - } ); - - return apiPromise - .then( function ( data ) { - return !!( - data.query && // query is missing on title="" - data.query.pages && // query.pages is missing on title="#" or title="mw:" - data.query.pages[ 0 ].categoryinfo - ); - } ) - .promise( { abort: apiPromise.abort } ); - }, - - /** - * Get a list of categories that match a certain prefix. - * - * E.g. given "Foo", return "Food", "Foolish people", "Foosball tables"... - * - * @param {string} prefix Prefix to match. - * @return {jQuery.Promise} - * @return {Function} return.done - * @return {string[]} return.done.categories Matched categories - */ - getCategoriesByPrefix: function ( prefix ) { - // Fetch with allpages to only get categories that have a corresponding description page. - var apiPromise = this.get( { - formatversion: 2, - list: 'allpages', - apprefix: prefix, - apnamespace: mw.config.get( 'wgNamespaceIds' ).category - } ); - - return apiPromise - .then( function ( data ) { - return data.query.allpages.map( function ( category ) { - return new mw.Title( category.title ).getMainText(); - } ); - } ) - .promise( { abort: apiPromise.abort } ); - }, - - /** - * Get the categories that a particular page on the wiki belongs to. - * - * @param {mw.Title|string} title - * @return {jQuery.Promise} - * @return {Function} return.done - * @return {boolean|mw.Title[]} return.done.categories List of category titles or false - * if title was not found. - */ - getCategories: function ( title ) { - var apiPromise = this.get( { - formatversion: 2, - prop: 'categories', - titles: [ String( title ) ] - } ); - - return apiPromise - .then( function ( data ) { - var page; - - if ( !data.query || !data.query.pages ) { - return false; - } - page = data.query.pages[ 0 ]; - if ( !page.categories ) { - return false; - } - return page.categories.map( function ( cat ) { - return new mw.Title( cat.title ); - } ); - } ) - .promise( { abort: apiPromise.abort } ); - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.category - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.edit.js b/resources/src/mediawiki.api.edit.js deleted file mode 100644 index 21c55c7001..0000000000 --- a/resources/src/mediawiki.api.edit.js +++ /dev/null @@ -1,199 +0,0 @@ -/** - * @class mw.Api.plugin.edit - */ -( function ( mw, $ ) { - - $.extend( mw.Api.prototype, { - - /** - * Post to API with csrf token. If we have no token, get one and try to post. - * If we have a cached token try using that, and if it fails, blank out the - * cached token and start over. - * - * @param {Object} params API parameters - * @param {Object} [ajaxOptions] - * @return {jQuery.Promise} See #post - */ - postWithEditToken: function ( params, ajaxOptions ) { - return this.postWithToken( 'csrf', params, ajaxOptions ); - }, - - /** - * API helper to grab a csrf token. - * - * @return {jQuery.Promise} Received token. - */ - getEditToken: function () { - return this.getToken( 'csrf' ); - }, - - /** - * Create a new page. - * - * Example: - * - * new mw.Api().create( 'Sandbox', - * { summary: 'Load sand particles.' }, - * 'Sand.' - * ); - * - * @since 1.28 - * @param {mw.Title|string} title Page title - * @param {Object} params Edit API parameters - * @param {string} params.summary Edit summary - * @param {string} content - * @return {jQuery.Promise} API response - */ - create: function ( title, params, content ) { - return this.postWithEditToken( $.extend( { - action: 'edit', - title: String( title ), - text: content, - formatversion: '2', - - // Protect against errors and conflicts - assert: mw.user.isAnon() ? undefined : 'user', - createonly: true - }, params ) ).then( function ( data ) { - return data.edit; - } ); - }, - - /** - * Edit an existing page. - * - * To create a new page, use #create() instead. - * - * Simple transformation: - * - * new mw.Api() - * .edit( 'Sandbox', function ( revision ) { - * return revision.content.replace( 'foo', 'bar' ); - * } ) - * .then( function () { - * console.log( 'Saved! '); - * } ); - * - * Set save parameters by returning an object instead of a string: - * - * new mw.Api().edit( - * 'Sandbox', - * function ( revision ) { - * return { - * text: revision.content.replace( 'foo', 'bar' ), - * summary: 'Replace "foo" with "bar".', - * assert: 'bot', - * minor: true - * }; - * } - * ) - * .then( function () { - * console.log( 'Saved! '); - * } ); - * - * Transform asynchronously by returning a promise. - * - * new mw.Api() - * .edit( 'Sandbox', function ( revision ) { - * return Spelling - * .corrections( revision.content ) - * .then( function ( report ) { - * return { - * text: report.output, - * summary: report.changelog - * }; - * } ); - * } ) - * .then( function () { - * console.log( 'Saved! '); - * } ); - * - * @since 1.28 - * @param {mw.Title|string} title Page title - * @param {Function} transform Callback that prepares the edit - * @param {Object} transform.revision Current revision - * @param {string} transform.revision.content Current revision content - * @param {string|Object|jQuery.Promise} transform.return New content, object with edit - * API parameters, or promise providing one of those. - * @return {jQuery.Promise} Edit API response - */ - edit: function ( title, transform ) { - var basetimestamp, curtimestamp, - api = this; - - title = String( title ); - - return api.get( { - action: 'query', - prop: 'revisions', - rvprop: [ 'content', 'timestamp' ], - titles: [ title ], - formatversion: '2', - curtimestamp: true - } ) - .then( function ( data ) { - var page, revision; - if ( !data.query || !data.query.pages ) { - return $.Deferred().reject( 'unknown' ); - } - page = data.query.pages[ 0 ]; - if ( !page || page.invalid ) { - return $.Deferred().reject( 'invalidtitle' ); - } - if ( page.missing ) { - return $.Deferred().reject( 'nocreate-missing' ); - } - revision = page.revisions[ 0 ]; - basetimestamp = revision.timestamp; - curtimestamp = data.curtimestamp; - return transform( { - timestamp: revision.timestamp, - content: revision.content - } ); - } ) - .then( function ( params ) { - var editParams = typeof params === 'object' ? params : { text: String( params ) }; - return api.postWithEditToken( $.extend( { - action: 'edit', - title: title, - formatversion: '2', - - // Protect against errors and conflicts - assert: mw.user.isAnon() ? undefined : 'user', - basetimestamp: basetimestamp, - starttimestamp: curtimestamp, - nocreate: true - }, editParams ) ); - } ) - .then( function ( data ) { - return data.edit; - } ); - }, - - /** - * Post a new section to the page. - * - * @see #postWithEditToken - * @param {mw.Title|string} title Target page - * @param {string} header - * @param {string} message wikitext message - * @param {Object} [additionalParams] Additional API parameters, e.g. `{ redirect: true }` - * @return {jQuery.Promise} - */ - newSection: function ( title, header, message, additionalParams ) { - return this.postWithEditToken( $.extend( { - action: 'edit', - section: 'new', - title: String( title ), - summary: header, - text: message - }, additionalParams ) ); - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.edit - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.js b/resources/src/mediawiki.api.js deleted file mode 100644 index 0038ed8ecf..0000000000 --- a/resources/src/mediawiki.api.js +++ /dev/null @@ -1,506 +0,0 @@ -( function ( mw, $ ) { - - /** - * @class mw.Api - */ - - /** - * @property {Object} defaultOptions Default options for #ajax calls. Can be overridden by passing - * `options` to mw.Api constructor. - * @property {Object} defaultOptions.parameters Default query parameters for API requests. - * @property {Object} defaultOptions.ajax Default options for jQuery#ajax. - * @property {boolean} defaultOptions.useUS Whether to use U+001F when joining multi-valued - * parameters (since 1.28). Default is true if ajax.url is not set, false otherwise for - * compatibility. - * @private - */ - var defaultOptions = { - parameters: { - action: 'query', - format: 'json' - }, - ajax: { - url: mw.util.wikiScript( 'api' ), - timeout: 30 * 1000, // 30 seconds - dataType: 'json' - } - }, - - // Keyed by ajax url and symbolic name for the individual request - promises = {}; - - function mapLegacyToken( action ) { - // Legacy types for backward-compatibility with API action=tokens. - var csrfActions = [ - 'edit', - 'delete', - 'protect', - 'move', - 'block', - 'unblock', - 'email', - 'import', - 'options' - ]; - if ( csrfActions.indexOf( action ) !== -1 ) { - mw.track( 'mw.deprecate', 'apitoken_' + action ); - mw.log.warn( 'Use of the "' + action + '" token is deprecated. Use "csrf" instead.' ); - return 'csrf'; - } - return action; - } - - // Pre-populate with fake ajax promises to save http requests for tokens - // we already have on the page via the user.tokens module (T36733). - promises[ defaultOptions.ajax.url ] = {}; - $.each( mw.user.tokens.get(), function ( key, value ) { - // This requires #getToken to use the same key as user.tokens. - // Format: token-type + "Token" (eg. csrfToken, patrolToken, watchToken). - promises[ defaultOptions.ajax.url ][ key ] = $.Deferred() - .resolve( value ) - .promise( { abort: function () {} } ); - } ); - - /** - * Constructor to create an object to interact with the API of a particular MediaWiki server. - * mw.Api objects represent the API of a particular MediaWiki server. - * - * var api = new mw.Api(); - * api.get( { - * action: 'query', - * meta: 'userinfo' - * } ).done( function ( data ) { - * console.log( data ); - * } ); - * - * Since MW 1.25, multiple values for a parameter can be specified using an array: - * - * var api = new mw.Api(); - * api.get( { - * action: 'query', - * meta: [ 'userinfo', 'siteinfo' ] // same effect as 'userinfo|siteinfo' - * } ).done( function ( data ) { - * console.log( data ); - * } ); - * - * Since MW 1.26, boolean values for a parameter can be specified directly. If the value is - * `false` or `undefined`, the parameter will be omitted from the request, as required by the API. - * - * @constructor - * @param {Object} [options] See #defaultOptions documentation above. Can also be overridden for - * each individual request by passing them to #get or #post (or directly #ajax) later on. - */ - mw.Api = function ( options ) { - options = options || {}; - - // Force a string if we got a mw.Uri object - if ( options.ajax && options.ajax.url !== undefined ) { - options.ajax.url = String( options.ajax.url ); - } - - options = $.extend( { useUS: !options.ajax || !options.ajax.url }, options ); - - options.parameters = $.extend( {}, defaultOptions.parameters, options.parameters ); - options.ajax = $.extend( {}, defaultOptions.ajax, options.ajax ); - - this.defaults = options; - this.requests = []; - }; - - mw.Api.prototype = { - /** - * Abort all unfinished requests issued by this Api object. - * - * @method - */ - abort: function () { - this.requests.forEach( function ( request ) { - if ( request ) { - request.abort(); - } - } ); - }, - - /** - * Perform API get request - * - * @param {Object} parameters - * @param {Object} [ajaxOptions] - * @return {jQuery.Promise} - */ - get: function ( parameters, ajaxOptions ) { - ajaxOptions = ajaxOptions || {}; - ajaxOptions.type = 'GET'; - return this.ajax( parameters, ajaxOptions ); - }, - - /** - * Perform API post request - * - * @param {Object} parameters - * @param {Object} [ajaxOptions] - * @return {jQuery.Promise} - */ - post: function ( parameters, ajaxOptions ) { - ajaxOptions = ajaxOptions || {}; - ajaxOptions.type = 'POST'; - return this.ajax( parameters, ajaxOptions ); - }, - - /** - * Massage parameters from the nice format we accept into a format suitable for the API. - * - * NOTE: A value of undefined/null in an array will be represented by Array#join() - * as the empty string. Should we filter silently? Warn? Leave as-is? - * - * @private - * @param {Object} parameters (modified in-place) - * @param {boolean} useUS Whether to use U+001F when joining multi-valued parameters. - */ - preprocessParameters: function ( parameters, useUS ) { - var key; - // Handle common MediaWiki API idioms for passing parameters - for ( key in parameters ) { - // Multiple values are pipe-separated - if ( Array.isArray( parameters[ key ] ) ) { - if ( !useUS || parameters[ key ].join( '' ).indexOf( '|' ) === -1 ) { - parameters[ key ] = parameters[ key ].join( '|' ); - } else { - parameters[ key ] = '\x1f' + parameters[ key ].join( '\x1f' ); - } - } else if ( parameters[ key ] === false || parameters[ key ] === undefined ) { - // Boolean values are only false when not given at all - delete parameters[ key ]; - } - } - }, - - /** - * Perform the API call. - * - * @param {Object} parameters - * @param {Object} [ajaxOptions] - * @return {jQuery.Promise} Done: API response data and the jqXHR object. - * Fail: Error code - */ - ajax: function ( parameters, ajaxOptions ) { - var token, requestIndex, - api = this, - apiDeferred = $.Deferred(), - xhr, key, formData; - - parameters = $.extend( {}, this.defaults.parameters, parameters ); - ajaxOptions = $.extend( {}, this.defaults.ajax, ajaxOptions ); - - // Ensure that token parameter is last (per [[mw:API:Edit#Token]]). - if ( parameters.token ) { - token = parameters.token; - delete parameters.token; - } - - this.preprocessParameters( parameters, this.defaults.useUS ); - - // If multipart/form-data has been requested and emulation is possible, emulate it - if ( - ajaxOptions.type === 'POST' && - window.FormData && - ajaxOptions.contentType === 'multipart/form-data' - ) { - - formData = new FormData(); - - for ( key in parameters ) { - formData.append( key, parameters[ key ] ); - } - // If we extracted a token parameter, add it back in. - if ( token ) { - formData.append( 'token', token ); - } - - ajaxOptions.data = formData; - - // Prevent jQuery from mangling our FormData object - ajaxOptions.processData = false; - // Prevent jQuery from overriding the Content-Type header - ajaxOptions.contentType = false; - } else { - // This works because jQuery accepts data as a query string or as an Object - ajaxOptions.data = $.param( parameters ); - // If we extracted a token parameter, add it back in. - if ( token ) { - ajaxOptions.data += '&token=' + encodeURIComponent( token ); - } - - // Depending on server configuration, MediaWiki may forbid periods in URLs, due to an IE 6 - // XSS bug. So let's escape them here. See WebRequest::checkUrlExtension() and T30235. - ajaxOptions.data = ajaxOptions.data.replace( /\./g, '%2E' ); - - if ( ajaxOptions.contentType === 'multipart/form-data' ) { - // We were asked to emulate but can't, so drop the Content-Type header, otherwise - // it'll be wrong and the server will fail to decode the POST body - delete ajaxOptions.contentType; - } - } - - // Make the AJAX request - xhr = $.ajax( ajaxOptions ) - // If AJAX fails, reject API call with error code 'http' - // and details in second argument. - .fail( function ( xhr, textStatus, exception ) { - apiDeferred.reject( 'http', { - xhr: xhr, - textStatus: textStatus, - exception: exception - } ); - } ) - // AJAX success just means "200 OK" response, also check API error codes - .done( function ( result, textStatus, jqXHR ) { - var code; - if ( result === undefined || result === null || result === '' ) { - apiDeferred.reject( 'ok-but-empty', - 'OK response but empty result (check HTTP headers?)', - result, - jqXHR - ); - } else if ( result.error ) { - // errorformat=bc - code = result.error.code === undefined ? 'unknown' : result.error.code; - apiDeferred.reject( code, result, result, jqXHR ); - } else if ( result.errors ) { - // errorformat!=bc - code = result.errors[ 0 ].code === undefined ? 'unknown' : result.errors[ 0 ].code; - apiDeferred.reject( code, result, result, jqXHR ); - } else { - apiDeferred.resolve( result, jqXHR ); - } - } ); - - requestIndex = this.requests.length; - this.requests.push( xhr ); - xhr.always( function () { - api.requests[ requestIndex ] = null; - } ); - // Return the Promise - return apiDeferred.promise( { abort: xhr.abort } ).fail( function ( code, details ) { - if ( !( code === 'http' && details && details.textStatus === 'abort' ) ) { - mw.log( 'mw.Api error: ', code, details ); - } - } ); - }, - - /** - * Post to API with specified type of token. If we have no token, get one and try to post. - * If we have a cached token try using that, and if it fails, blank out the - * cached token and start over. For example to change an user option you could do: - * - * new mw.Api().postWithToken( 'csrf', { - * action: 'options', - * optionname: 'gender', - * optionvalue: 'female' - * } ); - * - * @param {string} tokenType The name of the token, like options or edit. - * @param {Object} params API parameters - * @param {Object} [ajaxOptions] - * @return {jQuery.Promise} See #post - * @since 1.22 - */ - postWithToken: function ( tokenType, params, ajaxOptions ) { - var api = this, - abortedPromise = $.Deferred().reject( 'http', - { textStatus: 'abort', exception: 'abort' } ).promise(), - abortable, - aborted; - - return api.getToken( tokenType, params.assert ).then( function ( token ) { - params.token = token; - // Request was aborted while token request was running, but we - // don't want to unnecessarily abort token requests, so abort - // a fake request instead - if ( aborted ) { - return abortedPromise; - } - - return ( abortable = api.post( params, ajaxOptions ) ).catch( - // Error handler - function ( code ) { - if ( code === 'badtoken' ) { - api.badToken( tokenType ); - // Try again, once - params.token = undefined; - abortable = null; - return api.getToken( tokenType, params.assert ).then( function ( token ) { - params.token = token; - if ( aborted ) { - return abortedPromise; - } - - return ( abortable = api.post( params, ajaxOptions ) ); - } ); - } - - // Let caller handle the error code - return $.Deferred().rejectWith( this, arguments ); - } - ); - } ).promise( { abort: function () { - if ( abortable ) { - abortable.abort(); - } else { - aborted = true; - } - } } ); - }, - - /** - * Get a token for a certain action from the API. - * - * The assert parameter is only for internal use by #postWithToken. - * - * @since 1.22 - * @param {string} type Token type - * @param {string} [assert] - * @return {jQuery.Promise} Received token. - */ - getToken: function ( type, assert ) { - var apiPromise, promiseGroup, d, reject; - type = mapLegacyToken( type ); - promiseGroup = promises[ this.defaults.ajax.url ]; - d = promiseGroup && promiseGroup[ type + 'Token' ]; - - if ( !promiseGroup ) { - promiseGroup = promises[ this.defaults.ajax.url ] = {}; - } - - if ( !d ) { - apiPromise = this.get( { - action: 'query', - meta: 'tokens', - type: type, - assert: assert - } ); - reject = function () { - // Clear promise. Do not cache errors. - delete promiseGroup[ type + 'Token' ]; - - // Let caller handle the error code - return $.Deferred().rejectWith( this, arguments ); - }; - d = apiPromise - .then( function ( res ) { - if ( !res.query ) { - return reject( 'query-missing', res ); - } - // If token type is unknown, it is omitted from the response - if ( !res.query.tokens[ type + 'token' ] ) { - return $.Deferred().reject( 'token-missing', res ); - } - return res.query.tokens[ type + 'token' ]; - }, reject ) - // Attach abort handler - .promise( { abort: apiPromise.abort } ); - - // Store deferred now so that we can use it again even if it isn't ready yet - promiseGroup[ type + 'Token' ] = d; - } - - return d; - }, - - /** - * Indicate that the cached token for a certain action of the API is bad. - * - * Call this if you get a 'badtoken' error when using the token returned by #getToken. - * You may also want to use #postWithToken instead, which invalidates bad cached tokens - * automatically. - * - * @param {string} type Token type - * @since 1.26 - */ - badToken: function ( type ) { - var promiseGroup = promises[ this.defaults.ajax.url ]; - - type = mapLegacyToken( type ); - if ( promiseGroup ) { - delete promiseGroup[ type + 'Token' ]; - } - } - }; - - /** - * @static - * @property {Array} - * Very incomplete and outdated list of errors we might receive from the API. Do not use. - * @deprecated since 1.29 - */ - mw.Api.errors = [ - // occurs when POST aborted - // jQuery 1.4 can't distinguish abort or lost connection from 200 OK + empty result - 'ok-but-empty', - - // timeout - 'timeout', - - // really a warning, but we treat it like an error - 'duplicate', - 'duplicate-archive', - - // upload succeeded, but no image info. - // this is probably impossible, but might as well check for it - 'noimageinfo', - // remote errors, defined in API - 'uploaddisabled', - 'nomodule', - 'mustbeposted', - 'badaccess-groups', - 'missingresult', - 'missingparam', - 'invalid-file-key', - 'copyuploaddisabled', - 'mustbeloggedin', - 'empty-file', - 'file-too-large', - 'filetype-missing', - 'filetype-banned', - 'filetype-banned-type', - 'filename-tooshort', - 'illegal-filename', - 'verification-error', - 'hookaborted', - 'unknown-error', - 'internal-error', - 'overwrite', - 'badtoken', - 'fetchfileerror', - 'fileexists-shared-forbidden', - 'invalidtitle', - 'notloggedin', - 'autoblocked', - 'blocked', - - // Stash-specific errors - expanded - 'stashfailed', - 'stasherror', - 'stashedfilenotfound', - 'stashpathinvalid', - 'stashfilestorage', - 'stashzerolength', - 'stashnotloggedin', - 'stashwrongowner', - 'stashnosuchfilekey' - ]; - mw.log.deprecate( mw.Api, 'errors', mw.Api.errors, null, 'mw.Api.errors' ); - - /** - * @static - * @property {Array} - * Very incomplete and outdated list of warnings we might receive from the API. Do not use. - * @deprecated since 1.29 - */ - mw.Api.warnings = [ - 'duplicate', - 'exists' - ]; - mw.log.deprecate( mw.Api, 'warnings', mw.Api.warnings, null, 'mw.Api.warnings' ); - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.login.js b/resources/src/mediawiki.api.login.js deleted file mode 100644 index 2b709aae7b..0000000000 --- a/resources/src/mediawiki.api.login.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Make the two-step login easier. - * - * @author Niklas Laxström - * @class mw.Api.plugin.login - * @since 1.22 - */ -( function ( mw, $ ) { - 'use strict'; - - $.extend( mw.Api.prototype, { - /** - * @param {string} username - * @param {string} password - * @return {jQuery.Promise} See mw.Api#post - */ - login: function ( username, password ) { - var params, apiPromise, innerPromise, - api = this; - - params = { - action: 'login', - lgname: username, - lgpassword: password - }; - - apiPromise = api.post( params ); - - return apiPromise - .then( function ( data ) { - params.lgtoken = data.login.token; - innerPromise = api.post( params ) - .then( function ( data ) { - var code; - if ( data.login.result !== 'Success' ) { - // Set proper error code whenever possible - code = data.error && data.error.code || 'unknown'; - return $.Deferred().reject( code, data ); - } - return data; - } ); - return innerPromise; - } ) - .promise( { - abort: function () { - apiPromise.abort(); - if ( innerPromise ) { - innerPromise.abort(); - } - } - } ); - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.login - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.messages.js b/resources/src/mediawiki.api.messages.js deleted file mode 100644 index 688f0b2435..0000000000 --- a/resources/src/mediawiki.api.messages.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Allows to retrieve a specific or a set of - * messages to be added to mw.messages and returned - * by the Api. - * - * @class mw.Api.plugin.messages - * @since 1.27 - */ -( function ( mw, $ ) { - 'use strict'; - - $.extend( mw.Api.prototype, { - /** - * Get a set of messages. - * - * @param {Array} messages Messages to retrieve - * @param {Object} [options] Additional parameters for the API call - * @return {jQuery.Promise} - */ - getMessages: function ( messages, options ) { - options = options || {}; - return this.get( $.extend( { - action: 'query', - meta: 'allmessages', - ammessages: messages, - amlang: mw.config.get( 'wgUserLanguage' ), - formatversion: 2 - }, options ) ).then( function ( data ) { - var result = {}; - - data.query.allmessages.forEach( function ( obj ) { - if ( !obj.missing ) { - result[ obj.name ] = obj.content; - } - } ); - - return result; - } ); - }, - - /** - * Loads a set of messages and add them to mw.messages. - * - * @param {Array} messages Messages to retrieve - * @param {Object} [options] Additional parameters for the API call - * @return {jQuery.Promise} - */ - loadMessages: function ( messages, options ) { - return this.getMessages( messages, options ).then( $.proxy( mw.messages, 'set' ) ); - }, - - /** - * Loads a set of messages and add them to mw.messages. Only messages that are not already known - * are loaded. If all messages are known, the returned promise is resolved immediately. - * - * @param {Array} messages Messages to retrieve - * @param {Object} [options] Additional parameters for the API call - * @return {jQuery.Promise} - */ - loadMessagesIfMissing: function ( messages, options ) { - var missing = messages.filter( function ( msg ) { - return !mw.message( msg ).exists(); - } ); - - if ( missing.length === 0 ) { - return $.Deferred().resolve(); - } - - return this.getMessages( missing, options ).then( $.proxy( mw.messages, 'set' ) ); - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.messages - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.options.js b/resources/src/mediawiki.api.options.js deleted file mode 100644 index 4930c4fccc..0000000000 --- a/resources/src/mediawiki.api.options.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * @class mw.Api.plugin.options - */ -( function ( mw, $ ) { - - $.extend( mw.Api.prototype, { - - /** - * Asynchronously save the value of a single user option using the API. See #saveOptions. - * - * @param {string} name - * @param {string|null} value - * @return {jQuery.Promise} - */ - saveOption: function ( name, value ) { - var param = {}; - param[ name ] = value; - return this.saveOptions( param ); - }, - - /** - * Asynchronously save the values of user options using the API. - * - * If a value of `null` is provided, the given option will be reset to the default value. - * - * Any warnings returned by the API, including warnings about invalid option names or values, - * are ignored. However, do not rely on this behavior. - * - * If necessary, the options will be saved using several sequential API requests. Only one promise - * is always returned that will be resolved when all requests complete. - * - * @param {Object} options Options as a `{ name: value, … }` object - * @return {jQuery.Promise} - */ - saveOptions: function ( options ) { - var name, value, bundleable, - grouped = [], - promise = $.Deferred().resolve(); - - for ( name in options ) { - value = options[ name ] === null ? null : String( options[ name ] ); - - // Can we bundle this option, or does it need a separate request? - if ( this.defaults.useUS ) { - bundleable = name.indexOf( '=' ) === -1; - } else { - bundleable = - ( value === null || value.indexOf( '|' ) === -1 ) && - ( name.indexOf( '|' ) === -1 && name.indexOf( '=' ) === -1 ); - } - - if ( bundleable ) { - if ( value !== null ) { - grouped.push( name + '=' + value ); - } else { - // Omitting value resets the option - grouped.push( name ); - } - } else { - if ( value !== null ) { - promise = promise.then( function ( name, value ) { - return this.postWithToken( 'csrf', { - formatversion: 2, - action: 'options', - optionname: name, - optionvalue: value - } ); - }.bind( this, name, value ) ); - } else { - // Omitting value resets the option - promise = promise.then( function ( name ) { - return this.postWithToken( 'csrf', { - formatversion: 2, - action: 'options', - optionname: name - } ); - }.bind( this, name ) ); - } - } - } - - if ( grouped.length ) { - promise = promise.then( function () { - return this.postWithToken( 'csrf', { - formatversion: 2, - action: 'options', - change: grouped - } ); - }.bind( this ) ); - } - - return promise; - } - - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.options - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.parse.js b/resources/src/mediawiki.api.parse.js deleted file mode 100644 index f38e88b5c3..0000000000 --- a/resources/src/mediawiki.api.parse.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @class mw.Api.plugin.parse - */ -( function ( mw, $ ) { - - $.extend( mw.Api.prototype, { - /** - * Convenience method for 'action=parse'. - * - * @param {string|mw.Title} content Content to parse, either as a wikitext string or - * a mw.Title. - * @param {Object} additionalParams Parameters object to set custom settings, e.g. - * redirects, sectionpreview. prop should not be overridden. - * @return {jQuery.Promise} - * @return {Function} return.done - * @return {string} return.done.data Parsed HTML of `wikitext`. - */ - parse: function ( content, additionalParams ) { - var apiPromise, - config = $.extend( { - formatversion: 2, - action: 'parse', - contentmodel: 'wikitext' - }, additionalParams ); - - if ( mw.Title && content instanceof mw.Title ) { - // Parse existing page - config.page = content.getPrefixedDb(); - } else { - // Parse wikitext from input - config.text = String( content ); - } - - apiPromise = this.get( config ); - - return apiPromise - .then( function ( data ) { - return data.parse.text; - } ) - .promise( { abort: apiPromise.abort } ); - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.parse - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.rollback.js b/resources/src/mediawiki.api.rollback.js deleted file mode 100644 index 322143dc5f..0000000000 --- a/resources/src/mediawiki.api.rollback.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @class mw.Api.plugin.rollback - * @since 1.28 - */ -( function ( mw, $ ) { - - $.extend( mw.Api.prototype, { - /** - * Convenience method for `action=rollback`. - * - * @param {string|mw.Title} page - * @param {string} user - * @param {Object} [params] Additional parameters - * @return {jQuery.Promise} - */ - rollback: function ( page, user, params ) { - return this.postWithToken( 'rollback', $.extend( { - action: 'rollback', - title: String( page ), - user: user, - uselang: mw.config.get( 'wgUserLanguage' ) - }, params ) ).then( function ( data ) { - return data.rollback; - } ); - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.rollback - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.upload.js b/resources/src/mediawiki.api.upload.js deleted file mode 100644 index 29bd59aed9..0000000000 --- a/resources/src/mediawiki.api.upload.js +++ /dev/null @@ -1,668 +0,0 @@ -/** - * Provides an interface for uploading files to MediaWiki. - * - * @class mw.Api.plugin.upload - * @singleton - */ -( function ( mw, $ ) { - var nonce = 0, - fieldsAllowed = { - stash: true, - filekey: true, - filename: true, - comment: true, - text: true, - watchlist: true, - ignorewarnings: true, - chunk: true, - offset: true, - filesize: true, - async: true - }; - - /** - * Get nonce for iframe IDs on the page. - * - * @private - * @return {number} - */ - function getNonce() { - return nonce++; - } - - /** - * Given a non-empty object, return one of its keys. - * - * @private - * @param {Object} obj - * @return {string} - */ - function getFirstKey( obj ) { - var key; - for ( key in obj ) { - if ( obj.hasOwnProperty( key ) ) { - return key; - } - } - } - - /** - * Get new iframe object for an upload. - * - * @private - * @param {string} id - * @return {HTMLIframeElement} - */ - function getNewIframe( id ) { - var frame = document.createElement( 'iframe' ); - frame.id = id; - frame.name = id; - return frame; - } - - /** - * Shortcut for getting hidden inputs - * - * @private - * @param {string} name - * @param {string} val - * @return {jQuery} - */ - function getHiddenInput( name, val ) { - return $( '' ).attr( 'type', 'hidden' ) - .attr( 'name', name ) - .val( val ); - } - - /** - * Process the result of the form submission, returned to an iframe. - * This is the iframe's onload event. - * - * @param {HTMLIframeElement} iframe Iframe to extract result from - * @return {Object} Response from the server. The return value may or may - * not be an XMLDocument, this code was copied from elsewhere, so if you - * see an unexpected return type, please file a bug. - */ - function processIframeResult( iframe ) { - var json, - doc = iframe.contentDocument || frames[ iframe.id ].document; - - if ( doc.XMLDocument ) { - // The response is a document property in IE - return doc.XMLDocument; - } - - if ( doc.body ) { - // Get the json string - // We're actually searching through an HTML doc here -- - // according to mdale we need to do this - // because IE does not load JSON properly in an iframe - json = $( doc.body ).find( 'pre' ).text(); - - return JSON.parse( json ); - } - - // Response is a xml document - return doc; - } - - function formDataAvailable() { - return window.FormData !== undefined && - window.File !== undefined && - window.File.prototype.slice !== undefined; - } - - $.extend( mw.Api.prototype, { - /** - * Upload a file to MediaWiki. - * - * The file will be uploaded using AJAX and FormData, if the browser supports it, or via an - * iframe if it doesn't. - * - * Caveats of iframe upload: - * - The returned jQuery.Promise will not receive `progress` notifications during the upload - * - It is incompatible with uploads to a foreign wiki using mw.ForeignApi - * - You must pass a HTMLInputElement and not a File for it to be possible - * - * @param {HTMLInputElement|File|Blob} file HTML input type=file element with a file already inside - * of it, or a File object. - * @param {Object} data Other upload options, see action=upload API docs for more - * @return {jQuery.Promise} - */ - upload: function ( file, data ) { - var isFileInput, canUseFormData; - - isFileInput = file && file.nodeType === Node.ELEMENT_NODE; - - if ( formDataAvailable() && isFileInput && file.files ) { - file = file.files[ 0 ]; - } - - if ( !file ) { - throw new Error( 'No file' ); - } - - // Blobs are allowed in formdata uploads, it turns out - canUseFormData = formDataAvailable() && ( file instanceof window.File || file instanceof window.Blob ); - - if ( !isFileInput && !canUseFormData ) { - throw new Error( 'Unsupported argument type passed to mw.Api.upload' ); - } - - if ( canUseFormData ) { - return this.uploadWithFormData( file, data ); - } - - return this.uploadWithIframe( file, data ); - }, - - /** - * Upload a file to MediaWiki with an iframe and a form. - * - * This method is necessary for browsers without the File/FormData - * APIs, and continues to work in browsers with those APIs. - * - * The rough sketch of how this method works is as follows: - * 1. An iframe is loaded with no content. - * 2. A form is submitted with the passed-in file input and some extras. - * 3. The MediaWiki API receives that form data, and sends back a response. - * 4. The response is sent to the iframe, because we set target=(iframe id) - * 5. The response is parsed out of the iframe's document, and passed back - * through the promise. - * - * @private - * @param {HTMLInputElement} file The file input with a file in it. - * @param {Object} data Other upload options, see action=upload API docs for more - * @return {jQuery.Promise} - */ - uploadWithIframe: function ( file, data ) { - var key, - tokenPromise = $.Deferred(), - api = this, - deferred = $.Deferred(), - nonce = getNonce(), - id = 'uploadframe-' + nonce, - $form = $( '
' ), - iframe = getNewIframe( id ), - $iframe = $( iframe ); - - for ( key in data ) { - if ( !fieldsAllowed[ key ] ) { - delete data[ key ]; - } - } - - data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data ); - $form.addClass( 'mw-api-upload-form' ); - - $form.css( 'display', 'none' ) - .attr( { - action: this.defaults.ajax.url, - method: 'POST', - target: id, - enctype: 'multipart/form-data' - } ); - - $iframe.one( 'load', function () { - $iframe.one( 'load', function () { - var result = processIframeResult( iframe ); - deferred.notify( 1 ); - - if ( !result ) { - deferred.reject( 'ok-but-empty', 'No response from API on upload attempt.' ); - } else if ( result.error ) { - if ( result.error.code === 'badtoken' ) { - api.badToken( 'csrf' ); - } - - deferred.reject( result.error.code, result ); - } else if ( result.upload && result.upload.warnings ) { - deferred.reject( getFirstKey( result.upload.warnings ), result ); - } else { - deferred.resolve( result ); - } - } ); - tokenPromise.done( function () { - $form.submit(); - } ); - } ); - - $iframe.on( 'error', function ( error ) { - deferred.reject( 'http', error ); - } ); - - $iframe.prop( 'src', 'about:blank' ).hide(); - - file.name = 'file'; - - $.each( data, function ( key, val ) { - $form.append( getHiddenInput( key, val ) ); - } ); - - if ( !data.filename && !data.stash ) { - throw new Error( 'Filename not included in file data.' ); - } - - if ( this.needToken() ) { - this.getEditToken().then( function ( token ) { - $form.append( getHiddenInput( 'token', token ) ); - tokenPromise.resolve(); - }, tokenPromise.reject ); - } else { - tokenPromise.resolve(); - } - - $( 'body' ).append( $form, $iframe ); - - deferred.always( function () { - $form.remove(); - $iframe.remove(); - } ); - - return deferred.promise(); - }, - - /** - * Uploads a file using the FormData API. - * - * @private - * @param {File} file - * @param {Object} data Other upload options, see action=upload API docs for more - * @return {jQuery.Promise} - */ - uploadWithFormData: function ( file, data ) { - var key, request, - deferred = $.Deferred(); - - for ( key in data ) { - if ( !fieldsAllowed[ key ] ) { - delete data[ key ]; - } - } - - data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data ); - if ( !data.chunk ) { - data.file = file; - } - - if ( !data.filename && !data.stash ) { - throw new Error( 'Filename not included in file data.' ); - } - - // Use this.postWithEditToken() or this.post() - request = this[ this.needToken() ? 'postWithEditToken' : 'post' ]( data, { - // Use FormData (if we got here, we know that it's available) - contentType: 'multipart/form-data', - // No timeout (default from mw.Api is 30 seconds) - timeout: 0, - // Provide upload progress notifications - xhr: function () { - var xhr = $.ajaxSettings.xhr(); - if ( xhr.upload ) { - // need to bind this event before we open the connection (see note at - // https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest/Using_XMLHttpRequest#Monitoring_progress) - xhr.upload.addEventListener( 'progress', function ( ev ) { - if ( ev.lengthComputable ) { - deferred.notify( ev.loaded / ev.total ); - } - } ); - } - return xhr; - } - } ) - .done( function ( result ) { - deferred.notify( 1 ); - if ( result.upload && result.upload.warnings ) { - deferred.reject( getFirstKey( result.upload.warnings ), result ); - } else { - deferred.resolve( result ); - } - } ) - .fail( function ( errorCode, result ) { - deferred.notify( 1 ); - deferred.reject( errorCode, result ); - } ); - - return deferred.promise( { abort: request.abort } ); - }, - - /** - * Upload a file in several chunks. - * - * @param {File} file - * @param {Object} data Other upload options, see action=upload API docs for more - * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB) - * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1) - * @return {jQuery.Promise} - */ - chunkedUpload: function ( file, data, chunkSize, chunkRetries ) { - var start, end, promise, next, active, - deferred = $.Deferred(); - - chunkSize = chunkSize === undefined ? 5 * 1024 * 1024 : chunkSize; - chunkRetries = chunkRetries === undefined ? 1 : chunkRetries; - - if ( !data.filename ) { - throw new Error( 'Filename not included in file data.' ); - } - - // Submit first chunk to get the filekey - active = promise = this.uploadChunk( file, data, 0, chunkSize, '', chunkRetries ) - .done( chunkSize >= file.size ? deferred.resolve : null ) - .fail( deferred.reject ) - .progress( deferred.notify ); - - // Now iteratively submit the rest of the chunks - for ( start = chunkSize; start < file.size; start += chunkSize ) { - end = Math.min( start + chunkSize, file.size ); - next = $.Deferred(); - - // We could simply chain one this.uploadChunk after another with - // .then(), but then we'd hit an `Uncaught RangeError: Maximum - // call stack size exceeded` at as low as 1024 calls in Firefox - // 47. This'll work around it, but comes with the drawback of - // having to properly relay the results to the returned promise. - // eslint-disable-next-line no-loop-func - promise.done( function ( start, end, next, result ) { - var filekey = result.upload.filekey; - active = this.uploadChunk( file, data, start, end, filekey, chunkRetries ) - .done( end === file.size ? deferred.resolve : next.resolve ) - .fail( deferred.reject ) - .progress( deferred.notify ); - // start, end & next must be bound to closure, or they'd have - // changed by the time the promises are resolved - }.bind( this, start, end, next ) ); - - promise = next; - } - - return deferred.promise( { abort: active.abort } ); - }, - - /** - * Uploads 1 chunk. - * - * @private - * @param {File} file - * @param {Object} data Other upload options, see action=upload API docs for more - * @param {number} start Chunk start position - * @param {number} end Chunk end position - * @param {string} [filekey] File key, for follow-up chunks - * @param {number} [retries] Amount of times to retry request - * @return {jQuery.Promise} - */ - uploadChunk: function ( file, data, start, end, filekey, retries ) { - var upload, - api = this, - chunk = this.slice( file, start, end ); - - // When uploading in chunks, we're going to be issuing a lot more - // requests and there's always a chance of 1 getting dropped. - // In such case, it could be useful to try again: a network hickup - // doesn't necessarily have to result in upload failure... - retries = retries === undefined ? 1 : retries; - - data.filesize = file.size; - data.chunk = chunk; - data.offset = start; - - // filekey must only be added when uploading follow-up chunks; the - // first chunk should never have a filekey (it'll be generated) - if ( filekey && start !== 0 ) { - data.filekey = filekey; - } - - upload = this.uploadWithFormData( file, data ); - return upload.then( - null, - function ( code, result ) { - var retry; - - // uploadWithFormData will reject uploads with warnings, but - // these warnings could be "harmless" or recovered from - // (e.g. exists-normalized, when it'll be renamed later) - // In the case of (only) a warning, we still want to - // continue the chunked upload until it completes: then - // reject it - at least it's been fully uploaded by then and - // failure handlers have a complete result object (including - // possibly more warnings, e.g. duplicate) - // This matches .upload, which also completes the upload. - if ( result.upload && result.upload.warnings && code in result.upload.warnings ) { - if ( end === file.size ) { - // uploaded last chunk = reject with result data - return $.Deferred().reject( code, result ); - } else { - // still uploading chunks = resolve to keep going - return $.Deferred().resolve( result ); - } - } - - if ( retries === 0 ) { - return $.Deferred().reject( code, result ); - } - - // If the call flat out failed, we may want to try again... - retry = api.uploadChunk.bind( this, file, data, start, end, filekey, retries - 1 ); - return api.retry( code, result, retry ); - }, - function ( fraction ) { - // Since we're only uploading small parts of a file, we - // need to adjust the reported progress to reflect where - // we actually are in the combined upload - return ( start + fraction * ( end - start ) ) / file.size; - } - ).promise( { abort: upload.abort } ); - }, - - /** - * Launch the upload anew if it failed because of network issues. - * - * @private - * @param {string} code Error code - * @param {Object} result API result - * @param {Function} callable - * @return {jQuery.Promise} - */ - retry: function ( code, result, callable ) { - var uploadPromise, - retryTimer, - deferred = $.Deferred(), - // Wrap around the callable, so that once it completes, it'll - // resolve/reject the promise we'll return - retry = function () { - uploadPromise = callable(); - uploadPromise.then( deferred.resolve, deferred.reject ); - }; - - // Don't retry if the request failed because we aborted it (or if - // it's another kind of request failure) - if ( code !== 'http' || result.textStatus === 'abort' ) { - return deferred.reject( code, result ); - } - - retryTimer = setTimeout( retry, 1000 ); - return deferred.promise( { abort: function () { - // Clear the scheduled upload, or abort if already in flight - if ( retryTimer ) { - clearTimeout( retryTimer ); - } - if ( uploadPromise.abort ) { - uploadPromise.abort(); - } - } } ); - }, - - /** - * Slice a chunk out of a File object. - * - * @private - * @param {File} file - * @param {number} start - * @param {number} stop - * @return {Blob} - */ - slice: function ( file, start, stop ) { - if ( file.mozSlice ) { - // FF <= 12 - return file.mozSlice( start, stop, file.type ); - } else if ( file.webkitSlice ) { - // Chrome <= 20 - return file.webkitSlice( start, stop, file.type ); - } else { - // On really old browser versions (before slice was prefixed), - // slice() would take (start, length) instead of (start, end) - // We'll ignore that here... - return file.slice( start, stop, file.type ); - } - }, - - /** - * This function will handle how uploads to stash (via uploadToStash or - * chunkedUploadToStash) are resolved/rejected. - * - * After a successful stash, it'll resolve with a callback which, when - * called, will finalize the upload in stash (with the given data, or - * with additional/conflicting data) - * - * A failed stash can still be recovered from as long as 'filekey' is - * present. In that case, it'll also resolve with the callback to - * finalize the upload (all warnings are then ignored.) - * Otherwise, it'll just reject as you'd expect, with code & result. - * - * @private - * @param {jQuery.Promise} uploadPromise - * @param {Object} data - * @return {jQuery.Promise} - * @return {Function} return.finishUpload Call this function to finish the upload. - * @return {Object} return.finishUpload.data Additional data for the upload. - * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload - * @return {Object} return.finishUpload.return.data API return value for the final upload - */ - finishUploadToStash: function ( uploadPromise, data ) { - var filekey, - api = this; - - function finishUpload( moreData ) { - return api.uploadFromStash( filekey, $.extend( data, moreData ) ); - } - - return uploadPromise.then( - function ( result ) { - filekey = result.upload.filekey; - return finishUpload; - }, - function ( errorCode, result ) { - if ( result && result.upload && result.upload.filekey ) { - // Ignore any warnings if 'filekey' was returned, that's all we care about - filekey = result.upload.filekey; - return $.Deferred().resolve( finishUpload ); - } - return $.Deferred().reject( errorCode, result ); - } - ); - }, - - /** - * Upload a file to the stash. - * - * This function will return a promise, which when resolved, will pass back a function - * to finish the stash upload. You can call that function with an argument containing - * more, or conflicting, data to pass to the server. For example: - * - * // upload a file to the stash with a placeholder filename - * api.uploadToStash( file, { filename: 'testing.png' } ).done( function ( finish ) { - * // finish is now the function we can use to finalize the upload - * // pass it a new filename from user input to override the initial value - * finish( { filename: getFilenameFromUser() } ).done( function ( data ) { - * // the upload is complete, data holds the API response - * } ); - * } ); - * - * @param {File|HTMLInputElement} file - * @param {Object} [data] - * @return {jQuery.Promise} - * @return {Function} return.finishUpload Call this function to finish the upload. - * @return {Object} return.finishUpload.data Additional data for the upload. - * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload - * @return {Object} return.finishUpload.return.data API return value for the final upload - */ - uploadToStash: function ( file, data ) { - var promise; - - if ( !data.filename ) { - throw new Error( 'Filename not included in file data.' ); - } - - promise = this.upload( file, { stash: true, filename: data.filename } ); - - return this.finishUploadToStash( promise, data ); - }, - - /** - * Upload a file to the stash, in chunks. - * - * This function will return a promise, which when resolved, will pass back a function - * to finish the stash upload. - * - * @see #method-uploadToStash - * @param {File|HTMLInputElement} file - * @param {Object} [data] - * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB) - * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1) - * @return {jQuery.Promise} - * @return {Function} return.finishUpload Call this function to finish the upload. - * @return {Object} return.finishUpload.data Additional data for the upload. - * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload - * @return {Object} return.finishUpload.return.data API return value for the final upload - */ - chunkedUploadToStash: function ( file, data, chunkSize, chunkRetries ) { - var promise; - - if ( !data.filename ) { - throw new Error( 'Filename not included in file data.' ); - } - - promise = this.chunkedUpload( - file, - { stash: true, filename: data.filename }, - chunkSize, - chunkRetries - ); - - return this.finishUploadToStash( promise, data ); - }, - - /** - * Finish an upload in the stash. - * - * @param {string} filekey - * @param {Object} data - * @return {jQuery.Promise} - */ - uploadFromStash: function ( filekey, data ) { - data.filekey = filekey; - data.action = 'upload'; - data.format = 'json'; - - if ( !data.filename ) { - throw new Error( 'Filename not included in file data.' ); - } - - return this.postWithEditToken( data ).then( function ( result ) { - if ( result.upload && result.upload.warnings ) { - return $.Deferred().reject( getFirstKey( result.upload.warnings ), result ).promise(); - } - return result; - } ); - }, - - needToken: function () { - return true; - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.upload - */ -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.user.js b/resources/src/mediawiki.api.user.js deleted file mode 100644 index e7b4b6d54f..0000000000 --- a/resources/src/mediawiki.api.user.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @class mw.Api.plugin.user - * @since 1.27 - */ -( function ( mw, $ ) { - - $.extend( mw.Api.prototype, { - - /** - * Get the current user's groups and rights. - * - * @return {jQuery.Promise} - * @return {Function} return.done - * @return {Object} return.done.userInfo - * @return {string[]} return.done.userInfo.groups User groups that the current user belongs to - * @return {string[]} return.done.userInfo.rights Current user's rights - */ - getUserInfo: function () { - return this.get( { - action: 'query', - meta: 'userinfo', - uiprop: [ 'groups', 'rights' ] - } ).then( function ( data ) { - if ( data.query && data.query.userinfo ) { - return data.query.userinfo; - } - return $.Deferred().reject().promise(); - } ); - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.user - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api.watch.js b/resources/src/mediawiki.api.watch.js deleted file mode 100644 index 025c111e84..0000000000 --- a/resources/src/mediawiki.api.watch.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @class mw.Api.plugin.watch - * @since 1.19 - */ -( function ( mw, $ ) { - - /** - * @private - * @static - * @context mw.Api - * - * @param {string|mw.Title|string[]|mw.Title[]} pages Full page name or instance of mw.Title, or an - * array thereof. If an array is passed, the return value passed to the promise will also be an - * array of appropriate objects. - * @param {Object} [addParams] - * @return {jQuery.Promise} - * @return {Function} return.done - * @return {Object|Object[]} return.done.watch Object or list of objects (depends on the `pages` - * parameter) - * @return {string} return.done.watch.title Full pagename - * @return {boolean} return.done.watch.watched Whether the page is now watched or unwatched - */ - function doWatchInternal( pages, addParams ) { - // XXX: Parameter addParams is undocumented because we inherit this - // documentation in the public method... - var apiPromise = this.postWithToken( 'watch', - $.extend( - { - formatversion: 2, - action: 'watch', - titles: Array.isArray( pages ) ? pages : String( pages ) - }, - addParams - ) - ); - - return apiPromise - .then( function ( data ) { - // If a single page was given (not an array) respond with a single item as well. - return Array.isArray( pages ) ? data.watch : data.watch[ 0 ]; - } ) - .promise( { abort: apiPromise.abort } ); - } - - $.extend( mw.Api.prototype, { - /** - * Convenience method for `action=watch`. - * - * @inheritdoc #doWatchInternal - */ - watch: function ( pages ) { - return doWatchInternal.call( this, pages ); - }, - - /** - * Convenience method for `action=watch&unwatch=1`. - * - * @inheritdoc #doWatchInternal - */ - unwatch: function ( pages ) { - return doWatchInternal.call( this, pages, { unwatch: 1 } ); - } - } ); - - /** - * @class mw.Api - * @mixins mw.Api.plugin.watch - */ - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/category.js b/resources/src/mediawiki.api/category.js new file mode 100644 index 0000000000..85df90e912 --- /dev/null +++ b/resources/src/mediawiki.api/category.js @@ -0,0 +1,101 @@ +/** + * @class mw.Api.plugin.category + */ +( function ( mw, $ ) { + + $.extend( mw.Api.prototype, { + /** + * Determine if a category exists. + * + * @param {mw.Title|string} title + * @return {jQuery.Promise} + * @return {Function} return.done + * @return {boolean} return.done.isCategory Whether the category exists. + */ + isCategory: function ( title ) { + var apiPromise = this.get( { + formatversion: 2, + prop: 'categoryinfo', + titles: [ String( title ) ] + } ); + + return apiPromise + .then( function ( data ) { + return !!( + data.query && // query is missing on title="" + data.query.pages && // query.pages is missing on title="#" or title="mw:" + data.query.pages[ 0 ].categoryinfo + ); + } ) + .promise( { abort: apiPromise.abort } ); + }, + + /** + * Get a list of categories that match a certain prefix. + * + * E.g. given "Foo", return "Food", "Foolish people", "Foosball tables"... + * + * @param {string} prefix Prefix to match. + * @return {jQuery.Promise} + * @return {Function} return.done + * @return {string[]} return.done.categories Matched categories + */ + getCategoriesByPrefix: function ( prefix ) { + // Fetch with allpages to only get categories that have a corresponding description page. + var apiPromise = this.get( { + formatversion: 2, + list: 'allpages', + apprefix: prefix, + apnamespace: mw.config.get( 'wgNamespaceIds' ).category + } ); + + return apiPromise + .then( function ( data ) { + return data.query.allpages.map( function ( category ) { + return new mw.Title( category.title ).getMainText(); + } ); + } ) + .promise( { abort: apiPromise.abort } ); + }, + + /** + * Get the categories that a particular page on the wiki belongs to. + * + * @param {mw.Title|string} title + * @return {jQuery.Promise} + * @return {Function} return.done + * @return {boolean|mw.Title[]} return.done.categories List of category titles or false + * if title was not found. + */ + getCategories: function ( title ) { + var apiPromise = this.get( { + formatversion: 2, + prop: 'categories', + titles: [ String( title ) ] + } ); + + return apiPromise + .then( function ( data ) { + var page; + + if ( !data.query || !data.query.pages ) { + return false; + } + page = data.query.pages[ 0 ]; + if ( !page.categories ) { + return false; + } + return page.categories.map( function ( cat ) { + return new mw.Title( cat.title ); + } ); + } ) + .promise( { abort: apiPromise.abort } ); + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.category + */ + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/edit.js b/resources/src/mediawiki.api/edit.js new file mode 100644 index 0000000000..e6f56680bd --- /dev/null +++ b/resources/src/mediawiki.api/edit.js @@ -0,0 +1,199 @@ +/** + * @class mw.Api.plugin.edit + */ +( function ( mw, $ ) { + + $.extend( mw.Api.prototype, { + + /** + * Post to API with csrf token. If we have no token, get one and try to post. + * If we have a cached token try using that, and if it fails, blank out the + * cached token and start over. + * + * @param {Object} params API parameters + * @param {Object} [ajaxOptions] + * @return {jQuery.Promise} See #post + */ + postWithEditToken: function ( params, ajaxOptions ) { + return this.postWithToken( 'csrf', params, ajaxOptions ); + }, + + /** + * API helper to grab a csrf token. + * + * @return {jQuery.Promise} Received token. + */ + getEditToken: function () { + return this.getToken( 'csrf' ); + }, + + /** + * Create a new page. + * + * Example: + * + * new mw.Api().create( 'Sandbox', + * { summary: 'Load sand particles.' }, + * 'Sand.' + * ); + * + * @since 1.28 + * @param {mw.Title|string} title Page title + * @param {Object} params Edit API parameters + * @param {string} params.summary Edit summary + * @param {string} content + * @return {jQuery.Promise} API response + */ + create: function ( title, params, content ) { + return this.postWithEditToken( $.extend( { + action: 'edit', + title: String( title ), + text: content, + formatversion: '2', + + // Protect against errors and conflicts + assert: mw.config.get( 'wgUserName' ) ? 'user' : undefined, + createonly: true + }, params ) ).then( function ( data ) { + return data.edit; + } ); + }, + + /** + * Edit an existing page. + * + * To create a new page, use #create() instead. + * + * Simple transformation: + * + * new mw.Api() + * .edit( 'Sandbox', function ( revision ) { + * return revision.content.replace( 'foo', 'bar' ); + * } ) + * .then( function () { + * console.log( 'Saved! '); + * } ); + * + * Set save parameters by returning an object instead of a string: + * + * new mw.Api().edit( + * 'Sandbox', + * function ( revision ) { + * return { + * text: revision.content.replace( 'foo', 'bar' ), + * summary: 'Replace "foo" with "bar".', + * assert: 'bot', + * minor: true + * }; + * } + * ) + * .then( function () { + * console.log( 'Saved! '); + * } ); + * + * Transform asynchronously by returning a promise. + * + * new mw.Api() + * .edit( 'Sandbox', function ( revision ) { + * return Spelling + * .corrections( revision.content ) + * .then( function ( report ) { + * return { + * text: report.output, + * summary: report.changelog + * }; + * } ); + * } ) + * .then( function () { + * console.log( 'Saved! '); + * } ); + * + * @since 1.28 + * @param {mw.Title|string} title Page title + * @param {Function} transform Callback that prepares the edit + * @param {Object} transform.revision Current revision + * @param {string} transform.revision.content Current revision content + * @param {string|Object|jQuery.Promise} transform.return New content, object with edit + * API parameters, or promise providing one of those. + * @return {jQuery.Promise} Edit API response + */ + edit: function ( title, transform ) { + var basetimestamp, curtimestamp, + api = this; + + title = String( title ); + + return api.get( { + action: 'query', + prop: 'revisions', + rvprop: [ 'content', 'timestamp' ], + titles: [ title ], + formatversion: '2', + curtimestamp: true + } ) + .then( function ( data ) { + var page, revision; + if ( !data.query || !data.query.pages ) { + return $.Deferred().reject( 'unknown' ); + } + page = data.query.pages[ 0 ]; + if ( !page || page.invalid ) { + return $.Deferred().reject( 'invalidtitle' ); + } + if ( page.missing ) { + return $.Deferred().reject( 'nocreate-missing' ); + } + revision = page.revisions[ 0 ]; + basetimestamp = revision.timestamp; + curtimestamp = data.curtimestamp; + return transform( { + timestamp: revision.timestamp, + content: revision.content + } ); + } ) + .then( function ( params ) { + var editParams = typeof params === 'object' ? params : { text: String( params ) }; + return api.postWithEditToken( $.extend( { + action: 'edit', + title: title, + formatversion: '2', + + // Protect against errors and conflicts + assert: mw.config.get( 'wgUserName' ) ? 'user' : undefined, + basetimestamp: basetimestamp, + starttimestamp: curtimestamp, + nocreate: true + }, editParams ) ); + } ) + .then( function ( data ) { + return data.edit; + } ); + }, + + /** + * Post a new section to the page. + * + * @see #postWithEditToken + * @param {mw.Title|string} title Target page + * @param {string} header + * @param {string} message wikitext message + * @param {Object} [additionalParams] Additional API parameters, e.g. `{ redirect: true }` + * @return {jQuery.Promise} + */ + newSection: function ( title, header, message, additionalParams ) { + return this.postWithEditToken( $.extend( { + action: 'edit', + section: 'new', + title: String( title ), + summary: header, + text: message + }, additionalParams ) ); + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.edit + */ + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/index.js b/resources/src/mediawiki.api/index.js new file mode 100644 index 0000000000..0038ed8ecf --- /dev/null +++ b/resources/src/mediawiki.api/index.js @@ -0,0 +1,506 @@ +( function ( mw, $ ) { + + /** + * @class mw.Api + */ + + /** + * @property {Object} defaultOptions Default options for #ajax calls. Can be overridden by passing + * `options` to mw.Api constructor. + * @property {Object} defaultOptions.parameters Default query parameters for API requests. + * @property {Object} defaultOptions.ajax Default options for jQuery#ajax. + * @property {boolean} defaultOptions.useUS Whether to use U+001F when joining multi-valued + * parameters (since 1.28). Default is true if ajax.url is not set, false otherwise for + * compatibility. + * @private + */ + var defaultOptions = { + parameters: { + action: 'query', + format: 'json' + }, + ajax: { + url: mw.util.wikiScript( 'api' ), + timeout: 30 * 1000, // 30 seconds + dataType: 'json' + } + }, + + // Keyed by ajax url and symbolic name for the individual request + promises = {}; + + function mapLegacyToken( action ) { + // Legacy types for backward-compatibility with API action=tokens. + var csrfActions = [ + 'edit', + 'delete', + 'protect', + 'move', + 'block', + 'unblock', + 'email', + 'import', + 'options' + ]; + if ( csrfActions.indexOf( action ) !== -1 ) { + mw.track( 'mw.deprecate', 'apitoken_' + action ); + mw.log.warn( 'Use of the "' + action + '" token is deprecated. Use "csrf" instead.' ); + return 'csrf'; + } + return action; + } + + // Pre-populate with fake ajax promises to save http requests for tokens + // we already have on the page via the user.tokens module (T36733). + promises[ defaultOptions.ajax.url ] = {}; + $.each( mw.user.tokens.get(), function ( key, value ) { + // This requires #getToken to use the same key as user.tokens. + // Format: token-type + "Token" (eg. csrfToken, patrolToken, watchToken). + promises[ defaultOptions.ajax.url ][ key ] = $.Deferred() + .resolve( value ) + .promise( { abort: function () {} } ); + } ); + + /** + * Constructor to create an object to interact with the API of a particular MediaWiki server. + * mw.Api objects represent the API of a particular MediaWiki server. + * + * var api = new mw.Api(); + * api.get( { + * action: 'query', + * meta: 'userinfo' + * } ).done( function ( data ) { + * console.log( data ); + * } ); + * + * Since MW 1.25, multiple values for a parameter can be specified using an array: + * + * var api = new mw.Api(); + * api.get( { + * action: 'query', + * meta: [ 'userinfo', 'siteinfo' ] // same effect as 'userinfo|siteinfo' + * } ).done( function ( data ) { + * console.log( data ); + * } ); + * + * Since MW 1.26, boolean values for a parameter can be specified directly. If the value is + * `false` or `undefined`, the parameter will be omitted from the request, as required by the API. + * + * @constructor + * @param {Object} [options] See #defaultOptions documentation above. Can also be overridden for + * each individual request by passing them to #get or #post (or directly #ajax) later on. + */ + mw.Api = function ( options ) { + options = options || {}; + + // Force a string if we got a mw.Uri object + if ( options.ajax && options.ajax.url !== undefined ) { + options.ajax.url = String( options.ajax.url ); + } + + options = $.extend( { useUS: !options.ajax || !options.ajax.url }, options ); + + options.parameters = $.extend( {}, defaultOptions.parameters, options.parameters ); + options.ajax = $.extend( {}, defaultOptions.ajax, options.ajax ); + + this.defaults = options; + this.requests = []; + }; + + mw.Api.prototype = { + /** + * Abort all unfinished requests issued by this Api object. + * + * @method + */ + abort: function () { + this.requests.forEach( function ( request ) { + if ( request ) { + request.abort(); + } + } ); + }, + + /** + * Perform API get request + * + * @param {Object} parameters + * @param {Object} [ajaxOptions] + * @return {jQuery.Promise} + */ + get: function ( parameters, ajaxOptions ) { + ajaxOptions = ajaxOptions || {}; + ajaxOptions.type = 'GET'; + return this.ajax( parameters, ajaxOptions ); + }, + + /** + * Perform API post request + * + * @param {Object} parameters + * @param {Object} [ajaxOptions] + * @return {jQuery.Promise} + */ + post: function ( parameters, ajaxOptions ) { + ajaxOptions = ajaxOptions || {}; + ajaxOptions.type = 'POST'; + return this.ajax( parameters, ajaxOptions ); + }, + + /** + * Massage parameters from the nice format we accept into a format suitable for the API. + * + * NOTE: A value of undefined/null in an array will be represented by Array#join() + * as the empty string. Should we filter silently? Warn? Leave as-is? + * + * @private + * @param {Object} parameters (modified in-place) + * @param {boolean} useUS Whether to use U+001F when joining multi-valued parameters. + */ + preprocessParameters: function ( parameters, useUS ) { + var key; + // Handle common MediaWiki API idioms for passing parameters + for ( key in parameters ) { + // Multiple values are pipe-separated + if ( Array.isArray( parameters[ key ] ) ) { + if ( !useUS || parameters[ key ].join( '' ).indexOf( '|' ) === -1 ) { + parameters[ key ] = parameters[ key ].join( '|' ); + } else { + parameters[ key ] = '\x1f' + parameters[ key ].join( '\x1f' ); + } + } else if ( parameters[ key ] === false || parameters[ key ] === undefined ) { + // Boolean values are only false when not given at all + delete parameters[ key ]; + } + } + }, + + /** + * Perform the API call. + * + * @param {Object} parameters + * @param {Object} [ajaxOptions] + * @return {jQuery.Promise} Done: API response data and the jqXHR object. + * Fail: Error code + */ + ajax: function ( parameters, ajaxOptions ) { + var token, requestIndex, + api = this, + apiDeferred = $.Deferred(), + xhr, key, formData; + + parameters = $.extend( {}, this.defaults.parameters, parameters ); + ajaxOptions = $.extend( {}, this.defaults.ajax, ajaxOptions ); + + // Ensure that token parameter is last (per [[mw:API:Edit#Token]]). + if ( parameters.token ) { + token = parameters.token; + delete parameters.token; + } + + this.preprocessParameters( parameters, this.defaults.useUS ); + + // If multipart/form-data has been requested and emulation is possible, emulate it + if ( + ajaxOptions.type === 'POST' && + window.FormData && + ajaxOptions.contentType === 'multipart/form-data' + ) { + + formData = new FormData(); + + for ( key in parameters ) { + formData.append( key, parameters[ key ] ); + } + // If we extracted a token parameter, add it back in. + if ( token ) { + formData.append( 'token', token ); + } + + ajaxOptions.data = formData; + + // Prevent jQuery from mangling our FormData object + ajaxOptions.processData = false; + // Prevent jQuery from overriding the Content-Type header + ajaxOptions.contentType = false; + } else { + // This works because jQuery accepts data as a query string or as an Object + ajaxOptions.data = $.param( parameters ); + // If we extracted a token parameter, add it back in. + if ( token ) { + ajaxOptions.data += '&token=' + encodeURIComponent( token ); + } + + // Depending on server configuration, MediaWiki may forbid periods in URLs, due to an IE 6 + // XSS bug. So let's escape them here. See WebRequest::checkUrlExtension() and T30235. + ajaxOptions.data = ajaxOptions.data.replace( /\./g, '%2E' ); + + if ( ajaxOptions.contentType === 'multipart/form-data' ) { + // We were asked to emulate but can't, so drop the Content-Type header, otherwise + // it'll be wrong and the server will fail to decode the POST body + delete ajaxOptions.contentType; + } + } + + // Make the AJAX request + xhr = $.ajax( ajaxOptions ) + // If AJAX fails, reject API call with error code 'http' + // and details in second argument. + .fail( function ( xhr, textStatus, exception ) { + apiDeferred.reject( 'http', { + xhr: xhr, + textStatus: textStatus, + exception: exception + } ); + } ) + // AJAX success just means "200 OK" response, also check API error codes + .done( function ( result, textStatus, jqXHR ) { + var code; + if ( result === undefined || result === null || result === '' ) { + apiDeferred.reject( 'ok-but-empty', + 'OK response but empty result (check HTTP headers?)', + result, + jqXHR + ); + } else if ( result.error ) { + // errorformat=bc + code = result.error.code === undefined ? 'unknown' : result.error.code; + apiDeferred.reject( code, result, result, jqXHR ); + } else if ( result.errors ) { + // errorformat!=bc + code = result.errors[ 0 ].code === undefined ? 'unknown' : result.errors[ 0 ].code; + apiDeferred.reject( code, result, result, jqXHR ); + } else { + apiDeferred.resolve( result, jqXHR ); + } + } ); + + requestIndex = this.requests.length; + this.requests.push( xhr ); + xhr.always( function () { + api.requests[ requestIndex ] = null; + } ); + // Return the Promise + return apiDeferred.promise( { abort: xhr.abort } ).fail( function ( code, details ) { + if ( !( code === 'http' && details && details.textStatus === 'abort' ) ) { + mw.log( 'mw.Api error: ', code, details ); + } + } ); + }, + + /** + * Post to API with specified type of token. If we have no token, get one and try to post. + * If we have a cached token try using that, and if it fails, blank out the + * cached token and start over. For example to change an user option you could do: + * + * new mw.Api().postWithToken( 'csrf', { + * action: 'options', + * optionname: 'gender', + * optionvalue: 'female' + * } ); + * + * @param {string} tokenType The name of the token, like options or edit. + * @param {Object} params API parameters + * @param {Object} [ajaxOptions] + * @return {jQuery.Promise} See #post + * @since 1.22 + */ + postWithToken: function ( tokenType, params, ajaxOptions ) { + var api = this, + abortedPromise = $.Deferred().reject( 'http', + { textStatus: 'abort', exception: 'abort' } ).promise(), + abortable, + aborted; + + return api.getToken( tokenType, params.assert ).then( function ( token ) { + params.token = token; + // Request was aborted while token request was running, but we + // don't want to unnecessarily abort token requests, so abort + // a fake request instead + if ( aborted ) { + return abortedPromise; + } + + return ( abortable = api.post( params, ajaxOptions ) ).catch( + // Error handler + function ( code ) { + if ( code === 'badtoken' ) { + api.badToken( tokenType ); + // Try again, once + params.token = undefined; + abortable = null; + return api.getToken( tokenType, params.assert ).then( function ( token ) { + params.token = token; + if ( aborted ) { + return abortedPromise; + } + + return ( abortable = api.post( params, ajaxOptions ) ); + } ); + } + + // Let caller handle the error code + return $.Deferred().rejectWith( this, arguments ); + } + ); + } ).promise( { abort: function () { + if ( abortable ) { + abortable.abort(); + } else { + aborted = true; + } + } } ); + }, + + /** + * Get a token for a certain action from the API. + * + * The assert parameter is only for internal use by #postWithToken. + * + * @since 1.22 + * @param {string} type Token type + * @param {string} [assert] + * @return {jQuery.Promise} Received token. + */ + getToken: function ( type, assert ) { + var apiPromise, promiseGroup, d, reject; + type = mapLegacyToken( type ); + promiseGroup = promises[ this.defaults.ajax.url ]; + d = promiseGroup && promiseGroup[ type + 'Token' ]; + + if ( !promiseGroup ) { + promiseGroup = promises[ this.defaults.ajax.url ] = {}; + } + + if ( !d ) { + apiPromise = this.get( { + action: 'query', + meta: 'tokens', + type: type, + assert: assert + } ); + reject = function () { + // Clear promise. Do not cache errors. + delete promiseGroup[ type + 'Token' ]; + + // Let caller handle the error code + return $.Deferred().rejectWith( this, arguments ); + }; + d = apiPromise + .then( function ( res ) { + if ( !res.query ) { + return reject( 'query-missing', res ); + } + // If token type is unknown, it is omitted from the response + if ( !res.query.tokens[ type + 'token' ] ) { + return $.Deferred().reject( 'token-missing', res ); + } + return res.query.tokens[ type + 'token' ]; + }, reject ) + // Attach abort handler + .promise( { abort: apiPromise.abort } ); + + // Store deferred now so that we can use it again even if it isn't ready yet + promiseGroup[ type + 'Token' ] = d; + } + + return d; + }, + + /** + * Indicate that the cached token for a certain action of the API is bad. + * + * Call this if you get a 'badtoken' error when using the token returned by #getToken. + * You may also want to use #postWithToken instead, which invalidates bad cached tokens + * automatically. + * + * @param {string} type Token type + * @since 1.26 + */ + badToken: function ( type ) { + var promiseGroup = promises[ this.defaults.ajax.url ]; + + type = mapLegacyToken( type ); + if ( promiseGroup ) { + delete promiseGroup[ type + 'Token' ]; + } + } + }; + + /** + * @static + * @property {Array} + * Very incomplete and outdated list of errors we might receive from the API. Do not use. + * @deprecated since 1.29 + */ + mw.Api.errors = [ + // occurs when POST aborted + // jQuery 1.4 can't distinguish abort or lost connection from 200 OK + empty result + 'ok-but-empty', + + // timeout + 'timeout', + + // really a warning, but we treat it like an error + 'duplicate', + 'duplicate-archive', + + // upload succeeded, but no image info. + // this is probably impossible, but might as well check for it + 'noimageinfo', + // remote errors, defined in API + 'uploaddisabled', + 'nomodule', + 'mustbeposted', + 'badaccess-groups', + 'missingresult', + 'missingparam', + 'invalid-file-key', + 'copyuploaddisabled', + 'mustbeloggedin', + 'empty-file', + 'file-too-large', + 'filetype-missing', + 'filetype-banned', + 'filetype-banned-type', + 'filename-tooshort', + 'illegal-filename', + 'verification-error', + 'hookaborted', + 'unknown-error', + 'internal-error', + 'overwrite', + 'badtoken', + 'fetchfileerror', + 'fileexists-shared-forbidden', + 'invalidtitle', + 'notloggedin', + 'autoblocked', + 'blocked', + + // Stash-specific errors - expanded + 'stashfailed', + 'stasherror', + 'stashedfilenotfound', + 'stashpathinvalid', + 'stashfilestorage', + 'stashzerolength', + 'stashnotloggedin', + 'stashwrongowner', + 'stashnosuchfilekey' + ]; + mw.log.deprecate( mw.Api, 'errors', mw.Api.errors, null, 'mw.Api.errors' ); + + /** + * @static + * @property {Array} + * Very incomplete and outdated list of warnings we might receive from the API. Do not use. + * @deprecated since 1.29 + */ + mw.Api.warnings = [ + 'duplicate', + 'exists' + ]; + mw.log.deprecate( mw.Api, 'warnings', mw.Api.warnings, null, 'mw.Api.warnings' ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/login.js b/resources/src/mediawiki.api/login.js new file mode 100644 index 0000000000..2b709aae7b --- /dev/null +++ b/resources/src/mediawiki.api/login.js @@ -0,0 +1,60 @@ +/** + * Make the two-step login easier. + * + * @author Niklas Laxström + * @class mw.Api.plugin.login + * @since 1.22 + */ +( function ( mw, $ ) { + 'use strict'; + + $.extend( mw.Api.prototype, { + /** + * @param {string} username + * @param {string} password + * @return {jQuery.Promise} See mw.Api#post + */ + login: function ( username, password ) { + var params, apiPromise, innerPromise, + api = this; + + params = { + action: 'login', + lgname: username, + lgpassword: password + }; + + apiPromise = api.post( params ); + + return apiPromise + .then( function ( data ) { + params.lgtoken = data.login.token; + innerPromise = api.post( params ) + .then( function ( data ) { + var code; + if ( data.login.result !== 'Success' ) { + // Set proper error code whenever possible + code = data.error && data.error.code || 'unknown'; + return $.Deferred().reject( code, data ); + } + return data; + } ); + return innerPromise; + } ) + .promise( { + abort: function () { + apiPromise.abort(); + if ( innerPromise ) { + innerPromise.abort(); + } + } + } ); + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.login + */ + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/messages.js b/resources/src/mediawiki.api/messages.js new file mode 100644 index 0000000000..688f0b2435 --- /dev/null +++ b/resources/src/mediawiki.api/messages.js @@ -0,0 +1,78 @@ +/** + * Allows to retrieve a specific or a set of + * messages to be added to mw.messages and returned + * by the Api. + * + * @class mw.Api.plugin.messages + * @since 1.27 + */ +( function ( mw, $ ) { + 'use strict'; + + $.extend( mw.Api.prototype, { + /** + * Get a set of messages. + * + * @param {Array} messages Messages to retrieve + * @param {Object} [options] Additional parameters for the API call + * @return {jQuery.Promise} + */ + getMessages: function ( messages, options ) { + options = options || {}; + return this.get( $.extend( { + action: 'query', + meta: 'allmessages', + ammessages: messages, + amlang: mw.config.get( 'wgUserLanguage' ), + formatversion: 2 + }, options ) ).then( function ( data ) { + var result = {}; + + data.query.allmessages.forEach( function ( obj ) { + if ( !obj.missing ) { + result[ obj.name ] = obj.content; + } + } ); + + return result; + } ); + }, + + /** + * Loads a set of messages and add them to mw.messages. + * + * @param {Array} messages Messages to retrieve + * @param {Object} [options] Additional parameters for the API call + * @return {jQuery.Promise} + */ + loadMessages: function ( messages, options ) { + return this.getMessages( messages, options ).then( $.proxy( mw.messages, 'set' ) ); + }, + + /** + * Loads a set of messages and add them to mw.messages. Only messages that are not already known + * are loaded. If all messages are known, the returned promise is resolved immediately. + * + * @param {Array} messages Messages to retrieve + * @param {Object} [options] Additional parameters for the API call + * @return {jQuery.Promise} + */ + loadMessagesIfMissing: function ( messages, options ) { + var missing = messages.filter( function ( msg ) { + return !mw.message( msg ).exists(); + } ); + + if ( missing.length === 0 ) { + return $.Deferred().resolve(); + } + + return this.getMessages( missing, options ).then( $.proxy( mw.messages, 'set' ) ); + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.messages + */ + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/options.js b/resources/src/mediawiki.api/options.js new file mode 100644 index 0000000000..4930c4fccc --- /dev/null +++ b/resources/src/mediawiki.api/options.js @@ -0,0 +1,102 @@ +/** + * @class mw.Api.plugin.options + */ +( function ( mw, $ ) { + + $.extend( mw.Api.prototype, { + + /** + * Asynchronously save the value of a single user option using the API. See #saveOptions. + * + * @param {string} name + * @param {string|null} value + * @return {jQuery.Promise} + */ + saveOption: function ( name, value ) { + var param = {}; + param[ name ] = value; + return this.saveOptions( param ); + }, + + /** + * Asynchronously save the values of user options using the API. + * + * If a value of `null` is provided, the given option will be reset to the default value. + * + * Any warnings returned by the API, including warnings about invalid option names or values, + * are ignored. However, do not rely on this behavior. + * + * If necessary, the options will be saved using several sequential API requests. Only one promise + * is always returned that will be resolved when all requests complete. + * + * @param {Object} options Options as a `{ name: value, … }` object + * @return {jQuery.Promise} + */ + saveOptions: function ( options ) { + var name, value, bundleable, + grouped = [], + promise = $.Deferred().resolve(); + + for ( name in options ) { + value = options[ name ] === null ? null : String( options[ name ] ); + + // Can we bundle this option, or does it need a separate request? + if ( this.defaults.useUS ) { + bundleable = name.indexOf( '=' ) === -1; + } else { + bundleable = + ( value === null || value.indexOf( '|' ) === -1 ) && + ( name.indexOf( '|' ) === -1 && name.indexOf( '=' ) === -1 ); + } + + if ( bundleable ) { + if ( value !== null ) { + grouped.push( name + '=' + value ); + } else { + // Omitting value resets the option + grouped.push( name ); + } + } else { + if ( value !== null ) { + promise = promise.then( function ( name, value ) { + return this.postWithToken( 'csrf', { + formatversion: 2, + action: 'options', + optionname: name, + optionvalue: value + } ); + }.bind( this, name, value ) ); + } else { + // Omitting value resets the option + promise = promise.then( function ( name ) { + return this.postWithToken( 'csrf', { + formatversion: 2, + action: 'options', + optionname: name + } ); + }.bind( this, name ) ); + } + } + } + + if ( grouped.length ) { + promise = promise.then( function () { + return this.postWithToken( 'csrf', { + formatversion: 2, + action: 'options', + change: grouped + } ); + }.bind( this ) ); + } + + return promise; + } + + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.options + */ + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/parse.js b/resources/src/mediawiki.api/parse.js new file mode 100644 index 0000000000..f38e88b5c3 --- /dev/null +++ b/resources/src/mediawiki.api/parse.js @@ -0,0 +1,49 @@ +/** + * @class mw.Api.plugin.parse + */ +( function ( mw, $ ) { + + $.extend( mw.Api.prototype, { + /** + * Convenience method for 'action=parse'. + * + * @param {string|mw.Title} content Content to parse, either as a wikitext string or + * a mw.Title. + * @param {Object} additionalParams Parameters object to set custom settings, e.g. + * redirects, sectionpreview. prop should not be overridden. + * @return {jQuery.Promise} + * @return {Function} return.done + * @return {string} return.done.data Parsed HTML of `wikitext`. + */ + parse: function ( content, additionalParams ) { + var apiPromise, + config = $.extend( { + formatversion: 2, + action: 'parse', + contentmodel: 'wikitext' + }, additionalParams ); + + if ( mw.Title && content instanceof mw.Title ) { + // Parse existing page + config.page = content.getPrefixedDb(); + } else { + // Parse wikitext from input + config.text = String( content ); + } + + apiPromise = this.get( config ); + + return apiPromise + .then( function ( data ) { + return data.parse.text; + } ) + .promise( { abort: apiPromise.abort } ); + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.parse + */ + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/rollback.js b/resources/src/mediawiki.api/rollback.js new file mode 100644 index 0000000000..322143dc5f --- /dev/null +++ b/resources/src/mediawiki.api/rollback.js @@ -0,0 +1,33 @@ +/** + * @class mw.Api.plugin.rollback + * @since 1.28 + */ +( function ( mw, $ ) { + + $.extend( mw.Api.prototype, { + /** + * Convenience method for `action=rollback`. + * + * @param {string|mw.Title} page + * @param {string} user + * @param {Object} [params] Additional parameters + * @return {jQuery.Promise} + */ + rollback: function ( page, user, params ) { + return this.postWithToken( 'rollback', $.extend( { + action: 'rollback', + title: String( page ), + user: user, + uselang: mw.config.get( 'wgUserLanguage' ) + }, params ) ).then( function ( data ) { + return data.rollback; + } ); + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.rollback + */ + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/upload.js b/resources/src/mediawiki.api/upload.js new file mode 100644 index 0000000000..29bd59aed9 --- /dev/null +++ b/resources/src/mediawiki.api/upload.js @@ -0,0 +1,668 @@ +/** + * Provides an interface for uploading files to MediaWiki. + * + * @class mw.Api.plugin.upload + * @singleton + */ +( function ( mw, $ ) { + var nonce = 0, + fieldsAllowed = { + stash: true, + filekey: true, + filename: true, + comment: true, + text: true, + watchlist: true, + ignorewarnings: true, + chunk: true, + offset: true, + filesize: true, + async: true + }; + + /** + * Get nonce for iframe IDs on the page. + * + * @private + * @return {number} + */ + function getNonce() { + return nonce++; + } + + /** + * Given a non-empty object, return one of its keys. + * + * @private + * @param {Object} obj + * @return {string} + */ + function getFirstKey( obj ) { + var key; + for ( key in obj ) { + if ( obj.hasOwnProperty( key ) ) { + return key; + } + } + } + + /** + * Get new iframe object for an upload. + * + * @private + * @param {string} id + * @return {HTMLIframeElement} + */ + function getNewIframe( id ) { + var frame = document.createElement( 'iframe' ); + frame.id = id; + frame.name = id; + return frame; + } + + /** + * Shortcut for getting hidden inputs + * + * @private + * @param {string} name + * @param {string} val + * @return {jQuery} + */ + function getHiddenInput( name, val ) { + return $( '' ).attr( 'type', 'hidden' ) + .attr( 'name', name ) + .val( val ); + } + + /** + * Process the result of the form submission, returned to an iframe. + * This is the iframe's onload event. + * + * @param {HTMLIframeElement} iframe Iframe to extract result from + * @return {Object} Response from the server. The return value may or may + * not be an XMLDocument, this code was copied from elsewhere, so if you + * see an unexpected return type, please file a bug. + */ + function processIframeResult( iframe ) { + var json, + doc = iframe.contentDocument || frames[ iframe.id ].document; + + if ( doc.XMLDocument ) { + // The response is a document property in IE + return doc.XMLDocument; + } + + if ( doc.body ) { + // Get the json string + // We're actually searching through an HTML doc here -- + // according to mdale we need to do this + // because IE does not load JSON properly in an iframe + json = $( doc.body ).find( 'pre' ).text(); + + return JSON.parse( json ); + } + + // Response is a xml document + return doc; + } + + function formDataAvailable() { + return window.FormData !== undefined && + window.File !== undefined && + window.File.prototype.slice !== undefined; + } + + $.extend( mw.Api.prototype, { + /** + * Upload a file to MediaWiki. + * + * The file will be uploaded using AJAX and FormData, if the browser supports it, or via an + * iframe if it doesn't. + * + * Caveats of iframe upload: + * - The returned jQuery.Promise will not receive `progress` notifications during the upload + * - It is incompatible with uploads to a foreign wiki using mw.ForeignApi + * - You must pass a HTMLInputElement and not a File for it to be possible + * + * @param {HTMLInputElement|File|Blob} file HTML input type=file element with a file already inside + * of it, or a File object. + * @param {Object} data Other upload options, see action=upload API docs for more + * @return {jQuery.Promise} + */ + upload: function ( file, data ) { + var isFileInput, canUseFormData; + + isFileInput = file && file.nodeType === Node.ELEMENT_NODE; + + if ( formDataAvailable() && isFileInput && file.files ) { + file = file.files[ 0 ]; + } + + if ( !file ) { + throw new Error( 'No file' ); + } + + // Blobs are allowed in formdata uploads, it turns out + canUseFormData = formDataAvailable() && ( file instanceof window.File || file instanceof window.Blob ); + + if ( !isFileInput && !canUseFormData ) { + throw new Error( 'Unsupported argument type passed to mw.Api.upload' ); + } + + if ( canUseFormData ) { + return this.uploadWithFormData( file, data ); + } + + return this.uploadWithIframe( file, data ); + }, + + /** + * Upload a file to MediaWiki with an iframe and a form. + * + * This method is necessary for browsers without the File/FormData + * APIs, and continues to work in browsers with those APIs. + * + * The rough sketch of how this method works is as follows: + * 1. An iframe is loaded with no content. + * 2. A form is submitted with the passed-in file input and some extras. + * 3. The MediaWiki API receives that form data, and sends back a response. + * 4. The response is sent to the iframe, because we set target=(iframe id) + * 5. The response is parsed out of the iframe's document, and passed back + * through the promise. + * + * @private + * @param {HTMLInputElement} file The file input with a file in it. + * @param {Object} data Other upload options, see action=upload API docs for more + * @return {jQuery.Promise} + */ + uploadWithIframe: function ( file, data ) { + var key, + tokenPromise = $.Deferred(), + api = this, + deferred = $.Deferred(), + nonce = getNonce(), + id = 'uploadframe-' + nonce, + $form = $( '' ), + iframe = getNewIframe( id ), + $iframe = $( iframe ); + + for ( key in data ) { + if ( !fieldsAllowed[ key ] ) { + delete data[ key ]; + } + } + + data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data ); + $form.addClass( 'mw-api-upload-form' ); + + $form.css( 'display', 'none' ) + .attr( { + action: this.defaults.ajax.url, + method: 'POST', + target: id, + enctype: 'multipart/form-data' + } ); + + $iframe.one( 'load', function () { + $iframe.one( 'load', function () { + var result = processIframeResult( iframe ); + deferred.notify( 1 ); + + if ( !result ) { + deferred.reject( 'ok-but-empty', 'No response from API on upload attempt.' ); + } else if ( result.error ) { + if ( result.error.code === 'badtoken' ) { + api.badToken( 'csrf' ); + } + + deferred.reject( result.error.code, result ); + } else if ( result.upload && result.upload.warnings ) { + deferred.reject( getFirstKey( result.upload.warnings ), result ); + } else { + deferred.resolve( result ); + } + } ); + tokenPromise.done( function () { + $form.submit(); + } ); + } ); + + $iframe.on( 'error', function ( error ) { + deferred.reject( 'http', error ); + } ); + + $iframe.prop( 'src', 'about:blank' ).hide(); + + file.name = 'file'; + + $.each( data, function ( key, val ) { + $form.append( getHiddenInput( key, val ) ); + } ); + + if ( !data.filename && !data.stash ) { + throw new Error( 'Filename not included in file data.' ); + } + + if ( this.needToken() ) { + this.getEditToken().then( function ( token ) { + $form.append( getHiddenInput( 'token', token ) ); + tokenPromise.resolve(); + }, tokenPromise.reject ); + } else { + tokenPromise.resolve(); + } + + $( 'body' ).append( $form, $iframe ); + + deferred.always( function () { + $form.remove(); + $iframe.remove(); + } ); + + return deferred.promise(); + }, + + /** + * Uploads a file using the FormData API. + * + * @private + * @param {File} file + * @param {Object} data Other upload options, see action=upload API docs for more + * @return {jQuery.Promise} + */ + uploadWithFormData: function ( file, data ) { + var key, request, + deferred = $.Deferred(); + + for ( key in data ) { + if ( !fieldsAllowed[ key ] ) { + delete data[ key ]; + } + } + + data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data ); + if ( !data.chunk ) { + data.file = file; + } + + if ( !data.filename && !data.stash ) { + throw new Error( 'Filename not included in file data.' ); + } + + // Use this.postWithEditToken() or this.post() + request = this[ this.needToken() ? 'postWithEditToken' : 'post' ]( data, { + // Use FormData (if we got here, we know that it's available) + contentType: 'multipart/form-data', + // No timeout (default from mw.Api is 30 seconds) + timeout: 0, + // Provide upload progress notifications + xhr: function () { + var xhr = $.ajaxSettings.xhr(); + if ( xhr.upload ) { + // need to bind this event before we open the connection (see note at + // https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest/Using_XMLHttpRequest#Monitoring_progress) + xhr.upload.addEventListener( 'progress', function ( ev ) { + if ( ev.lengthComputable ) { + deferred.notify( ev.loaded / ev.total ); + } + } ); + } + return xhr; + } + } ) + .done( function ( result ) { + deferred.notify( 1 ); + if ( result.upload && result.upload.warnings ) { + deferred.reject( getFirstKey( result.upload.warnings ), result ); + } else { + deferred.resolve( result ); + } + } ) + .fail( function ( errorCode, result ) { + deferred.notify( 1 ); + deferred.reject( errorCode, result ); + } ); + + return deferred.promise( { abort: request.abort } ); + }, + + /** + * Upload a file in several chunks. + * + * @param {File} file + * @param {Object} data Other upload options, see action=upload API docs for more + * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB) + * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1) + * @return {jQuery.Promise} + */ + chunkedUpload: function ( file, data, chunkSize, chunkRetries ) { + var start, end, promise, next, active, + deferred = $.Deferred(); + + chunkSize = chunkSize === undefined ? 5 * 1024 * 1024 : chunkSize; + chunkRetries = chunkRetries === undefined ? 1 : chunkRetries; + + if ( !data.filename ) { + throw new Error( 'Filename not included in file data.' ); + } + + // Submit first chunk to get the filekey + active = promise = this.uploadChunk( file, data, 0, chunkSize, '', chunkRetries ) + .done( chunkSize >= file.size ? deferred.resolve : null ) + .fail( deferred.reject ) + .progress( deferred.notify ); + + // Now iteratively submit the rest of the chunks + for ( start = chunkSize; start < file.size; start += chunkSize ) { + end = Math.min( start + chunkSize, file.size ); + next = $.Deferred(); + + // We could simply chain one this.uploadChunk after another with + // .then(), but then we'd hit an `Uncaught RangeError: Maximum + // call stack size exceeded` at as low as 1024 calls in Firefox + // 47. This'll work around it, but comes with the drawback of + // having to properly relay the results to the returned promise. + // eslint-disable-next-line no-loop-func + promise.done( function ( start, end, next, result ) { + var filekey = result.upload.filekey; + active = this.uploadChunk( file, data, start, end, filekey, chunkRetries ) + .done( end === file.size ? deferred.resolve : next.resolve ) + .fail( deferred.reject ) + .progress( deferred.notify ); + // start, end & next must be bound to closure, or they'd have + // changed by the time the promises are resolved + }.bind( this, start, end, next ) ); + + promise = next; + } + + return deferred.promise( { abort: active.abort } ); + }, + + /** + * Uploads 1 chunk. + * + * @private + * @param {File} file + * @param {Object} data Other upload options, see action=upload API docs for more + * @param {number} start Chunk start position + * @param {number} end Chunk end position + * @param {string} [filekey] File key, for follow-up chunks + * @param {number} [retries] Amount of times to retry request + * @return {jQuery.Promise} + */ + uploadChunk: function ( file, data, start, end, filekey, retries ) { + var upload, + api = this, + chunk = this.slice( file, start, end ); + + // When uploading in chunks, we're going to be issuing a lot more + // requests and there's always a chance of 1 getting dropped. + // In such case, it could be useful to try again: a network hickup + // doesn't necessarily have to result in upload failure... + retries = retries === undefined ? 1 : retries; + + data.filesize = file.size; + data.chunk = chunk; + data.offset = start; + + // filekey must only be added when uploading follow-up chunks; the + // first chunk should never have a filekey (it'll be generated) + if ( filekey && start !== 0 ) { + data.filekey = filekey; + } + + upload = this.uploadWithFormData( file, data ); + return upload.then( + null, + function ( code, result ) { + var retry; + + // uploadWithFormData will reject uploads with warnings, but + // these warnings could be "harmless" or recovered from + // (e.g. exists-normalized, when it'll be renamed later) + // In the case of (only) a warning, we still want to + // continue the chunked upload until it completes: then + // reject it - at least it's been fully uploaded by then and + // failure handlers have a complete result object (including + // possibly more warnings, e.g. duplicate) + // This matches .upload, which also completes the upload. + if ( result.upload && result.upload.warnings && code in result.upload.warnings ) { + if ( end === file.size ) { + // uploaded last chunk = reject with result data + return $.Deferred().reject( code, result ); + } else { + // still uploading chunks = resolve to keep going + return $.Deferred().resolve( result ); + } + } + + if ( retries === 0 ) { + return $.Deferred().reject( code, result ); + } + + // If the call flat out failed, we may want to try again... + retry = api.uploadChunk.bind( this, file, data, start, end, filekey, retries - 1 ); + return api.retry( code, result, retry ); + }, + function ( fraction ) { + // Since we're only uploading small parts of a file, we + // need to adjust the reported progress to reflect where + // we actually are in the combined upload + return ( start + fraction * ( end - start ) ) / file.size; + } + ).promise( { abort: upload.abort } ); + }, + + /** + * Launch the upload anew if it failed because of network issues. + * + * @private + * @param {string} code Error code + * @param {Object} result API result + * @param {Function} callable + * @return {jQuery.Promise} + */ + retry: function ( code, result, callable ) { + var uploadPromise, + retryTimer, + deferred = $.Deferred(), + // Wrap around the callable, so that once it completes, it'll + // resolve/reject the promise we'll return + retry = function () { + uploadPromise = callable(); + uploadPromise.then( deferred.resolve, deferred.reject ); + }; + + // Don't retry if the request failed because we aborted it (or if + // it's another kind of request failure) + if ( code !== 'http' || result.textStatus === 'abort' ) { + return deferred.reject( code, result ); + } + + retryTimer = setTimeout( retry, 1000 ); + return deferred.promise( { abort: function () { + // Clear the scheduled upload, or abort if already in flight + if ( retryTimer ) { + clearTimeout( retryTimer ); + } + if ( uploadPromise.abort ) { + uploadPromise.abort(); + } + } } ); + }, + + /** + * Slice a chunk out of a File object. + * + * @private + * @param {File} file + * @param {number} start + * @param {number} stop + * @return {Blob} + */ + slice: function ( file, start, stop ) { + if ( file.mozSlice ) { + // FF <= 12 + return file.mozSlice( start, stop, file.type ); + } else if ( file.webkitSlice ) { + // Chrome <= 20 + return file.webkitSlice( start, stop, file.type ); + } else { + // On really old browser versions (before slice was prefixed), + // slice() would take (start, length) instead of (start, end) + // We'll ignore that here... + return file.slice( start, stop, file.type ); + } + }, + + /** + * This function will handle how uploads to stash (via uploadToStash or + * chunkedUploadToStash) are resolved/rejected. + * + * After a successful stash, it'll resolve with a callback which, when + * called, will finalize the upload in stash (with the given data, or + * with additional/conflicting data) + * + * A failed stash can still be recovered from as long as 'filekey' is + * present. In that case, it'll also resolve with the callback to + * finalize the upload (all warnings are then ignored.) + * Otherwise, it'll just reject as you'd expect, with code & result. + * + * @private + * @param {jQuery.Promise} uploadPromise + * @param {Object} data + * @return {jQuery.Promise} + * @return {Function} return.finishUpload Call this function to finish the upload. + * @return {Object} return.finishUpload.data Additional data for the upload. + * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload + * @return {Object} return.finishUpload.return.data API return value for the final upload + */ + finishUploadToStash: function ( uploadPromise, data ) { + var filekey, + api = this; + + function finishUpload( moreData ) { + return api.uploadFromStash( filekey, $.extend( data, moreData ) ); + } + + return uploadPromise.then( + function ( result ) { + filekey = result.upload.filekey; + return finishUpload; + }, + function ( errorCode, result ) { + if ( result && result.upload && result.upload.filekey ) { + // Ignore any warnings if 'filekey' was returned, that's all we care about + filekey = result.upload.filekey; + return $.Deferred().resolve( finishUpload ); + } + return $.Deferred().reject( errorCode, result ); + } + ); + }, + + /** + * Upload a file to the stash. + * + * This function will return a promise, which when resolved, will pass back a function + * to finish the stash upload. You can call that function with an argument containing + * more, or conflicting, data to pass to the server. For example: + * + * // upload a file to the stash with a placeholder filename + * api.uploadToStash( file, { filename: 'testing.png' } ).done( function ( finish ) { + * // finish is now the function we can use to finalize the upload + * // pass it a new filename from user input to override the initial value + * finish( { filename: getFilenameFromUser() } ).done( function ( data ) { + * // the upload is complete, data holds the API response + * } ); + * } ); + * + * @param {File|HTMLInputElement} file + * @param {Object} [data] + * @return {jQuery.Promise} + * @return {Function} return.finishUpload Call this function to finish the upload. + * @return {Object} return.finishUpload.data Additional data for the upload. + * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload + * @return {Object} return.finishUpload.return.data API return value for the final upload + */ + uploadToStash: function ( file, data ) { + var promise; + + if ( !data.filename ) { + throw new Error( 'Filename not included in file data.' ); + } + + promise = this.upload( file, { stash: true, filename: data.filename } ); + + return this.finishUploadToStash( promise, data ); + }, + + /** + * Upload a file to the stash, in chunks. + * + * This function will return a promise, which when resolved, will pass back a function + * to finish the stash upload. + * + * @see #method-uploadToStash + * @param {File|HTMLInputElement} file + * @param {Object} [data] + * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB) + * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1) + * @return {jQuery.Promise} + * @return {Function} return.finishUpload Call this function to finish the upload. + * @return {Object} return.finishUpload.data Additional data for the upload. + * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload + * @return {Object} return.finishUpload.return.data API return value for the final upload + */ + chunkedUploadToStash: function ( file, data, chunkSize, chunkRetries ) { + var promise; + + if ( !data.filename ) { + throw new Error( 'Filename not included in file data.' ); + } + + promise = this.chunkedUpload( + file, + { stash: true, filename: data.filename }, + chunkSize, + chunkRetries + ); + + return this.finishUploadToStash( promise, data ); + }, + + /** + * Finish an upload in the stash. + * + * @param {string} filekey + * @param {Object} data + * @return {jQuery.Promise} + */ + uploadFromStash: function ( filekey, data ) { + data.filekey = filekey; + data.action = 'upload'; + data.format = 'json'; + + if ( !data.filename ) { + throw new Error( 'Filename not included in file data.' ); + } + + return this.postWithEditToken( data ).then( function ( result ) { + if ( result.upload && result.upload.warnings ) { + return $.Deferred().reject( getFirstKey( result.upload.warnings ), result ).promise(); + } + return result; + } ); + }, + + needToken: function () { + return true; + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.upload + */ +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/user.js b/resources/src/mediawiki.api/user.js new file mode 100644 index 0000000000..e7b4b6d54f --- /dev/null +++ b/resources/src/mediawiki.api/user.js @@ -0,0 +1,37 @@ +/** + * @class mw.Api.plugin.user + * @since 1.27 + */ +( function ( mw, $ ) { + + $.extend( mw.Api.prototype, { + + /** + * Get the current user's groups and rights. + * + * @return {jQuery.Promise} + * @return {Function} return.done + * @return {Object} return.done.userInfo + * @return {string[]} return.done.userInfo.groups User groups that the current user belongs to + * @return {string[]} return.done.userInfo.rights Current user's rights + */ + getUserInfo: function () { + return this.get( { + action: 'query', + meta: 'userinfo', + uiprop: [ 'groups', 'rights' ] + } ).then( function ( data ) { + if ( data.query && data.query.userinfo ) { + return data.query.userinfo; + } + return $.Deferred().reject().promise(); + } ); + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.user + */ + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/watch.js b/resources/src/mediawiki.api/watch.js new file mode 100644 index 0000000000..025c111e84 --- /dev/null +++ b/resources/src/mediawiki.api/watch.js @@ -0,0 +1,70 @@ +/** + * @class mw.Api.plugin.watch + * @since 1.19 + */ +( function ( mw, $ ) { + + /** + * @private + * @static + * @context mw.Api + * + * @param {string|mw.Title|string[]|mw.Title[]} pages Full page name or instance of mw.Title, or an + * array thereof. If an array is passed, the return value passed to the promise will also be an + * array of appropriate objects. + * @param {Object} [addParams] + * @return {jQuery.Promise} + * @return {Function} return.done + * @return {Object|Object[]} return.done.watch Object or list of objects (depends on the `pages` + * parameter) + * @return {string} return.done.watch.title Full pagename + * @return {boolean} return.done.watch.watched Whether the page is now watched or unwatched + */ + function doWatchInternal( pages, addParams ) { + // XXX: Parameter addParams is undocumented because we inherit this + // documentation in the public method... + var apiPromise = this.postWithToken( 'watch', + $.extend( + { + formatversion: 2, + action: 'watch', + titles: Array.isArray( pages ) ? pages : String( pages ) + }, + addParams + ) + ); + + return apiPromise + .then( function ( data ) { + // If a single page was given (not an array) respond with a single item as well. + return Array.isArray( pages ) ? data.watch : data.watch[ 0 ]; + } ) + .promise( { abort: apiPromise.abort } ); + } + + $.extend( mw.Api.prototype, { + /** + * Convenience method for `action=watch`. + * + * @inheritdoc #doWatchInternal + */ + watch: function ( pages ) { + return doWatchInternal.call( this, pages ); + }, + + /** + * Convenience method for `action=watch&unwatch=1`. + * + * @inheritdoc #doWatchInternal + */ + unwatch: function ( pages ) { + return doWatchInternal.call( this, pages, { unwatch: 1 } ); + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.watch + */ + +}( mediaWiki, jQuery ) ); diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index 785e11462d..1922de5c74 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -115,12 +115,6 @@ return [ 'jquery.tablesorter', 'jquery.textSelection', 'mediawiki.api', - 'mediawiki.api.category', - 'mediawiki.api.messages', - 'mediawiki.api.options', - 'mediawiki.api.parse', - 'mediawiki.api.upload', - 'mediawiki.api.watch', 'mediawiki.ForeignApi.core', 'mediawiki.jqueryMsg', 'mediawiki.messagePoster',