From: Timo Tijhof Date: Wed, 9 May 2018 17:40:57 +0000 (+0100) Subject: resources: Move more various single-file mediawiki.* modules to src/ X-Git-Tag: 1.34.0-rc.0~5472 X-Git-Url: https://git.cyclocoop.org/%27.WWW_URL.%27admin/?a=commitdiff_plain;h=ba88625a64173d6597019f98a616dec0979795f7;p=lhc%2Fweb%2Fwiklou.git resources: Move more various single-file mediawiki.* modules to src/ * Reduce clutter in src/mediawiki/. * Make these files and modules easier to discover and associate. Follows-up I677edac3b5e, which only moved simple cases where no related modules existed. This commit also moves files for modules that have some related multi-file modules. As well as files that previously did not strictly have their path match directly to their module name. For example: - 'mediawiki.checkboxtoggle.css' to 'mediawiki.checkboxtoggle.styles.css', because its module name is 'mediawiki.checkboxtoggle.styles'. - 'mediawiki/page/gallery-slideshow.js' to 'mediawiki.page.gallery.slideshow.js', because its module name uses a dot, not a dash. - 'mediawiki/page/watch.js' to 'mediawiki.page.watch.ajax.js', because its module name also includes 'ajax'. This also makes it matches the way "mediawiki.page.patrol.ajax" files were already named. Ideas for later: - Consider merging 'mediawiki.ForeignApi' and 'mediawiki.ForeignApi.core.'. - Consider merging 'mediawiki.page.ready' and 'mediawiki.page.startup'. Bug: T193826 Change-Id: I9564b05df305b7d217c9a03b80ce92476279e5c8 --- diff --git a/resources/Resources.php b/resources/Resources.php index d41352e5aa..4ecf89a6c7 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -869,7 +869,7 @@ return [ 'targets' => [ 'desktop' ], ], 'mediawiki.template' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.template.js', + 'scripts' => 'resources/src/mediawiki.template.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.template.mustache' => [ @@ -881,16 +881,16 @@ return [ 'dependencies' => 'mediawiki.template', ], 'mediawiki.template.regexp' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.template.regexp.js', + 'scripts' => 'resources/src/mediawiki.template.regexp.js', 'targets' => [ 'desktop', 'mobile' ], 'dependencies' => 'mediawiki.template', ], 'mediawiki.apipretty' => [ - 'styles' => 'resources/src/mediawiki/mediawiki.apipretty.css', + 'styles' => 'resources/src/mediawiki.apipretty.css', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api' => [ - 'scripts' => 'resources/src/mediawiki/api.js', + 'scripts' => 'resources/src/mediawiki.api.js', 'dependencies' => [ 'mediawiki.util', 'user.tokens', @@ -898,14 +898,14 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.category' => [ - 'scripts' => 'resources/src/mediawiki/api/category.js', + 'scripts' => 'resources/src/mediawiki.api.category.js', 'dependencies' => [ 'mediawiki.api', 'mediawiki.Title', ], ], 'mediawiki.api.edit' => [ - 'scripts' => 'resources/src/mediawiki/api/edit.js', + 'scripts' => 'resources/src/mediawiki.api.edit.js', 'dependencies' => [ 'mediawiki.api', 'mediawiki.user', @@ -913,21 +913,21 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.login' => [ - 'scripts' => 'resources/src/mediawiki/api/login.js', + 'scripts' => 'resources/src/mediawiki.api.login.js', 'dependencies' => 'mediawiki.api', ], 'mediawiki.api.options' => [ - 'scripts' => 'resources/src/mediawiki/api/options.js', + 'scripts' => 'resources/src/mediawiki.api.options.js', 'dependencies' => 'mediawiki.api', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.parse' => [ - 'scripts' => 'resources/src/mediawiki/api/parse.js', + 'scripts' => 'resources/src/mediawiki.api.parse.js', 'dependencies' => 'mediawiki.api', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.upload' => [ - 'scripts' => 'resources/src/mediawiki/api/upload.js', + 'scripts' => 'resources/src/mediawiki.api.upload.js', 'dependencies' => [ 'mediawiki.api', 'mediawiki.api.edit', @@ -935,27 +935,27 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.user' => [ - 'scripts' => 'resources/src/mediawiki/api/user.js', + 'scripts' => 'resources/src/mediawiki.api.user.js', 'dependencies' => [ 'mediawiki.api', ], 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.watch' => [ - 'scripts' => 'resources/src/mediawiki/api/watch.js', + 'scripts' => 'resources/src/mediawiki.api.watch.js', 'dependencies' => [ 'mediawiki.api', ], ], 'mediawiki.api.messages' => [ - 'scripts' => 'resources/src/mediawiki/api/messages.js', + 'scripts' => 'resources/src/mediawiki.api.messages.js', 'dependencies' => [ 'mediawiki.api', ], 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.api.rollback' => [ - 'scripts' => 'resources/src/mediawiki/api/rollback.js', + 'scripts' => 'resources/src/mediawiki.api.rollback.js', 'dependencies' => [ 'mediawiki.api', ], @@ -1043,7 +1043,7 @@ return [ 'dependencies' => 'mediawiki.ForeignApi.core', ], 'mediawiki.ForeignApi.core' => [ - 'scripts' => 'resources/src/mediawiki/ForeignApi.js', + 'scripts' => 'resources/src/mediawiki.ForeignApi.core.js', 'dependencies' => [ 'mediawiki.api', 'oojs', @@ -1175,15 +1175,15 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.notification.convertmessagebox' => [ + 'scripts' => 'resources/src/mediawiki.notification.convertmessagebox.js', 'dependencies' => [ 'mediawiki.notification', ], - 'scripts' => 'resources/src/mediawiki/mediawiki.notification.convertmessagebox.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.notification.convertmessagebox.styles' => [ 'styles' => [ - 'resources/src/mediawiki/mediawiki.notification.convertmessagebox.styles.less', + 'resources/src/mediawiki.notification.convertmessagebox.styles.less', ], 'targets' => [ 'desktop', 'mobile' ], ], @@ -1228,13 +1228,13 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.Upload' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.Upload.js', + 'scripts' => 'resources/src/mediawiki.Upload.js', 'dependencies' => [ 'mediawiki.api.upload', ], ], 'mediawiki.ForeignUpload' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.ForeignUpload.js', + 'scripts' => 'resources/src/mediawiki.ForeignUpload.js', 'dependencies' => [ 'mediawiki.ForeignApi', 'mediawiki.Upload', @@ -1250,7 +1250,7 @@ return [ 'class' => ResourceLoaderUploadDialogModule::class, ], 'mediawiki.ForeignStructuredUpload' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.ForeignStructuredUpload.js', + 'scripts' => 'resources/src/mediawiki.ForeignStructuredUpload.js', 'dependencies' => [ 'mediawiki.ForeignUpload', 'mediawiki.ForeignStructuredUpload.config', @@ -1261,7 +1261,7 @@ return [ ], 'mediawiki.Upload.Dialog' => [ 'scripts' => [ - 'resources/src/mediawiki/mediawiki.Upload.Dialog.js', + 'resources/src/mediawiki.Upload.Dialog.js', ], 'dependencies' => [ 'mediawiki.Upload.BookletLayout', @@ -1400,10 +1400,10 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.checkboxtoggle' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.checkboxtoggle.js', + 'scripts' => 'resources/src/mediawiki.checkboxtoggle.js', ], 'mediawiki.checkboxtoggle.styles' => [ - 'styles' => 'resources/src/mediawiki/mediawiki.checkboxtoggle.css', + 'styles' => 'resources/src/mediawiki.checkboxtoggle.styles.css', ], 'mediawiki.cookie' => [ 'scripts' => 'resources/src/mediawiki.cookie.js', @@ -1679,7 +1679,7 @@ return [ /* MediaWiki Page */ 'mediawiki.page.gallery' => [ - 'scripts' => 'resources/src/mediawiki/page/gallery.js', + 'scripts' => 'resources/src/mediawiki.page.gallery.js', 'dependencies' => [ 'mediawiki.page.gallery.styles', 'jquery.throttle-debounce', @@ -1693,7 +1693,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.page.gallery.slideshow' => [ - 'scripts' => 'resources/src/mediawiki/page/gallery-slideshow.js', + 'scripts' => 'resources/src/mediawiki.page.gallery.slideshow.js', 'dependencies' => [ 'mediawiki.api', 'mediawiki.Title', @@ -1708,7 +1708,7 @@ return [ ] ], 'mediawiki.page.ready' => [ - 'scripts' => 'resources/src/mediawiki/page/ready.js', + 'scripts' => 'resources/src/mediawiki.page.ready.js', 'dependencies' => [ 'jquery.accessKeyLabel', 'jquery.checkboxShiftClick', @@ -1717,11 +1717,11 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.page.startup' => [ - 'scripts' => 'resources/src/mediawiki/page/startup.js', + 'scripts' => 'resources/src/mediawiki.page.startup.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.page.patrol.ajax' => [ - 'scripts' => 'resources/src/mediawiki/page/patrol.ajax.js', + 'scripts' => 'resources/src/mediawiki.page.patrol.ajax.js', 'dependencies' => [ 'mediawiki.api', 'mediawiki.util', @@ -1737,7 +1737,7 @@ return [ ], ], 'mediawiki.page.watch.ajax' => [ - 'scripts' => 'resources/src/mediawiki/page/watch.js', + 'scripts' => 'resources/src/mediawiki.page.watch.ajax.js', 'dependencies' => [ 'mediawiki.api.watch', 'mediawiki.notify', @@ -1762,7 +1762,7 @@ return [ ], ], 'mediawiki.page.rollback' => [ - 'scripts' => 'resources/src/mediawiki/page/rollback.js', + 'scripts' => 'resources/src/mediawiki.page.rollback.js', 'dependencies' => [ 'mediawiki.api.rollback', 'mediawiki.notify', @@ -1775,7 +1775,7 @@ return [ ], ], 'mediawiki.page.image.pagination' => [ - 'scripts' => 'resources/src/mediawiki/page/image-pagination.js', + 'scripts' => 'resources/src/mediawiki.page.image.pagination.js', 'dependencies' => [ 'mediawiki.util', 'jquery.spinner', diff --git a/resources/src/mediawiki.ForeignApi.core.js b/resources/src/mediawiki.ForeignApi.core.js new file mode 100644 index 0000000000..1a3cdd5a3f --- /dev/null +++ b/resources/src/mediawiki.ForeignApi.core.js @@ -0,0 +1,119 @@ +( function ( mw, $ ) { + + /** + * Create an object like mw.Api, but automatically handling everything required to communicate + * with another MediaWiki wiki via cross-origin requests (CORS). + * + * The foreign wiki must be configured to accept requests from the current wiki. See + * for details. + * + * var api = new mw.ForeignApi( 'https://commons.wikimedia.org/w/api.php' ); + * api.get( { + * action: 'query', + * meta: 'userinfo' + * } ).done( function ( data ) { + * console.log( data ); + * } ); + * + * To ensure that the user at the foreign wiki is logged in, pass the `assert: 'user'` parameter + * to #get/#post (since MW 1.23): if they are not, the API request will fail. (Note that this + * doesn't guarantee that it's the same user.) + * + * Authentication-related MediaWiki extensions may extend this class to ensure that the user + * authenticated on the current wiki will be automatically authenticated on the foreign one. These + * extension modules should be registered using the ResourceLoaderForeignApiModules hook. See + * CentralAuth for a practical example. The general pattern to extend and override the name is: + * + * function MyForeignApi() {}; + * OO.inheritClass( MyForeignApi, mw.ForeignApi ); + * mw.ForeignApi = MyForeignApi; + * + * @class mw.ForeignApi + * @extends mw.Api + * @since 1.26 + * + * @constructor + * @param {string|mw.Uri} url URL pointing to another wiki's `api.php` endpoint. + * @param {Object} [options] See mw.Api. + * @param {Object} [options.anonymous=false] Perform all requests anonymously. Use this option if + * the target wiki may otherwise not accept cross-origin requests, or if you don't need to + * perform write actions or read restricted information and want to avoid the overhead. + * + * @author Bartosz Dziewoński + * @author Jon Robson + */ + function CoreForeignApi( url, options ) { + if ( !url || $.isPlainObject( url ) ) { + throw new Error( 'mw.ForeignApi() requires a `url` parameter' ); + } + + this.apiUrl = String( url ); + this.anonymous = options && options.anonymous; + + options = $.extend( /* deep=*/ true, + { + ajax: { + url: this.apiUrl, + xhrFields: { + withCredentials: !this.anonymous + } + }, + parameters: { + // Add 'origin' query parameter to all requests. + origin: this.getOrigin() + } + }, + options + ); + + // Call parent constructor + CoreForeignApi.parent.call( this, options ); + } + + OO.inheritClass( CoreForeignApi, mw.Api ); + + /** + * Return the origin to use for API requests, in the required format (protocol, host and port, if + * any). + * + * @protected + * @return {string} + */ + CoreForeignApi.prototype.getOrigin = function () { + var origin; + if ( this.anonymous ) { + return '*'; + } + origin = location.protocol + '//' + location.hostname; + if ( location.port ) { + origin += ':' + location.port; + } + return origin; + }; + + /** + * @inheritdoc + */ + CoreForeignApi.prototype.ajax = function ( parameters, ajaxOptions ) { + var url, origin, newAjaxOptions; + + // 'origin' query parameter must be part of the request URI, and not just POST request body + if ( ajaxOptions.type === 'POST' ) { + url = ( ajaxOptions && ajaxOptions.url ) || this.defaults.ajax.url; + origin = ( parameters && parameters.origin ) || this.defaults.parameters.origin; + url += ( url.indexOf( '?' ) !== -1 ? '&' : '?' ) + + // 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. + 'origin=' + encodeURIComponent( origin ).replace( /\./g, '%2E' ); + newAjaxOptions = $.extend( {}, ajaxOptions, { url: url } ); + } else { + newAjaxOptions = ajaxOptions; + } + + return CoreForeignApi.parent.prototype.ajax.call( this, parameters, newAjaxOptions ); + }; + + // Expose + mw.ForeignApi = CoreForeignApi; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.ForeignStructuredUpload.js b/resources/src/mediawiki.ForeignStructuredUpload.js new file mode 100644 index 0000000000..177861ec05 --- /dev/null +++ b/resources/src/mediawiki.ForeignStructuredUpload.js @@ -0,0 +1,250 @@ +( function ( mw, $, OO ) { + /** + * Used to represent an upload in progress on the frontend. + * + * This subclass will upload to a wiki using a structured metadata + * system similar to (or identical to) the one on Wikimedia Commons. + * + * See for + * a more detailed description of how that system works. + * + * **TODO: This currently only supports uploads under CC-BY-SA 4.0, + * and should really have support for more licenses.** + * + * @class mw.ForeignStructuredUpload + * @extends mw.ForeignUpload + * + * @constructor + * @param {string} [target] + * @param {Object} [apiconfig] + */ + function ForeignStructuredUpload( target, apiconfig ) { + this.date = undefined; + this.descriptions = []; + this.categories = []; + + // Config for uploads to local wiki. + // Can be overridden with foreign wiki config when #loadConfig is called. + this.config = mw.config.get( 'wgUploadDialog' ); + + mw.ForeignUpload.call( this, target, apiconfig ); + } + + OO.inheritClass( ForeignStructuredUpload, mw.ForeignUpload ); + + /** + * Get the configuration for the form and filepage from the foreign wiki, if any, and use it for + * this upload. + * + * @return {jQuery.Promise} Promise returning config object + */ + ForeignStructuredUpload.prototype.loadConfig = function () { + var deferred, + upload = this; + + if ( this.configPromise ) { + return this.configPromise; + } + + if ( this.target === 'local' ) { + deferred = $.Deferred(); + setTimeout( function () { + // Resolve asynchronously, so that it's harder to accidentally write synchronous code that + // will break for cross-wiki uploads + deferred.resolve( upload.config ); + } ); + this.configPromise = deferred.promise(); + } else { + this.configPromise = this.apiPromise.then( function ( api ) { + // Get the config from the foreign wiki + return api.get( { + action: 'query', + meta: 'siteinfo', + siprop: 'uploaddialog', + // For convenient true/false booleans + formatversion: 2 + } ).then( function ( resp ) { + // Foreign wiki might be running a pre-1.27 MediaWiki, without support for this + if ( resp.query && resp.query.uploaddialog ) { + upload.config = resp.query.uploaddialog; + return upload.config; + } else { + return $.Deferred().reject( 'upload-foreign-cant-load-config' ); + } + }, function () { + return $.Deferred().reject( 'upload-foreign-cant-load-config' ); + } ); + } ); + } + + return this.configPromise; + }; + + /** + * Add categories to the upload. + * + * @param {string[]} categories Array of categories to which this upload will be added. + */ + ForeignStructuredUpload.prototype.addCategories = function ( categories ) { + // The length of the array must be less than 10000. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push#Merging_two_arrays + Array.prototype.push.apply( this.categories, categories ); + }; + + /** + * Empty the list of categories for the upload. + */ + ForeignStructuredUpload.prototype.clearCategories = function () { + this.categories = []; + }; + + /** + * Add a description to the upload. + * + * @param {string} language The language code for the description's language. Must have a template on the target wiki to work properly. + * @param {string} description The description of the file. + */ + ForeignStructuredUpload.prototype.addDescription = function ( language, description ) { + this.descriptions.push( { + language: language, + text: description + } ); + }; + + /** + * Empty the list of descriptions for the upload. + */ + ForeignStructuredUpload.prototype.clearDescriptions = function () { + this.descriptions = []; + }; + + /** + * Set the date of creation for the upload. + * + * @param {Date} date + */ + ForeignStructuredUpload.prototype.setDate = function ( date ) { + this.date = date; + }; + + /** + * Get the text of the file page, to be created on upload. Brings together + * several different pieces of information to create useful text. + * + * @return {string} + */ + ForeignStructuredUpload.prototype.getText = function () { + return this.config.format.filepage + // Replace "named parameters" with the given information + .replace( '$DESCRIPTION', this.getDescriptions() ) + .replace( '$DATE', this.getDate() ) + .replace( '$SOURCE', this.getSource() ) + .replace( '$AUTHOR', this.getUser() ) + .replace( '$LICENSE', this.getLicense() ) + .replace( '$CATEGORIES', this.getCategories() ); + }; + + /** + * @inheritdoc + */ + ForeignStructuredUpload.prototype.getComment = function () { + var + isLocal = this.target === 'local', + comment = typeof this.config.comment === 'string' ? + this.config.comment : + this.config.comment[ isLocal ? 'local' : 'foreign' ]; + return comment + .replace( '$PAGENAME', mw.config.get( 'wgPageName' ) ) + .replace( '$HOST', location.host ); + }; + + /** + * Gets the wikitext for the creation date of this upload. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getDate = function () { + if ( !this.date ) { + return ''; + } + + return this.date.toString(); + }; + + /** + * Fetches the wikitext for any descriptions that have been added + * to the upload. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getDescriptions = function () { + var upload = this; + return this.descriptions.map( function ( desc ) { + return upload.config.format.description + .replace( '$LANGUAGE', desc.language ) + .replace( '$TEXT', desc.text ); + } ).join( '\n' ); + }; + + /** + * Fetches the wikitext for the categories to which the upload will + * be added. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getCategories = function () { + if ( this.categories.length === 0 ) { + return this.config.format.uncategorized; + } + + return this.categories.map( function ( cat ) { + return '[[Category:' + cat + ']]'; + } ).join( '\n' ); + }; + + /** + * Gets the wikitext for the license of the upload. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getLicense = function () { + return this.config.format.license; + }; + + /** + * Get the source. This should be some sort of localised text for "Own work". + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getSource = function () { + return this.config.format.ownwork; + }; + + /** + * Get the username. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getUser = function () { + var username, namespace; + // Do not localise, we don't know the language of target wiki + namespace = 'User'; + username = mw.config.get( 'wgUserName' ); + if ( !username ) { + // The user is not logged in locally. However, they might be logged in on the foreign wiki. + // We should record their username there. (If they're not logged in there either, this will + // record the IP address.) It's also possible that the user opened this dialog, got an error + // about not being logged in, logged in in another browser tab, then continued uploading. + username = '{{subst:REVISIONUSER}}'; + } + return '[[' + namespace + ':' + username + '|' + username + ']]'; + }; + + mw.ForeignStructuredUpload = ForeignStructuredUpload; +}( mediaWiki, jQuery, OO ) ); diff --git a/resources/src/mediawiki.ForeignUpload.js b/resources/src/mediawiki.ForeignUpload.js new file mode 100644 index 0000000000..08fc01d2f2 --- /dev/null +++ b/resources/src/mediawiki.ForeignUpload.js @@ -0,0 +1,143 @@ +( function ( mw, OO, $ ) { + /** + * Used to represent an upload in progress on the frontend. + * + * Subclassed to upload to a foreign API, with no other goodies. Use + * this for a generic foreign image repository on your wiki farm. + * + * Note you can provide the {@link #target target} or not - if the first argument is + * an object, we assume you want the default, and treat it as apiconfig + * instead. + * + * @class mw.ForeignUpload + * @extends mw.Upload + * + * @constructor + * @param {string} [target] Used to set up the target + * wiki. If not remote, this class behaves identically to mw.Upload (unless further subclassed) + * Use the same names as set in $wgForeignFileRepos for this. Also, + * make sure there is an entry in the $wgForeignUploadTargets array for this name. + * @param {Object} [apiconfig] Passed to the constructor of mw.ForeignApi or mw.Api, as needed. + */ + function ForeignUpload( target, apiconfig ) { + var api, + validTargets = mw.config.get( 'wgForeignUploadTargets' ), + upload = this; + + if ( typeof target === 'object' ) { + // target probably wasn't passed in, it must + // be apiconfig + apiconfig = target; + target = undefined; + } + + // * Use the given `target` first; + // * If not given, fall back to default (first) ForeignUploadTarget; + // * If none is configured, fall back to local uploads. + this.target = target || validTargets[ 0 ] || 'local'; + + // Now we have several different options. + // If the local wiki is the target, then we can skip a bunch of steps + // and just return an mw.Api object, because we don't need any special + // configuration for that. + // However, if the target is a remote wiki, we must check the API + // to confirm that the target is one that this site is configured to + // support. + if ( validTargets.length === 0 ) { + this.apiPromise = $.Deferred().reject( 'upload-dialog-disabled' ); + } else if ( this.target === 'local' ) { + // If local uploads were requested, but they are disabled, fail. + if ( !mw.config.get( 'wgEnableUploads' ) ) { + this.apiPromise = $.Deferred().reject( 'uploaddisabledtext' ); + } else { + // We'll ignore the CORS and centralauth stuff if the target is + // the local wiki. + this.apiPromise = $.Deferred().resolve( new mw.Api( apiconfig ) ); + } + } else { + api = new mw.Api(); + this.apiPromise = api.get( { + action: 'query', + meta: 'filerepoinfo', + friprop: [ 'name', 'scriptDirUrl', 'canUpload' ] + } ).then( function ( data ) { + var i, repo, + repos = data.query.repos; + + // First pass - try to find the passed-in target and check + // that it's configured for uploads. + for ( i in repos ) { + repo = repos[ i ]; + + // Skip repos that are not our target, or if they + // are the target, cannot be uploaded to. + if ( repo.name === upload.target && repo.canUpload === '' ) { + return new mw.ForeignApi( + repo.scriptDirUrl + '/api.php', + apiconfig + ); + } + } + + return $.Deferred().reject( 'upload-foreign-cant-upload' ); + } ); + } + + // Build the upload object without an API - this class overrides the + // actual API call methods to wait for the apiPromise to resolve + // before continuing. + mw.Upload.call( this, null ); + } + + OO.inheritClass( ForeignUpload, mw.Upload ); + + /** + * @property {string} target + * Used to specify the target repository of the upload. + * + * If you set this to something that isn't 'local', you must be sure to + * add that target to $wgForeignUploadTargets in LocalSettings, and the + * repository must be set up to use CORS and CentralAuth. + * + * Most wikis use "shared" to refer to Wikimedia Commons, we assume that + * in this class and in the messages linked to it. + * + * Defaults to the first available foreign upload target, + * or to local uploads if no foreign target is configured. + */ + + /** + * @inheritdoc + */ + ForeignUpload.prototype.getApi = function () { + return this.apiPromise; + }; + + /** + * Override from mw.Upload to make sure the API info is found and allowed + * + * @inheritdoc + */ + ForeignUpload.prototype.upload = function () { + var upload = this; + return this.apiPromise.then( function ( api ) { + upload.api = api; + return mw.Upload.prototype.upload.call( upload ); + } ); + }; + + /** + * Override from mw.Upload to make sure the API info is found and allowed + * + * @inheritdoc + */ + ForeignUpload.prototype.uploadToStash = function () { + var upload = this; + return this.apiPromise.then( function ( api ) { + upload.api = api; + return mw.Upload.prototype.uploadToStash.call( upload ); + } ); + }; + + mw.ForeignUpload = ForeignUpload; +}( mediaWiki, OO, jQuery ) ); diff --git a/resources/src/mediawiki.Upload.Dialog.js b/resources/src/mediawiki.Upload.Dialog.js new file mode 100644 index 0000000000..00c04bc807 --- /dev/null +++ b/resources/src/mediawiki.Upload.Dialog.js @@ -0,0 +1,230 @@ +( function ( $, mw ) { + + /** + * mw.Upload.Dialog controls a {@link mw.Upload.BookletLayout BookletLayout}. + * + * ## Usage + * + * To use, setup a {@link OO.ui.WindowManager window manager} like for normal + * dialogs: + * + * var uploadDialog = new mw.Upload.Dialog(); + * var windowManager = new OO.ui.WindowManager(); + * $( 'body' ).append( windowManager.$element ); + * windowManager.addWindows( [ uploadDialog ] ); + * windowManager.openWindow( uploadDialog ); + * + * The dialog's closing promise can be used to get details of the upload. + * + * If you want to use a different OO.ui.BookletLayout, for example the + * mw.ForeignStructuredUpload.BookletLayout, like in the case of of the upload + * interface in VisualEditor, you can pass it in the {@link #cfg-bookletClass}: + * + * var uploadDialog = new mw.Upload.Dialog( { + * bookletClass: mw.ForeignStructuredUpload.BookletLayout + * } ); + * + * + * @class mw.Upload.Dialog + * @uses mw.Upload + * @uses mw.Upload.BookletLayout + * @extends OO.ui.ProcessDialog + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Function} [bookletClass=mw.Upload.BookletLayout] Booklet class to be + * used for the steps + * @cfg {Object} [booklet] Booklet constructor configuration + */ + mw.Upload.Dialog = function ( config ) { + // Config initialization + config = $.extend( { + bookletClass: mw.Upload.BookletLayout + }, config ); + + // Parent constructor + mw.Upload.Dialog.parent.call( this, config ); + + // Initialize + this.bookletClass = config.bookletClass; + this.bookletConfig = config.booklet; + }; + + /* Setup */ + + OO.inheritClass( mw.Upload.Dialog, OO.ui.ProcessDialog ); + + /* Static Properties */ + + /** + * @inheritdoc + * @property name + */ + mw.Upload.Dialog.static.name = 'mwUploadDialog'; + + /** + * @inheritdoc + * @property title + */ + mw.Upload.Dialog.static.title = mw.msg( 'upload-dialog-title' ); + + /** + * @inheritdoc + * @property actions + */ + mw.Upload.Dialog.static.actions = [ + { + flags: 'safe', + action: 'cancel', + label: mw.msg( 'upload-dialog-button-cancel' ), + modes: [ 'upload', 'insert' ] + }, + { + flags: 'safe', + action: 'cancelupload', + label: mw.msg( 'upload-dialog-button-back' ), + modes: [ 'info' ] + }, + { + flags: [ 'primary', 'progressive' ], + label: mw.msg( 'upload-dialog-button-done' ), + action: 'insert', + modes: 'insert' + }, + { + flags: [ 'primary', 'progressive' ], + label: mw.msg( 'upload-dialog-button-save' ), + action: 'save', + modes: 'info' + }, + { + flags: [ 'primary', 'progressive' ], + label: mw.msg( 'upload-dialog-button-upload' ), + action: 'upload', + modes: 'upload' + } + ]; + + /* Methods */ + + /** + * @inheritdoc + */ + mw.Upload.Dialog.prototype.initialize = function () { + // Parent method + mw.Upload.Dialog.parent.prototype.initialize.call( this ); + + this.uploadBooklet = this.createUploadBooklet(); + this.uploadBooklet.connect( this, { + set: 'onUploadBookletSet', + uploadValid: 'onUploadValid', + infoValid: 'onInfoValid' + } ); + + this.$body.append( this.uploadBooklet.$element ); + }; + + /** + * Create an upload booklet + * + * @protected + * @return {mw.Upload.BookletLayout} An upload booklet + */ + mw.Upload.Dialog.prototype.createUploadBooklet = function () { + // eslint-disable-next-line new-cap + return new this.bookletClass( $.extend( { + $overlay: this.$overlay + }, this.bookletConfig ) ); + }; + + /** + * @inheritdoc + */ + mw.Upload.Dialog.prototype.getBodyHeight = function () { + return 600; + }; + + /** + * Handle panelNameSet events from the upload booklet + * + * @protected + * @param {OO.ui.PageLayout} page Current page + */ + mw.Upload.Dialog.prototype.onUploadBookletSet = function ( page ) { + this.actions.setMode( page.getName() ); + this.actions.setAbilities( { upload: false, save: false } ); + }; + + /** + * Handle uploadValid events + * + * {@link OO.ui.ActionSet#setAbilities Sets abilities} + * for the dialog accordingly. + * + * @protected + * @param {boolean} isValid The panel is complete and valid + */ + mw.Upload.Dialog.prototype.onUploadValid = function ( isValid ) { + this.actions.setAbilities( { upload: isValid } ); + }; + + /** + * Handle infoValid events + * + * {@link OO.ui.ActionSet#setAbilities Sets abilities} + * for the dialog accordingly. + * + * @protected + * @param {boolean} isValid The panel is complete and valid + */ + mw.Upload.Dialog.prototype.onInfoValid = function ( isValid ) { + this.actions.setAbilities( { save: isValid } ); + }; + + /** + * @inheritdoc + */ + mw.Upload.Dialog.prototype.getSetupProcess = function ( data ) { + return mw.Upload.Dialog.parent.prototype.getSetupProcess.call( this, data ) + .next( function () { + return this.uploadBooklet.initialize(); + }, this ); + }; + + /** + * @inheritdoc + */ + mw.Upload.Dialog.prototype.getActionProcess = function ( action ) { + var dialog = this; + + if ( action === 'upload' ) { + return new OO.ui.Process( this.uploadBooklet.uploadFile() ); + } + if ( action === 'save' ) { + return new OO.ui.Process( this.uploadBooklet.saveFile() ); + } + if ( action === 'insert' ) { + return new OO.ui.Process( function () { + dialog.close( dialog.upload ); + } ); + } + if ( action === 'cancel' ) { + return new OO.ui.Process( this.close().closed ); + } + if ( action === 'cancelupload' ) { + return new OO.ui.Process( this.uploadBooklet.initialize() ); + } + + return mw.Upload.Dialog.parent.prototype.getActionProcess.call( this, action ); + }; + + /** + * @inheritdoc + */ + mw.Upload.Dialog.prototype.getTeardownProcess = function ( data ) { + return mw.Upload.Dialog.parent.prototype.getTeardownProcess.call( this, data ) + .next( function () { + this.uploadBooklet.clear(); + }, this ); + }; +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.Upload.js b/resources/src/mediawiki.Upload.js new file mode 100644 index 0000000000..7e6cfb68fe --- /dev/null +++ b/resources/src/mediawiki.Upload.js @@ -0,0 +1,393 @@ +( function ( mw, $ ) { + var UP; + + /** + * Used to represent an upload in progress on the frontend. + * Most of the functionality is implemented in mw.Api.plugin.upload, + * but this model class will tie it together as well as let you perform + * actions in a logical way. + * + * A simple example: + * + * var file = new OO.ui.SelectFileWidget(), + * button = new OO.ui.ButtonWidget( { label: 'Save' } ), + * upload = new mw.Upload; + * + * button.on( 'click', function () { + * upload.setFile( file.getValue() ); + * upload.setFilename( file.getValue().name ); + * upload.upload(); + * } ); + * + * $( 'body' ).append( file.$element, button.$element ); + * + * You can also choose to {@link #uploadToStash stash the upload} and + * {@link #finishStashUpload finalize} it later: + * + * var file, // Some file object + * upload = new mw.Upload, + * stashPromise = $.Deferred(); + * + * upload.setFile( file ); + * upload.uploadToStash().then( function () { + * stashPromise.resolve(); + * } ); + * + * stashPromise.then( function () { + * upload.setFilename( 'foo' ); + * upload.setText( 'bar' ); + * upload.finishStashUpload().then( function () { + * console.log( 'Done!' ); + * } ); + * } ); + * + * @class mw.Upload + * + * @constructor + * @param {Object|mw.Api} [apiconfig] A mw.Api object (or subclass), or configuration + * to pass to the constructor of mw.Api. + */ + function Upload( apiconfig ) { + this.api = ( apiconfig instanceof mw.Api ) ? apiconfig : new mw.Api( apiconfig ); + + this.watchlist = false; + this.text = ''; + this.comment = ''; + this.filename = null; + this.file = null; + this.setState( Upload.State.NEW ); + + this.imageinfo = undefined; + } + + UP = Upload.prototype; + + /** + * Get the mw.Api instance used by this Upload object. + * + * @return {jQuery.Promise} + * @return {Function} return.done + * @return {mw.Api} return.done.api + */ + UP.getApi = function () { + return $.Deferred().resolve( this.api ).promise(); + }; + + /** + * Set the text of the file page, to be created on file upload. + * + * @param {string} text + */ + UP.setText = function ( text ) { + this.text = text; + }; + + /** + * Set the filename, to be finalized on upload. + * + * @param {string} filename + */ + UP.setFilename = function ( filename ) { + this.filename = filename; + }; + + /** + * Set the stashed file to finish uploading. + * + * @param {string} filekey + */ + UP.setFilekey = function ( filekey ) { + var upload = this; + + this.setState( Upload.State.STASHED ); + this.stashPromise = $.Deferred().resolve( function ( data ) { + return upload.api.uploadFromStash( filekey, data ); + } ); + }; + + /** + * Sets the filename based on the filename as it was on the upload. + */ + UP.setFilenameFromFile = function () { + var file = this.getFile(); + if ( !file ) { + return; + } + if ( file.nodeType && file.nodeType === Node.ELEMENT_NODE ) { + // File input element, use getBasename to cut out the path + this.setFilename( this.getBasename( file.value ) ); + } else if ( file.name ) { + // HTML5 FileAPI File object, but use getBasename to be safe + this.setFilename( this.getBasename( file.name ) ); + } else { + // If we ever implement uploading files from clipboard, they might not have a name + this.setFilename( '?' ); + } + }; + + /** + * Set the file to be uploaded. + * + * @param {HTMLInputElement|File|Blob} file + */ + UP.setFile = function ( file ) { + this.file = file; + }; + + /** + * Set whether the file should be watchlisted after upload. + * + * @param {boolean} watchlist + */ + UP.setWatchlist = function ( watchlist ) { + this.watchlist = watchlist; + }; + + /** + * Set the edit comment for the upload. + * + * @param {string} comment + */ + UP.setComment = function ( comment ) { + this.comment = comment; + }; + + /** + * Get the text of the file page, to be created on file upload. + * + * @return {string} + */ + UP.getText = function () { + return this.text; + }; + + /** + * Get the filename, to be finalized on upload. + * + * @return {string} + */ + UP.getFilename = function () { + return this.filename; + }; + + /** + * Get the file being uploaded. + * + * @return {HTMLInputElement|File|Blob} + */ + UP.getFile = function () { + return this.file; + }; + + /** + * Get the boolean for whether the file will be watchlisted after upload. + * + * @return {boolean} + */ + UP.getWatchlist = function () { + return this.watchlist; + }; + + /** + * Get the current value of the edit comment for the upload. + * + * @return {string} + */ + UP.getComment = function () { + return this.comment; + }; + + /** + * Gets the base filename from a path name. + * + * @param {string} path + * @return {string} + */ + UP.getBasename = function ( path ) { + if ( path === undefined || path === null ) { + return ''; + } + + // Find the index of the last path separator in the + // path, and add 1. Then, take the entire string after that. + return path.slice( + Math.max( + path.lastIndexOf( '/' ), + path.lastIndexOf( '\\' ) + ) + 1 + ); + }; + + /** + * Sets the state and state details (if any) of the upload. + * + * @param {mw.Upload.State} state + * @param {Object} stateDetails + */ + UP.setState = function ( state, stateDetails ) { + this.state = state; + this.stateDetails = stateDetails; + }; + + /** + * Gets the state of the upload. + * + * @return {mw.Upload.State} + */ + UP.getState = function () { + return this.state; + }; + + /** + * Gets details of the current state. + * + * @return {string} + */ + UP.getStateDetails = function () { + return this.stateDetails; + }; + + /** + * Get the imageinfo object for the finished upload. + * Only available once the upload is finished! Don't try to get it + * beforehand. + * + * @return {Object|undefined} + */ + UP.getImageInfo = function () { + return this.imageinfo; + }; + + /** + * Upload the file directly. + * + * @return {jQuery.Promise} + */ + UP.upload = function () { + var upload = this; + + if ( !this.getFile() ) { + return $.Deferred().reject( 'No file to upload. Call setFile to add one.' ); + } + + if ( !this.getFilename() ) { + return $.Deferred().reject( 'No filename set. Call setFilename to add one.' ); + } + + this.setState( Upload.State.UPLOADING ); + + return this.api.chunkedUpload( this.getFile(), { + watchlist: ( this.getWatchlist() ) ? 1 : undefined, + comment: this.getComment(), + filename: this.getFilename(), + text: this.getText() + } ).then( function ( result ) { + upload.setState( Upload.State.UPLOADED ); + upload.imageinfo = result.upload.imageinfo; + return result; + }, function ( errorCode, result ) { + if ( result && result.upload && result.upload.warnings ) { + upload.setState( Upload.State.WARNING, result ); + } else { + upload.setState( Upload.State.ERROR, result ); + } + return $.Deferred().reject( errorCode, result ); + } ); + }; + + /** + * Upload the file to the stash to be completed later. + * + * @return {jQuery.Promise} + */ + UP.uploadToStash = function () { + var upload = this; + + if ( !this.getFile() ) { + return $.Deferred().reject( 'No file to upload. Call setFile to add one.' ); + } + + if ( !this.getFilename() ) { + this.setFilenameFromFile(); + } + + this.setState( Upload.State.UPLOADING ); + + this.stashPromise = this.api.chunkedUploadToStash( this.getFile(), { + filename: this.getFilename() + } ).then( function ( finishStash ) { + upload.setState( Upload.State.STASHED ); + return finishStash; + }, function ( errorCode, result ) { + if ( result && result.upload && result.upload.warnings ) { + upload.setState( Upload.State.WARNING, result ); + } else { + upload.setState( Upload.State.ERROR, result ); + } + return $.Deferred().reject( errorCode, result ); + } ); + + return this.stashPromise; + }; + + /** + * Finish a stash upload. + * + * @return {jQuery.Promise} + */ + UP.finishStashUpload = function () { + var upload = this; + + if ( !this.stashPromise ) { + return $.Deferred().reject( 'This upload has not been stashed, please upload it to the stash first.' ); + } + + return this.stashPromise.then( function ( finishStash ) { + upload.setState( Upload.State.UPLOADING ); + + return finishStash( { + watchlist: ( upload.getWatchlist() ) ? 1 : undefined, + comment: upload.getComment(), + filename: upload.getFilename(), + text: upload.getText() + } ).then( function ( result ) { + upload.setState( Upload.State.UPLOADED ); + upload.imageinfo = result.upload.imageinfo; + return result; + }, function ( errorCode, result ) { + if ( result && result.upload && result.upload.warnings ) { + upload.setState( Upload.State.WARNING, result ); + } else { + upload.setState( Upload.State.ERROR, result ); + } + return $.Deferred().reject( errorCode, result ); + } ); + } ); + }; + + /** + * @enum mw.Upload.State + * State of uploads represented in simple terms. + */ + Upload.State = { + /** Upload not yet started */ + NEW: 0, + + /** Upload finished, but there was a warning */ + WARNING: 1, + + /** Upload finished, but there was an error */ + ERROR: 2, + + /** Upload in progress */ + UPLOADING: 3, + + /** Upload finished, but not published, call #finishStashUpload */ + STASHED: 4, + + /** Upload finished and published */ + UPLOADED: 5 + }; + + mw.Upload = Upload; +}( 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..21c55c7001 --- /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.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 new file mode 100644 index 0000000000..0038ed8ecf --- /dev/null +++ b/resources/src/mediawiki.api.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/resources/src/mediawiki.apipretty.css b/resources/src/mediawiki.apipretty.css new file mode 100644 index 0000000000..99e4569581 --- /dev/null +++ b/resources/src/mediawiki.apipretty.css @@ -0,0 +1,11 @@ +.mw-special-ApiHelp h1.firstHeading { + display: none; +} + +.api-pretty-header { + font-size: small; +} + +.api-pretty-content { + white-space: pre-wrap; +} diff --git a/resources/src/mediawiki.checkboxtoggle.js b/resources/src/mediawiki.checkboxtoggle.js new file mode 100644 index 0000000000..76bc86c5c0 --- /dev/null +++ b/resources/src/mediawiki.checkboxtoggle.js @@ -0,0 +1,38 @@ +/*! + * Allows users to perform all / none / invert operations on a list of + * checkboxes on the page. + * + * @licence GNU GPL v2+ + * @author Luke Faraone + * + * Based on ext.nuke.js from https://www.mediawiki.org/wiki/Extension:Nuke by + * Jeroen De Dauw + */ + +( function ( $ ) { + 'use strict'; + + $( function () { + // FIXME: This shouldn't be a global selector to avoid conflicts + // with unrelated content on the same page. (T131318) + var $checkboxes = $( 'li input[type="checkbox"]' ); + + function selectAll( check ) { + $checkboxes.prop( 'checked', check ); + } + + $( '.mw-checkbox-all' ).click( function () { + selectAll( true ); + } ); + $( '.mw-checkbox-none' ).click( function () { + selectAll( false ); + } ); + $( '.mw-checkbox-invert' ).click( function () { + $checkboxes.prop( 'checked', function ( i, val ) { + return !val; + } ); + } ); + + } ); + +}( jQuery ) ); diff --git a/resources/src/mediawiki.checkboxtoggle.styles.css b/resources/src/mediawiki.checkboxtoggle.styles.css new file mode 100644 index 0000000000..3da0d438ce --- /dev/null +++ b/resources/src/mediawiki.checkboxtoggle.styles.css @@ -0,0 +1,3 @@ +.client-nojs .mw-checkbox-toggle-controls { + display: none; +} diff --git a/resources/src/mediawiki.notification.convertmessagebox.js b/resources/src/mediawiki.notification.convertmessagebox.js new file mode 100644 index 0000000000..5d46de60bb --- /dev/null +++ b/resources/src/mediawiki.notification.convertmessagebox.js @@ -0,0 +1,64 @@ +/** + * Usage: + * + * var convertmessagebox = require( 'mediawiki.notification.convertmessagebox' ); + * + * @class mw.plugin.convertmessagebox + * @singleton + */ +( function ( mw, $ ) { + 'use strict'; + + /** + * Convert a messagebox to a notification. + * + * Checks if a message box with class `.mw-notify-success`, `.mw-notify-warning`, or `.mw-notify-error` + * exists and converts it into a mw.Notification with the text of the element or a given message key. + * + * By default the notification will automatically hide after 5s, or when the user clicks the element. + * This can be overridden by setting attribute `data-mw-autohide="true"`. + * + * @param {Object} [options] Options + * @param {mw.Message} [options.msg] Message key (must be loaded already) + */ + function convertmessagebox( options ) { + var $msgBox, type, autoHide, msg, notif, + $successBox = $( '.mw-notify-success' ), + $warningBox = $( '.mw-notify-warning' ), + $errorBox = $( '.mw-notify-error' ); + + // If there is a message box and javascript is enabled, use a slick notification instead! + if ( $successBox.length ) { + $msgBox = $successBox; + type = 'info'; + } else if ( $warningBox.length ) { + $msgBox = $warningBox; + type = 'warn'; + } else if ( $errorBox.length ) { + $msgBox = $errorBox; + type = 'error'; + } else { + return; + } + + autoHide = $msgBox.attr( 'data-mw-autohide' ) === 'true'; + + // If the msg param is given, use it, otherwise use the text of the successbox + msg = options && options.msg || $msgBox.text(); + $msgBox.detach(); + + notif = mw.notification.notify( msg, { autoHide: autoHide, type: type } ); + if ( !autoHide ) { + // 'change' event not reliable! + $( document ).one( 'keydown mousedown', function () { + if ( notif ) { + notif.close(); + notif = null; + } + } ); + } + } + + module.exports = convertmessagebox; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.notification.convertmessagebox.styles.less b/resources/src/mediawiki.notification.convertmessagebox.styles.less new file mode 100644 index 0000000000..2371f4e8d8 --- /dev/null +++ b/resources/src/mediawiki.notification.convertmessagebox.styles.less @@ -0,0 +1,7 @@ +.client-js { + .mw-notify-success, + .mw-notify-warning, + .mw-notify-error { + display: none; + } +} diff --git a/resources/src/mediawiki.page.gallery.js b/resources/src/mediawiki.page.gallery.js new file mode 100644 index 0000000000..79937e5706 --- /dev/null +++ b/resources/src/mediawiki.page.gallery.js @@ -0,0 +1,268 @@ +/*! + * Show gallery captions when focused. Copied directly from jquery.mw-jump.js. + * Also Dynamically resize images to justify them. + */ +( function ( mw, $ ) { + var $galleries, + bound = false, + // Is there a better way to detect a touchscreen? Current check taken from stack overflow. + isTouchScreen = !!( window.ontouchstart !== undefined || + window.DocumentTouch !== undefined && document instanceof window.DocumentTouch + ); + + /** + * Perform the layout justification. + * + * @ignore + * @context {HTMLElement} A `ul.mw-gallery-*` element + */ + function justify() { + var lastTop, + $img, + imgWidth, + imgHeight, + captionWidth, + rows = [], + $gallery = $( this ); + + $gallery.children( 'li.gallerybox' ).each( function () { + // Math.floor to be paranoid if things are off by 0.00000000001 + var top = Math.floor( $( this ).position().top ), + $this = $( this ); + + if ( top !== lastTop ) { + rows[ rows.length ] = []; + lastTop = top; + } + + $img = $this.find( 'div.thumb a.image img' ); + if ( $img.length && $img[ 0 ].height ) { + imgHeight = $img[ 0 ].height; + imgWidth = $img[ 0 ].width; + } else { + // If we don't have a real image, get the containing divs width/height. + // Note that if we do have a real image, using this method will generally + // give the same answer, but can be different in the case of a very + // narrow image where extra padding is added. + imgHeight = $this.children().children( 'div:first' ).height(); + imgWidth = $this.children().children( 'div:first' ).width(); + } + + // Hack to make an edge case work ok + if ( imgHeight < 30 ) { + // Don't try and resize this item. + imgHeight = 0; + } + + captionWidth = $this.children().children( 'div.gallerytextwrapper' ).width(); + rows[ rows.length - 1 ][ rows[ rows.length - 1 ].length ] = { + $elm: $this, + width: $this.outerWidth(), + imgWidth: imgWidth, + // XXX: can divide by 0 ever happen? + aspect: imgWidth / imgHeight, + captionWidth: captionWidth, + height: imgHeight + }; + + // Save all boundaries so we can restore them on window resize + $this.data( 'imgWidth', imgWidth ); + $this.data( 'imgHeight', imgHeight ); + $this.data( 'width', $this.outerWidth() ); + $this.data( 'captionWidth', captionWidth ); + } ); + + ( function () { + var maxWidth, + combinedAspect, + combinedPadding, + curRow, + curRowHeight, + wantedWidth, + preferredHeight, + newWidth, + padding, + $outerDiv, + $innerDiv, + $imageDiv, + $imageElm, + imageElm, + $caption, + i, + j, + avgZoom, + totalZoom = 0; + + for ( i = 0; i < rows.length; i++ ) { + maxWidth = $gallery.width(); + combinedAspect = 0; + combinedPadding = 0; + curRow = rows[ i ]; + curRowHeight = 0; + + for ( j = 0; j < curRow.length; j++ ) { + if ( curRowHeight === 0 ) { + if ( isFinite( curRow[ j ].height ) ) { + // Get the height of this row, by taking the first + // non-out of bounds height + curRowHeight = curRow[ j ].height; + } + } + + if ( curRow[ j ].aspect === 0 || !isFinite( curRow[ j ].aspect ) ) { + // One of the dimensions are 0. Probably should + // not try to resize. + combinedPadding += curRow[ j ].width; + } else { + combinedAspect += curRow[ j ].aspect; + combinedPadding += curRow[ j ].width - curRow[ j ].imgWidth; + } + } + + // Add some padding for inter-element spacing. + combinedPadding += 5 * curRow.length; + wantedWidth = maxWidth - combinedPadding; + preferredHeight = wantedWidth / combinedAspect; + + if ( preferredHeight > curRowHeight * 1.5 ) { + // Only expand at most 1.5 times current size + // As that's as high a resolution as we have. + // Also on the off chance there is a bug in this + // code, would prevent accidentally expanding to + // be 10 billion pixels wide. + if ( i === rows.length - 1 ) { + // If its the last row, and we can't fit it, + // don't make the entire row huge. + avgZoom = ( totalZoom / ( rows.length - 1 ) ) * curRowHeight; + if ( isFinite( avgZoom ) && avgZoom >= 1 && avgZoom <= 1.5 ) { + preferredHeight = avgZoom; + } else { + // Probably a single row gallery + preferredHeight = curRowHeight; + } + } else { + preferredHeight = 1.5 * curRowHeight; + } + } + if ( !isFinite( preferredHeight ) ) { + // This *definitely* should not happen. + // Skip this row. + continue; + } + if ( preferredHeight < 5 ) { + // Well something clearly went wrong... + // Skip this row. + continue; + } + + if ( preferredHeight / curRowHeight > 1 ) { + totalZoom += preferredHeight / curRowHeight; + } else { + // If we shrink, still consider that a zoom of 1 + totalZoom += 1; + } + + for ( j = 0; j < curRow.length; j++ ) { + newWidth = preferredHeight * curRow[ j ].aspect; + padding = curRow[ j ].width - curRow[ j ].imgWidth; + $outerDiv = curRow[ j ].$elm; + $innerDiv = $outerDiv.children( 'div' ).first(); + $imageDiv = $innerDiv.children( 'div.thumb' ); + $imageElm = $imageDiv.find( 'img' ).first(); + imageElm = $imageElm.length ? $imageElm[ 0 ] : null; + $caption = $outerDiv.find( 'div.gallerytextwrapper' ); + + // Since we are going to re-adjust the height, the vertical + // centering margins need to be reset. + $imageDiv.children( 'div' ).css( 'margin', '0px auto' ); + + if ( newWidth < 60 || !isFinite( newWidth ) ) { + // Making something skinnier than this will mess up captions, + if ( newWidth < 1 || !isFinite( newWidth ) ) { + $innerDiv.height( preferredHeight ); + // Don't even try and touch the image size if it could mean + // making it disappear. + continue; + } + } else { + $outerDiv.width( newWidth + padding ); + $innerDiv.width( newWidth + padding ); + $imageDiv.width( newWidth ); + $caption.width( curRow[ j ].captionWidth + ( newWidth - curRow[ j ].imgWidth ) ); + } + + if ( imageElm ) { + // We don't always have an img, e.g. in the case of an invalid file. + imageElm.width = newWidth; + imageElm.height = preferredHeight; + } else { + // Not a file box. + $imageDiv.height( preferredHeight ); + } + } + } + }() ); + } + + function handleResizeStart() { + $galleries.children( 'li.gallerybox' ).each( function () { + var imgWidth = $( this ).data( 'imgWidth' ), + imgHeight = $( this ).data( 'imgHeight' ), + width = $( this ).data( 'width' ), + captionWidth = $( this ).data( 'captionWidth' ), + $innerDiv = $( this ).children( 'div' ).first(), + $imageDiv = $innerDiv.children( 'div.thumb' ), + $imageElm, imageElm; + + // Restore original sizes so we can arrange the elements as on freshly loaded page + $( this ).width( width ); + $innerDiv.width( width ); + $imageDiv.width( imgWidth ); + $( this ).find( 'div.gallerytextwrapper' ).width( captionWidth ); + + $imageElm = $( this ).find( 'img' ).first(); + imageElm = $imageElm.length ? $imageElm[ 0 ] : null; + if ( imageElm ) { + imageElm.width = imgWidth; + imageElm.height = imgHeight; + } else { + $imageDiv.height( imgHeight ); + } + } ); + } + + function handleResizeEnd() { + $galleries.each( justify ); + } + + mw.hook( 'wikipage.content' ).add( function ( $content ) { + if ( isTouchScreen ) { + // Always show the caption for a touch screen. + $content.find( 'ul.mw-gallery-packed-hover' ) + .addClass( 'mw-gallery-packed-overlay' ) + .removeClass( 'mw-gallery-packed-hover' ); + } else { + // Note use of just "a", not a.image, since we want this to trigger if a link in + // the caption receives focus + $content.find( 'ul.mw-gallery-packed-hover li.gallerybox' ).on( 'focus blur', 'a', function ( e ) { + // Confusingly jQuery leaves e.type as focusout for delegated blur events + var gettingFocus = e.type !== 'blur' && e.type !== 'focusout'; + $( this ).closest( 'li.gallerybox' ).toggleClass( 'mw-gallery-focused', gettingFocus ); + } ); + } + + $galleries = $content.find( 'ul.mw-gallery-packed-overlay, ul.mw-gallery-packed-hover, ul.mw-gallery-packed' ); + // Call the justification asynchronous because live preview fires the hook with detached $content. + setTimeout( function () { + $galleries.each( justify ); + + // Bind here instead of in the top scope as the callbacks use $galleries. + if ( !bound ) { + bound = true; + $( window ) + .resize( $.debounce( 300, true, handleResizeStart ) ) + .resize( $.debounce( 300, handleResizeEnd ) ); + } + } ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.page.gallery.slideshow.js b/resources/src/mediawiki.page.gallery.slideshow.js new file mode 100644 index 0000000000..6e9ff0e7cd --- /dev/null +++ b/resources/src/mediawiki.page.gallery.slideshow.js @@ -0,0 +1,460 @@ +/*! + * mw.GallerySlideshow: Interface controls for the slideshow gallery + */ +( function ( mw, $, OO ) { + /** + * mw.GallerySlideshow encapsulates the user interface of the slideshow + * galleries. An object is instantiated for each `.mw-gallery-slideshow` + * element. + * + * @class mw.GallerySlideshow + * @uses mw.Title + * @uses mw.Api + * @param {jQuery} gallery The `