* 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
'targets' => [ 'desktop' ],
],
'mediawiki.template' => [
- 'scripts' => 'resources/src/mediawiki/mediawiki.template.js',
+ 'scripts' => 'resources/src/mediawiki.template.js',
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.template.mustache' => [
'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',
'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',
'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',
'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',
],
'dependencies' => 'mediawiki.ForeignApi.core',
],
'mediawiki.ForeignApi.core' => [
- 'scripts' => 'resources/src/mediawiki/ForeignApi.js',
+ 'scripts' => 'resources/src/mediawiki.ForeignApi.core.js',
'dependencies' => [
'mediawiki.api',
'oojs',
'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' ],
],
'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',
'class' => ResourceLoaderUploadDialogModule::class,
],
'mediawiki.ForeignStructuredUpload' => [
- 'scripts' => 'resources/src/mediawiki/mediawiki.ForeignStructuredUpload.js',
+ 'scripts' => 'resources/src/mediawiki.ForeignStructuredUpload.js',
'dependencies' => [
'mediawiki.ForeignUpload',
'mediawiki.ForeignStructuredUpload.config',
],
'mediawiki.Upload.Dialog' => [
'scripts' => [
- 'resources/src/mediawiki/mediawiki.Upload.Dialog.js',
+ 'resources/src/mediawiki.Upload.Dialog.js',
],
'dependencies' => [
'mediawiki.Upload.BookletLayout',
'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',
/* 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',
'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',
]
],
'mediawiki.page.ready' => [
- 'scripts' => 'resources/src/mediawiki/page/ready.js',
+ 'scripts' => 'resources/src/mediawiki.page.ready.js',
'dependencies' => [
'jquery.accessKeyLabel',
'jquery.checkboxShiftClick',
'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',
],
],
'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',
],
],
'mediawiki.page.rollback' => [
- 'scripts' => 'resources/src/mediawiki/page/rollback.js',
+ 'scripts' => 'resources/src/mediawiki.page.rollback.js',
'dependencies' => [
'mediawiki.api.rollback',
'mediawiki.notify',
],
],
'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',
--- /dev/null
+( 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
+ * <https://www.mediawiki.org/wiki/Manual:$wgCrossSiteAJAXdomains> 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 ) );
--- /dev/null
+( 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 <https://commons.wikimedia.org/wiki/Commons:Structured_data> 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 ) );
--- /dev/null
+( 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 ) );
--- /dev/null
+( 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 ) );
--- /dev/null
+( 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 ) );
--- /dev/null
+/**
+ * @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 ) );
--- /dev/null
+/**
+ * @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 ) );
--- /dev/null
+( 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 ) );
--- /dev/null
+/**
+ * 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 ) );
--- /dev/null
+/**
+ * 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 ) );
--- /dev/null
+/**
+ * @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 ) );
--- /dev/null
+/**
+ * @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 ) );
--- /dev/null
+/**
+ * @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 ) );
--- /dev/null
+/**
+ * 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 $( '<input>' ).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 = $( '<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 ) );
--- /dev/null
+/**
+ * @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 ) );
--- /dev/null
+/**
+ * @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 ) );
--- /dev/null
+.mw-special-ApiHelp h1.firstHeading {
+ display: none;
+}
+
+.api-pretty-header {
+ font-size: small;
+}
+
+.api-pretty-content {
+ white-space: pre-wrap;
+}
--- /dev/null
+/*!
+ * Allows users to perform all / none / invert operations on a list of
+ * checkboxes on the page.
+ *
+ * @licence GNU GPL v2+
+ * @author Luke Faraone <luke at faraone dot cc>
+ *
+ * Based on ext.nuke.js from https://www.mediawiki.org/wiki/Extension:Nuke by
+ * Jeroen De Dauw <jeroendedauw at gmail dot com>
+ */
+
+( 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 ) );
--- /dev/null
+.client-nojs .mw-checkbox-toggle-controls {
+ display: none;
+}
--- /dev/null
+/**
+ * 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 ) );
--- /dev/null
+.client-js {
+ .mw-notify-success,
+ .mw-notify-warning,
+ .mw-notify-error {
+ display: none;
+ }
+}
--- /dev/null
+/*!
+ * 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 ) );
--- /dev/null
+/*!
+ * 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 `<ul>` element of the gallery.
+ */
+ mw.GallerySlideshow = function ( gallery ) {
+ // Properties
+ this.$gallery = $( gallery );
+ this.$galleryCaption = this.$gallery.find( '.gallerycaption' );
+ this.$galleryBox = this.$gallery.find( '.gallerybox' );
+ this.$currentImage = null;
+ this.imageInfoCache = {};
+ if ( this.$gallery.parent().attr( 'id' ) !== 'mw-content-text' ) {
+ this.$container = this.$gallery.parent();
+ }
+
+ // Initialize
+ this.drawCarousel();
+ this.setSizeRequirement();
+ this.toggleThumbnails( !!this.$gallery.attr( 'data-showthumbnails' ) );
+ this.showCurrentImage();
+
+ // Events
+ $( window ).on(
+ 'resize',
+ OO.ui.debounce(
+ this.setSizeRequirement.bind( this ),
+ 100
+ )
+ );
+
+ // Disable thumbnails' link, instead show the image in the carousel
+ this.$galleryBox.on( 'click', function ( e ) {
+ this.$currentImage = $( e.currentTarget );
+ this.showCurrentImage();
+ return false;
+ }.bind( this ) );
+ };
+
+ /* Properties */
+ /**
+ * @property {jQuery} $gallery The `<ul>` element of the gallery.
+ */
+
+ /**
+ * @property {jQuery} $galleryCaption The `<li>` that has the gallery caption.
+ */
+
+ /**
+ * @property {jQuery} $galleryBox Selection of `<li>` elements that have thumbnails.
+ */
+
+ /**
+ * @property {jQuery} $carousel The `<li>` elements that contains the carousel.
+ */
+
+ /**
+ * @property {jQuery} $interface The `<div>` elements that contains the interface buttons.
+ */
+
+ /**
+ * @property {jQuery} $img The `<img>` element that'll display the current image.
+ */
+
+ /**
+ * @property {jQuery} $imgLink The `<a>` element that links to the image's File page.
+ */
+
+ /**
+ * @property {jQuery} $imgCaption The `<p>` element that holds the image caption.
+ */
+
+ /**
+ * @property {jQuery} $imgContainer The `<div>` element that contains the image.
+ */
+
+ /**
+ * @property {jQuery} $currentImage The `<li>` element of the current image.
+ */
+
+ /**
+ * @property {jQuery} $container If the gallery contained in an element that is
+ * not the main content element, then it stores that element.
+ */
+
+ /**
+ * @property {Object} imageInfoCache A key value pair of thumbnail URLs and image info.
+ */
+
+ /**
+ * @property {number} imageWidth Width of the image based on viewport size
+ */
+
+ /**
+ * @property {number} imageHeight Height of the image based on viewport size
+ * the URLs in the required size.
+ */
+
+ /* Setup */
+ OO.initClass( mw.GallerySlideshow );
+
+ /* Methods */
+ /**
+ * Draws the carousel and the interface around it.
+ */
+ mw.GallerySlideshow.prototype.drawCarousel = function () {
+ var next, prev, toggle, interfaceElements, carouselStack;
+
+ this.$carousel = $( '<li>' ).addClass( 'gallerycarousel' );
+
+ // Buttons for the interface
+ prev = new OO.ui.ButtonWidget( {
+ framed: false,
+ icon: 'previous'
+ } ).on( 'click', this.prevImage.bind( this ) );
+
+ next = new OO.ui.ButtonWidget( {
+ framed: false,
+ icon: 'next'
+ } ).on( 'click', this.nextImage.bind( this ) );
+
+ toggle = new OO.ui.ButtonWidget( {
+ framed: false,
+ icon: 'imageGallery',
+ title: mw.msg( 'gallery-slideshow-toggle' )
+ } ).on( 'click', this.toggleThumbnails.bind( this ) );
+
+ interfaceElements = new OO.ui.PanelLayout( {
+ expanded: false,
+ classes: [ 'mw-gallery-slideshow-buttons' ],
+ $content: $( '<div>' ).append(
+ prev.$element,
+ toggle.$element,
+ next.$element
+ )
+ } );
+ this.$interface = interfaceElements.$element;
+
+ // Containers for the current image, caption etc.
+ this.$img = $( '<img>' );
+ this.$imgLink = $( '<a>' ).append( this.$img );
+ this.$imgCaption = $( '<p>' ).attr( 'class', 'mw-gallery-slideshow-caption' );
+ this.$imgContainer = $( '<div>' )
+ .attr( 'class', 'mw-gallery-slideshow-img-container' )
+ .append( this.$imgLink );
+
+ carouselStack = new OO.ui.StackLayout( {
+ continuous: true,
+ expanded: false,
+ items: [
+ interfaceElements,
+ new OO.ui.PanelLayout( {
+ expanded: false,
+ $content: this.$imgContainer
+ } ),
+ new OO.ui.PanelLayout( {
+ expanded: false,
+ $content: this.$imgCaption
+ } )
+ ]
+ } );
+ this.$carousel.append( carouselStack.$element );
+
+ // Append below the caption or as the first element in the gallery
+ if ( this.$galleryCaption.length !== 0 ) {
+ this.$galleryCaption.after( this.$carousel );
+ } else {
+ this.$gallery.prepend( this.$carousel );
+ }
+ };
+
+ /**
+ * Sets the {@link #imageWidth} and {@link #imageHeight} properties
+ * based on the size of the window. Also flushes the
+ * {@link #imageInfoCache} as we'll now need URLs for a different
+ * size.
+ */
+ mw.GallerySlideshow.prototype.setSizeRequirement = function () {
+ var w, h;
+
+ if ( this.$container !== undefined ) {
+ w = this.$container.width() * 0.9;
+ h = ( this.$container.height() - this.getChromeHeight() ) * 0.9;
+ } else {
+ w = this.$imgContainer.width();
+ h = Math.min( $( window ).height() * ( 3 / 4 ), this.$imgContainer.width() ) - this.getChromeHeight();
+ }
+
+ // Only update and flush the cache if the size changed
+ if ( w !== this.imageWidth || h !== this.imageHeight ) {
+ this.imageWidth = w;
+ this.imageHeight = h;
+ this.imageInfoCache = {};
+ this.setImageSize();
+ }
+ };
+
+ /**
+ * Gets the height of the interface elements and the
+ * gallery's caption.
+ *
+ * @return {number} Height
+ */
+ mw.GallerySlideshow.prototype.getChromeHeight = function () {
+ return this.$interface.outerHeight() + this.$galleryCaption.outerHeight();
+ };
+
+ /**
+ * Sets the height and width of {@link #$img} based on the
+ * proportion of the image and the values generated by
+ * {@link #setSizeRequirement}.
+ *
+ * @return {boolean} Whether or not the image was sized.
+ */
+ mw.GallerySlideshow.prototype.setImageSize = function () {
+ if ( this.$img === undefined || this.$thumbnail === undefined ) {
+ return false;
+ }
+
+ // Reset height and width
+ this.$img
+ .removeAttr( 'width' )
+ .removeAttr( 'height' );
+
+ // Stretch image to take up the required size
+ this.$img.attr( 'height', ( this.imageHeight - this.$imgCaption.outerHeight() ) + 'px' );
+
+ // Make the image smaller in case the current image
+ // size is larger than the original file size.
+ this.getImageInfo( this.$thumbnail ).done( function ( info ) {
+ // NOTE: There will be a jump when resizing the window
+ // because the cache is cleared and this a new network request.
+ if (
+ info.thumbwidth < this.$img.width() ||
+ info.thumbheight < this.$img.height()
+ ) {
+ this.$img.attr( 'width', info.thumbwidth + 'px' );
+ this.$img.attr( 'height', info.thumbheight + 'px' );
+ }
+ }.bind( this ) );
+
+ return true;
+ };
+
+ /**
+ * Displays the image set as {@link #$currentImage} in the carousel.
+ */
+ mw.GallerySlideshow.prototype.showCurrentImage = function () {
+ var imageLi = this.getCurrentImage(),
+ caption = imageLi.find( '.gallerytext' );
+
+ // The order of the following is important for size calculations
+ // 1. Highlight current thumbnail
+ this.$gallery
+ .find( '.gallerybox.slideshow-current' )
+ .removeClass( 'slideshow-current' );
+ imageLi.addClass( 'slideshow-current' );
+
+ // 2. Show thumbnail
+ this.$thumbnail = imageLi.find( 'img' );
+ this.$img.attr( 'src', this.$thumbnail.attr( 'src' ) );
+ this.$img.attr( 'alt', this.$thumbnail.attr( 'alt' ) );
+ this.$imgLink.attr( 'href', imageLi.find( 'a' ).eq( 0 ).attr( 'href' ) );
+
+ // 3. Copy caption
+ this.$imgCaption
+ .empty()
+ .append( caption.clone() );
+
+ // 4. Stretch thumbnail to correct size
+ this.setImageSize();
+
+ // 5. Load image at the required size
+ this.loadImage( this.$thumbnail ).done( function ( info, $img ) {
+ // Show this image to the user only if its still the current one
+ if ( this.$thumbnail.attr( 'src' ) === $img.attr( 'src' ) ) {
+ this.$img.attr( 'src', info.thumburl );
+ this.setImageSize();
+
+ // Keep the next image ready
+ this.loadImage( this.getNextImage().find( 'img' ) );
+ }
+ }.bind( this ) );
+ };
+
+ /**
+ * Loads the full image given the `<img>` element of the thumbnail.
+ *
+ * @param {Object} $img
+ * @return {jQuery.Promise} Resolves with the images URL and original
+ * element once the image has loaded.
+ */
+ mw.GallerySlideshow.prototype.loadImage = function ( $img ) {
+ var img, d = $.Deferred();
+
+ this.getImageInfo( $img ).done( function ( info ) {
+ img = new Image();
+ img.src = info.thumburl;
+ img.onload = function () {
+ d.resolve( info, $img );
+ };
+ img.onerror = function () {
+ d.reject();
+ };
+ } ).fail( function () {
+ d.reject();
+ } );
+
+ return d.promise();
+ };
+
+ /**
+ * Gets the image's info given an `<img>` element.
+ *
+ * @param {Object} $img
+ * @return {jQuery.Promise} Resolves with the image's info.
+ */
+ mw.GallerySlideshow.prototype.getImageInfo = function ( $img ) {
+ var api, title, params,
+ imageSrc = $img.attr( 'src' );
+
+ // Reject promise if there is no thumbnail image
+ if ( $img[ 0 ] === undefined ) {
+ return $.Deferred().reject();
+ }
+
+ if ( this.imageInfoCache[ imageSrc ] === undefined ) {
+ api = new mw.Api();
+ // TODO: This supports only gallery of images
+ title = mw.Title.newFromImg( $img );
+ params = {
+ action: 'query',
+ formatversion: 2,
+ titles: title.toString(),
+ prop: 'imageinfo',
+ iiprop: 'url'
+ };
+
+ // Check which dimension we need to request, based on
+ // image and container proportions.
+ if ( this.getDimensionToRequest( $img ) === 'height' ) {
+ params.iiurlheight = this.imageHeight;
+ } else {
+ params.iiurlwidth = this.imageWidth;
+ }
+
+ this.imageInfoCache[ imageSrc ] = api.get( params ).then( function ( data ) {
+ if ( OO.getProp( data, 'query', 'pages', 0, 'imageinfo', 0, 'thumburl' ) !== undefined ) {
+ return data.query.pages[ 0 ].imageinfo[ 0 ];
+ } else {
+ return $.Deferred().reject();
+ }
+ } );
+ }
+
+ return this.imageInfoCache[ imageSrc ];
+ };
+
+ /**
+ * Given an image, the method checks whether to use the height
+ * or the width to request the larger image.
+ *
+ * @param {jQuery} $img
+ * @return {string}
+ */
+ mw.GallerySlideshow.prototype.getDimensionToRequest = function ( $img ) {
+ var ratio = $img.width() / $img.height();
+
+ if ( this.imageHeight * ratio <= this.imageWidth ) {
+ return 'height';
+ } else {
+ return 'width';
+ }
+ };
+
+ /**
+ * Toggles visibility of the thumbnails.
+ *
+ * @param {boolean} show Optional argument to control the state
+ */
+ mw.GallerySlideshow.prototype.toggleThumbnails = function ( show ) {
+ this.$galleryBox.toggle( show );
+ this.$carousel.toggleClass( 'mw-gallery-slideshow-thumbnails-toggled', show );
+ };
+
+ /**
+ * Getter method for {@link #$currentImage}
+ *
+ * @return {jQuery}
+ */
+ mw.GallerySlideshow.prototype.getCurrentImage = function () {
+ this.$currentImage = this.$currentImage || this.$galleryBox.eq( 0 );
+ return this.$currentImage;
+ };
+
+ /**
+ * Gets the image after the current one. Returns the first image if
+ * the current one is the last.
+ *
+ * @return {jQuery}
+ */
+ mw.GallerySlideshow.prototype.getNextImage = function () {
+ // Not the last image in the gallery
+ if ( this.$currentImage.next( '.gallerybox' )[ 0 ] !== undefined ) {
+ return this.$currentImage.next( '.gallerybox' );
+ } else {
+ return this.$galleryBox.eq( 0 );
+ }
+ };
+
+ /**
+ * Gets the image before the current one. Returns the last image if
+ * the current one is the first.
+ *
+ * @return {jQuery}
+ */
+ mw.GallerySlideshow.prototype.getPrevImage = function () {
+ // Not the first image in the gallery
+ if ( this.$currentImage.prev( '.gallerybox' )[ 0 ] !== undefined ) {
+ return this.$currentImage.prev( '.gallerybox' );
+ } else {
+ return this.$galleryBox.last();
+ }
+ };
+
+ /**
+ * Sets the {@link #$currentImage} to the next one and shows
+ * it in the carousel
+ */
+ mw.GallerySlideshow.prototype.nextImage = function () {
+ this.$currentImage = this.getNextImage();
+ this.showCurrentImage();
+ };
+
+ /**
+ * Sets the {@link #$currentImage} to the previous one and shows
+ * it in the carousel
+ */
+ mw.GallerySlideshow.prototype.prevImage = function () {
+ this.$currentImage = this.getPrevImage();
+ this.showCurrentImage();
+ };
+
+ // Bootstrap all slideshow galleries
+ mw.hook( 'wikipage.content' ).add( function ( $content ) {
+ $content.find( '.mw-gallery-slideshow' ).each( function () {
+ // eslint-disable-next-line no-new
+ new mw.GallerySlideshow( this );
+ } );
+ } );
+}( mediaWiki, jQuery, OO ) );
--- /dev/null
+/*!
+ * Implement AJAX navigation for multi-page images so the user may browse without a full page reload.
+ */
+
+/* eslint-disable no-use-before-define */
+
+( function ( mw, $ ) {
+ var jqXhr, $multipageimage, $spinner,
+ cache = {},
+ cacheOrder = [];
+
+ /* Fetch the next page, caching up to 10 last-loaded pages.
+ * @param {string} url
+ * @return {jQuery.Promise}
+ */
+ function fetchPageData( url ) {
+ if ( jqXhr && jqXhr.abort ) {
+ // Prevent race conditions and piling up pending requests
+ jqXhr.abort();
+ }
+ jqXhr = undefined;
+
+ // Try the cache
+ if ( cache[ url ] ) {
+ // Update access freshness
+ cacheOrder.splice( cacheOrder.indexOf( url ), 1 );
+ cacheOrder.push( url );
+ return $.Deferred().resolve( cache[ url ] ).promise();
+ }
+
+ // TODO Don't fetch the entire page. Ideally we'd only fetch the content portion or the data
+ // (thumbnail urls) and update the interface manually.
+ jqXhr = $.ajax( url ).then( function ( data ) {
+ return $( data ).find( 'table.multipageimage' ).contents();
+ } );
+
+ // Handle cache updates
+ jqXhr.done( function ( $contents ) {
+ jqXhr = undefined;
+
+ // Cache the newly loaded page
+ cache[ url ] = $contents;
+ cacheOrder.push( url );
+
+ // Remove the oldest entry if we're over the limit
+ if ( cacheOrder.length > 10 ) {
+ delete cache[ cacheOrder[ 0 ] ];
+ cacheOrder = cacheOrder.slice( 1 );
+ }
+ } );
+
+ return jqXhr.promise();
+ }
+
+ /* Fetch the next page and use jQuery to swap the table.multipageimage contents.
+ * @param {string} url
+ * @param {boolean} [hist=false] Whether this is a load triggered by history navigation (if
+ * true, this function won't push a new history state, for the browser did so already).
+ */
+ function switchPage( url, hist ) {
+ var $tr, promise;
+
+ // Start fetching data (might be cached)
+ promise = fetchPageData( url );
+
+ // Add a new spinner if one doesn't already exist and the data is not already ready
+ if ( !$spinner && promise.state() !== 'resolved' ) {
+ $tr = $multipageimage.find( 'tr' );
+ $spinner = $.createSpinner( {
+ size: 'large',
+ type: 'block'
+ } )
+ // Copy the old content dimensions equal so that the current scroll position is not
+ // lost between emptying the table is and receiving the new contents.
+ .css( {
+ height: $tr.outerHeight(),
+ width: $tr.outerWidth()
+ } );
+
+ $multipageimage.empty().append( $spinner );
+ }
+
+ promise.done( function ( $contents ) {
+ $spinner = undefined;
+
+ // Replace table contents
+ $multipageimage.empty().append( $contents.clone() );
+
+ bindPageNavigation( $multipageimage );
+
+ // Fire hook because the page's content has changed
+ mw.hook( 'wikipage.content' ).fire( $multipageimage );
+
+ // Update browser history and address bar. But not if we came here from a history
+ // event, in which case the url is already updated by the browser.
+ if ( history.pushState && !hist ) {
+ history.pushState( { tag: 'mw-pagination' }, document.title, url );
+ }
+ } );
+ }
+
+ function bindPageNavigation( $container ) {
+ $container.find( '.multipageimagenavbox' ).one( 'click', 'a', function ( e ) {
+ var page, url;
+
+ // Generate the same URL on client side as the one generated in ImagePage::openShowImage.
+ // We avoid using the URL in the link directly since it could have been manipulated (T68608)
+ page = Number( mw.util.getParamValue( 'page', this.href ) );
+ url = mw.util.getUrl( mw.config.get( 'wgPageName' ), { page: page } );
+
+ switchPage( url );
+ e.preventDefault();
+ } );
+
+ $container.find( 'form[name="pageselector"]' ).one( 'change submit', function ( e ) {
+ switchPage( this.action + '?' + $( this ).serialize() );
+ e.preventDefault();
+ } );
+ }
+
+ $( function () {
+ if ( mw.config.get( 'wgCanonicalNamespace' ) !== 'File' ) {
+ return;
+ }
+ $multipageimage = $( 'table.multipageimage' );
+ if ( !$multipageimage.length ) {
+ return;
+ }
+
+ bindPageNavigation( $multipageimage );
+
+ // Update the url using the History API (if available)
+ if ( history.pushState && history.replaceState ) {
+ history.replaceState( { tag: 'mw-pagination' }, '' );
+ $( window ).on( 'popstate', function ( e ) {
+ var state = e.originalEvent.state;
+ if ( state && state.tag === 'mw-pagination' ) {
+ switchPage( location.href, true );
+ }
+ } );
+ }
+ } );
+}( mediaWiki, jQuery ) );
--- /dev/null
+/*!
+ * Animate patrol links to use asynchronous API requests to
+ * patrol pages, rather than navigating to a different URI.
+ *
+ * @since 1.21
+ * @author Marius Hoch <hoo@online.de>
+ */
+( function ( mw, $ ) {
+ if ( !mw.user.tokens.exists( 'patrolToken' ) ) {
+ // Current user has no patrol right, or an old cached version of user.tokens
+ // that didn't have patrolToken yet.
+ return;
+ }
+ $( function () {
+ var $patrolLinks = $( '.patrollink[data-mw="interface"] a' );
+ $patrolLinks.on( 'click', function ( e ) {
+ var $spinner, rcid, apiRequest;
+
+ // Preload the notification module for mw.notify
+ mw.loader.load( 'mediawiki.notification' );
+
+ // Hide the link and create a spinner to show it inside the brackets.
+ $spinner = $.createSpinner( {
+ size: 'small',
+ type: 'inline'
+ } );
+ $( this ).hide().after( $spinner );
+
+ rcid = mw.util.getParamValue( 'rcid', this.href );
+ apiRequest = new mw.Api();
+
+ apiRequest.postWithToken( 'patrol', {
+ formatversion: 2,
+ action: 'patrol',
+ rcid: rcid
+ } ).done( function ( data ) {
+ var title;
+ // Remove all patrollinks from the page (including any spinners inside).
+ $patrolLinks.closest( '.patrollink' ).remove();
+ if ( data.patrol !== undefined ) {
+ // Success
+ title = new mw.Title( data.patrol.title );
+ mw.notify( mw.msg( 'markedaspatrollednotify', title.toText() ) );
+ } else {
+ // This should never happen as errors should trigger fail
+ mw.notify( mw.msg( 'markedaspatrollederrornotify' ), { type: 'error' } );
+ }
+ } ).fail( function ( error ) {
+ $spinner.remove();
+ // Restore the patrol link. This allows the user to try again
+ // (or open it in a new window, bypassing this ajax module).
+ $patrolLinks.show();
+ if ( error === 'noautopatrol' ) {
+ // Can't patrol own
+ mw.notify( mw.msg( 'markedaspatrollederror-noautopatrol' ), { type: 'warn' } );
+ } else {
+ mw.notify( mw.msg( 'markedaspatrollederrornotify' ), { type: 'error' } );
+ }
+ } );
+
+ e.preventDefault();
+ } );
+ } );
+}( mediaWiki, jQuery ) );
--- /dev/null
+( function ( mw, $ ) {
+ mw.hook( 'wikipage.content' ).add( function ( $content ) {
+ var $sortable, $collapsible;
+
+ $collapsible = $content.find( '.mw-collapsible' );
+ if ( $collapsible.length ) {
+ // Preloaded by Skin::getDefaultModules()
+ mw.loader.using( 'jquery.makeCollapsible', function () {
+ $collapsible.makeCollapsible();
+ } );
+ }
+
+ $sortable = $content.find( 'table.sortable' );
+ if ( $sortable.length ) {
+ // Preloaded by Skin::getDefaultModules()
+ mw.loader.using( 'jquery.tablesorter', function () {
+ $sortable.tablesorter();
+ } );
+ }
+
+ // Run jquery.checkboxShiftClick
+ $content.find( 'input[type="checkbox"]:not(.noshiftselect)' ).checkboxShiftClick();
+ } );
+
+ // Things outside the wikipage content
+ $( function () {
+ var $nodes;
+
+ // Add accesskey hints to the tooltips
+ $( '[accesskey]' ).updateTooltipAccessKeys();
+
+ $nodes = $( '.catlinks[data-mw="interface"]' );
+ if ( $nodes.length ) {
+ /**
+ * Fired when categories are being added to the DOM
+ *
+ * It is encouraged to fire it before the main DOM is changed (when $content
+ * is still detached). However, this order is not defined either way, so you
+ * should only rely on $content itself.
+ *
+ * This includes the ready event on a page load (including post-edit loads)
+ * and when content has been previewed with LivePreview.
+ *
+ * @event wikipage_categories
+ * @member mw.hook
+ * @param {jQuery} $content The most appropriate element containing the content,
+ * such as .catlinks
+ */
+ mw.hook( 'wikipage.categories' ).fire( $nodes );
+ }
+
+ $( '#t-print a' ).click( function ( e ) {
+ window.print();
+ e.preventDefault();
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+/*!
+ * Enhance rollback links by using asynchronous API requests,
+ * rather than navigating to an action page.
+ *
+ * @since 1.28
+ * @author Timo Tijhof
+ */
+( function ( mw, $ ) {
+
+ $( function () {
+ $( '.mw-rollback-link' ).on( 'click', 'a[data-mw="interface"]', function ( e ) {
+ var api, $spinner,
+ $link = $( this ),
+ url = this.href,
+ page = mw.util.getParamValue( 'title', url ),
+ user = mw.util.getParamValue( 'from', url );
+
+ if ( !page || user === null ) {
+ // Let native browsing handle the link
+ return true;
+ }
+
+ // Preload the notification module for mw.notify
+ mw.loader.load( 'mediawiki.notification' );
+
+ // Remove event handler so that next click (re-try) uses server action
+ $( e.delegateTarget ).off( 'click' );
+
+ // Hide the link and create a spinner to show it inside the brackets.
+ $spinner = $.createSpinner( { size: 'small', type: 'inline' } );
+ $link.hide().after( $spinner );
+
+ // @todo: data.messageHtml is no more. Convert to using errorformat=html.
+ api = new mw.Api();
+ api.rollback( page, user )
+ .then( function ( data ) {
+ mw.notify( $.parseHTML( data.messageHtml ), {
+ title: mw.msg( 'actioncomplete' )
+ } );
+
+ // Remove link container and the subsequent text node containing " | ".
+ if ( e.delegateTarget.nextSibling && e.delegateTarget.nextSibling.nodeType === Node.TEXT_NODE ) {
+ $( e.delegateTarget.nextSibling ).remove();
+ }
+ $( e.delegateTarget ).remove();
+ }, function ( errorCode, data ) {
+ var message = data && data.error && data.error.messageHtml ?
+ $.parseHTML( data.error.messageHtml ) :
+ mw.msg( 'rollbackfailed' ),
+ type = errorCode === 'alreadyrolled' ? 'warn' : 'error';
+
+ mw.notify( message, {
+ type: type,
+ title: mw.msg( 'rollbackfailed' ),
+ autoHide: false
+ } );
+
+ // Restore the link (enables user to try again)
+ $spinner.remove();
+ $link.show();
+ } );
+
+ e.preventDefault();
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+( function ( mw, $ ) {
+ // Break out of framesets
+ if ( mw.config.get( 'wgBreakFrames' ) ) {
+ // Note: In IE < 9 strict comparison to window is non-standard (the standard didn't exist yet)
+ // it works only comparing to window.self or window.window (http://stackoverflow.com/q/4850978/319266)
+ if ( window.top !== window.self ) {
+ // Un-trap us from framesets
+ window.top.location.href = location.href;
+ }
+ }
+
+ $( function () {
+ var $diff;
+
+ /**
+ * Fired when wiki content is being added to the DOM
+ *
+ * It is encouraged to fire it before the main DOM is changed (when $content
+ * is still detached). However, this order is not defined either way, so you
+ * should only rely on $content itself.
+ *
+ * This includes the ready event on a page load (including post-edit loads)
+ * and when content has been previewed with LivePreview.
+ *
+ * @event wikipage_content
+ * @member mw.hook
+ * @param {jQuery} $content The most appropriate element containing the content,
+ * such as #mw-content-text (regular content root) or #wikiPreview (live preview
+ * root)
+ */
+ mw.hook( 'wikipage.content' ).fire( $( '#mw-content-text' ) );
+
+ $diff = $( 'table.diff[data-mw="interface"]' );
+ if ( $diff.length ) {
+ /**
+ * Fired when the diff is added to a page containing a diff
+ *
+ * Similar to the {@link mw.hook#event-wikipage_content wikipage.content hook}
+ * $diff may still be detached when the hook is fired.
+ *
+ * @event wikipage_diff
+ * @member mw.hook
+ * @param {jQuery} $diff The root element of the MediaWiki diff (`table.diff`).
+ */
+ mw.hook( 'wikipage.diff' ).fire( $diff.eq( 0 ) );
+ }
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+/**
+ * Animate watch/unwatch links to use asynchronous API requests to
+ * watch pages, rather than navigating to a different URI.
+ *
+ * Usage:
+ *
+ * var watch = require( 'mediawiki.page.watch.ajax' );
+ * watch.updateWatchLink(
+ * $node,
+ * 'watch',
+ * 'loading'
+ * );
+ *
+ * @class mw.plugin.page.watch.ajax
+ * @singleton
+ */
+( function ( mw, $ ) {
+ var watch,
+ // The name of the page to watch or unwatch
+ title = mw.config.get( 'wgRelevantPageName' );
+
+ /**
+ * Update the link text, link href attribute and (if applicable)
+ * "loading" class.
+ *
+ * @param {jQuery} $link Anchor tag of (un)watch link
+ * @param {string} action One of 'watch', 'unwatch'
+ * @param {string} [state="idle"] 'idle' or 'loading'. Default is 'idle'
+ */
+ function updateWatchLink( $link, action, state ) {
+ var msgKey, $li, otherAction;
+
+ // A valid but empty jQuery object shouldn't throw a TypeError
+ if ( !$link.length ) {
+ return;
+ }
+
+ // Invalid actions shouldn't silently turn the page in an unrecoverable state
+ if ( action !== 'watch' && action !== 'unwatch' ) {
+ throw new Error( 'Invalid action' );
+ }
+
+ // message keys 'watch', 'watching', 'unwatch' or 'unwatching'.
+ msgKey = state === 'loading' ? action + 'ing' : action;
+ otherAction = action === 'watch' ? 'unwatch' : 'watch';
+ $li = $link.closest( 'li' );
+
+ // Trigger a 'watchpage' event for this List item.
+ // Announce the otherAction value as the first param.
+ // Used to monitor the state of watch link.
+ // TODO: Revise when system wide hooks are implemented
+ if ( state === undefined ) {
+ $li.trigger( 'watchpage.mw', otherAction );
+ }
+
+ $link
+ .text( mw.msg( msgKey ) )
+ .attr( 'title', mw.msg( 'tooltip-ca-' + action ) )
+ .updateTooltipAccessKeys()
+ .attr( 'href', mw.util.getUrl( title, { action: action } ) );
+
+ // Most common ID style
+ if ( $li.prop( 'id' ) === 'ca-' + otherAction ) {
+ $li.prop( 'id', 'ca-' + action );
+ }
+
+ if ( state === 'loading' ) {
+ $link.addClass( 'loading' );
+ } else {
+ $link.removeClass( 'loading' );
+ }
+ }
+
+ /**
+ * TODO: This should be moved somewhere more accessible.
+ *
+ * @private
+ * @param {string} url
+ * @return {string} The extracted action, defaults to 'view'
+ */
+ function mwUriGetAction( url ) {
+ var action, actionPaths, key, m, parts;
+
+ // TODO: Does MediaWiki give action path or query param
+ // precedence? If the former, move this to the bottom
+ action = mw.util.getParamValue( 'action', url );
+ if ( action !== null ) {
+ return action;
+ }
+
+ actionPaths = mw.config.get( 'wgActionPaths' );
+ for ( key in actionPaths ) {
+ if ( actionPaths.hasOwnProperty( key ) ) {
+ parts = actionPaths[ key ].split( '$1' );
+ parts = parts.map( mw.RegExp.escape );
+ m = new RegExp( parts.join( '(.+)' ) ).exec( url );
+ if ( m && m[ 1 ] ) {
+ return key;
+ }
+
+ }
+ }
+
+ return 'view';
+ }
+
+ // Expose public methods
+ watch = {
+ updateWatchLink: updateWatchLink
+ };
+ module.exports = watch;
+
+ $( function () {
+ var $links = $( '.mw-watchlink a[data-mw="interface"], a.mw-watchlink[data-mw="interface"]' );
+ if ( !$links.length ) {
+ // Fallback to the class-based exclusion method for backwards-compatibility
+ $links = $( '.mw-watchlink a, a.mw-watchlink' );
+ // Restrict to core interfaces, ignore user-generated content
+ $links = $links.filter( ':not( #bodyContent *, #content * )' );
+ }
+
+ $links.click( function ( e ) {
+ var mwTitle, action, api, $link;
+
+ mwTitle = mw.Title.newFromText( title );
+ action = mwUriGetAction( this.href );
+
+ if ( !mwTitle || ( action !== 'watch' && action !== 'unwatch' ) ) {
+ // Let native browsing handle the link
+ return true;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+
+ $link = $( this );
+
+ if ( $link.hasClass( 'loading' ) ) {
+ return;
+ }
+
+ updateWatchLink( $link, action, 'loading' );
+
+ // Preload the notification module for mw.notify
+ mw.loader.load( 'mediawiki.notification' );
+
+ api = new mw.Api();
+
+ api[ action ]( title )
+ .done( function ( watchResponse ) {
+ var message, otherAction = action === 'watch' ? 'unwatch' : 'watch';
+
+ if ( mwTitle.getNamespaceId() > 0 && mwTitle.getNamespaceId() % 2 === 1 ) {
+ message = action === 'watch' ? 'addedwatchtext-talk' : 'removedwatchtext-talk';
+ } else {
+ message = action === 'watch' ? 'addedwatchtext' : 'removedwatchtext';
+ }
+
+ mw.notify( mw.message( message, mwTitle.getPrefixedText() ).parseDom(), {
+ tag: 'watch-self'
+ } );
+
+ // Set link to opposite
+ updateWatchLink( $link, otherAction );
+
+ // Update the "Watch this page" checkbox on action=edit when the
+ // page is watched or unwatched via the tab (T14395).
+ $( '#wpWatchthis' ).prop( 'checked', watchResponse.watched === true );
+ } )
+ .fail( function () {
+ var msg, link;
+
+ // Reset link to non-loading mode
+ updateWatchLink( $link, action );
+
+ // Format error message
+ link = mw.html.element(
+ 'a', {
+ href: mw.util.getUrl( title ),
+ title: mwTitle.getPrefixedText()
+ }, mwTitle.getPrefixedText()
+ );
+ msg = mw.message( 'watcherrortext', link );
+
+ // Report to user about the error
+ mw.notify( msg, {
+ tag: 'watch-self',
+ type: 'error'
+ } );
+ } );
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+/**
+ * @class mw.template
+ * @singleton
+ */
+( function ( mw, $ ) {
+ var compiledTemplates = {},
+ compilers = {};
+
+ mw.template = {
+ /**
+ * Register a new compiler.
+ *
+ * A compiler is any object that implements a compile() method. The compile() method must
+ * return a Template interface with a method render() that returns HTML.
+ *
+ * The compiler name must correspond with the name suffix of templates that use this compiler.
+ *
+ * @param {string} name Compiler name
+ * @param {Object} compiler
+ */
+ registerCompiler: function ( name, compiler ) {
+ if ( !compiler.compile ) {
+ throw new Error( 'Compiler must implement a compile method' );
+ }
+ compilers[ name ] = compiler;
+ },
+
+ /**
+ * Get the name of the associated compiler based on a template name.
+ *
+ * @param {string} templateName Name of a template (including suffix)
+ * @return {string} Name of a compiler
+ */
+ getCompilerName: function ( templateName ) {
+ var nameParts = templateName.split( '.' );
+ if ( nameParts.length < 2 ) {
+ throw new Error( 'Template name must have a suffix' );
+ }
+ return nameParts[ nameParts.length - 1 ];
+ },
+
+ /**
+ * Get a compiler via its name.
+ *
+ * @param {string} name Name of a compiler
+ * @return {Object} The compiler
+ */
+ getCompiler: function ( name ) {
+ var compiler = compilers[ name ];
+ if ( !compiler ) {
+ throw new Error( 'Unknown compiler ' + name );
+ }
+ return compiler;
+ },
+
+ /**
+ * Register a template associated with a module.
+ *
+ * Precompiles the newly added template based on the suffix in its name.
+ *
+ * @param {string} moduleName Name of the ResourceLoader module the template is associated with
+ * @param {string} templateName Name of the template (including suffix)
+ * @param {string} templateBody Contents of the template (e.g. html markup)
+ * @return {Object} Compiled template
+ */
+ add: function ( moduleName, templateName, templateBody ) {
+ // Precompile and add to cache
+ var compiled = this.compile( templateBody, this.getCompilerName( templateName ) );
+ if ( !compiledTemplates[ moduleName ] ) {
+ compiledTemplates[ moduleName ] = {};
+ }
+ compiledTemplates[ moduleName ][ templateName ] = compiled;
+
+ return compiled;
+ },
+
+ /**
+ * Get a compiled template by module and template name.
+ *
+ * @param {string} moduleName Name of the module to retrieve the template from
+ * @param {string} templateName Name of template to be retrieved
+ * @return {Object} Compiled template
+ */
+ get: function ( moduleName, templateName ) {
+ var moduleTemplates;
+
+ // Try cache first
+ if ( compiledTemplates[ moduleName ] && compiledTemplates[ moduleName ][ templateName ] ) {
+ return compiledTemplates[ moduleName ][ templateName ];
+ }
+
+ moduleTemplates = mw.templates.get( moduleName );
+ if ( !moduleTemplates || !moduleTemplates[ templateName ] ) {
+ throw new Error( 'Template ' + templateName + ' not found in module ' + moduleName );
+ }
+
+ // Compiled and add to cache
+ return this.add( moduleName, templateName, moduleTemplates[ templateName ] );
+ },
+
+ /**
+ * Compile a string of template markup with an engine of choice.
+ *
+ * @param {string} templateBody Template body
+ * @param {string} compilerName The name of a registered compiler
+ * @return {Object} Compiled template
+ */
+ compile: function ( templateBody, compilerName ) {
+ return this.getCompiler( compilerName ).compile( templateBody );
+ }
+ };
+
+ // Register basic html compiler
+ mw.template.registerCompiler( 'html', {
+ compile: function ( src ) {
+ return {
+ render: function () {
+ return $( $.parseHTML( src.trim() ) );
+ }
+ };
+ }
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+mediaWiki.template.registerCompiler( 'regexp', {
+ compile: function ( src ) {
+ return {
+ render: function () {
+ return new RegExp(
+ src
+ // Remove whitespace
+ .replace( /\s+/g, '' )
+ // Remove named capturing groups
+ .replace( /\?<\w+?>/g, '' )
+ );
+ }
+ };
+ }
+} );
+++ /dev/null
-( 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
- * <https://www.mediawiki.org/wiki/Manual:$wgCrossSiteAJAXdomains> 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 ) );
+++ /dev/null
-( 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 ) );
+++ /dev/null
-/**
- * @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 ) );
+++ /dev/null
-/**
- * @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 ) );
+++ /dev/null
-/**
- * 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 ) );
+++ /dev/null
-/**
- * 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 ) );
+++ /dev/null
-/**
- * @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 ) );
+++ /dev/null
-/**
- * @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 ) );
+++ /dev/null
-/**
- * @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 ) );
+++ /dev/null
-/**
- * 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 $( '<input>' ).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 = $( '<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 ) );
+++ /dev/null
-/**
- * @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 ) );
+++ /dev/null
-/**
- * @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 ) );
+++ /dev/null
-( 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 <https://commons.wikimedia.org/wiki/Commons:Structured_data> 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 ) );
+++ /dev/null
-( 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 ) );
+++ /dev/null
-( 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 ) );
+++ /dev/null
-( 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 ) );
+++ /dev/null
-.mw-special-ApiHelp h1.firstHeading {
- display: none;
-}
-
-.api-pretty-header {
- font-size: small;
-}
-
-.api-pretty-content {
- white-space: pre-wrap;
-}
+++ /dev/null
-.client-nojs .mw-checkbox-toggle-controls {
- display: none;
-}
+++ /dev/null
-/*!
- * Allows users to perform all / none / invert operations on a list of
- * checkboxes on the page.
- *
- * @licence GNU GPL v2+
- * @author Luke Faraone <luke at faraone dot cc>
- *
- * Based on ext.nuke.js from https://www.mediawiki.org/wiki/Extension:Nuke by
- * Jeroen De Dauw <jeroendedauw at gmail dot com>
- */
-
-( 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 ) );
+++ /dev/null
-/**
- * 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 ) );
+++ /dev/null
-.client-js {
- .mw-notify-success,
- .mw-notify-warning,
- .mw-notify-error {
- display: none;
- }
-}
+++ /dev/null
-/**
- * @class mw.template
- * @singleton
- */
-( function ( mw, $ ) {
- var compiledTemplates = {},
- compilers = {};
-
- mw.template = {
- /**
- * Register a new compiler.
- *
- * A compiler is any object that implements a compile() method. The compile() method must
- * return a Template interface with a method render() that returns HTML.
- *
- * The compiler name must correspond with the name suffix of templates that use this compiler.
- *
- * @param {string} name Compiler name
- * @param {Object} compiler
- */
- registerCompiler: function ( name, compiler ) {
- if ( !compiler.compile ) {
- throw new Error( 'Compiler must implement a compile method' );
- }
- compilers[ name ] = compiler;
- },
-
- /**
- * Get the name of the associated compiler based on a template name.
- *
- * @param {string} templateName Name of a template (including suffix)
- * @return {string} Name of a compiler
- */
- getCompilerName: function ( templateName ) {
- var nameParts = templateName.split( '.' );
- if ( nameParts.length < 2 ) {
- throw new Error( 'Template name must have a suffix' );
- }
- return nameParts[ nameParts.length - 1 ];
- },
-
- /**
- * Get a compiler via its name.
- *
- * @param {string} name Name of a compiler
- * @return {Object} The compiler
- */
- getCompiler: function ( name ) {
- var compiler = compilers[ name ];
- if ( !compiler ) {
- throw new Error( 'Unknown compiler ' + name );
- }
- return compiler;
- },
-
- /**
- * Register a template associated with a module.
- *
- * Precompiles the newly added template based on the suffix in its name.
- *
- * @param {string} moduleName Name of the ResourceLoader module the template is associated with
- * @param {string} templateName Name of the template (including suffix)
- * @param {string} templateBody Contents of the template (e.g. html markup)
- * @return {Object} Compiled template
- */
- add: function ( moduleName, templateName, templateBody ) {
- // Precompile and add to cache
- var compiled = this.compile( templateBody, this.getCompilerName( templateName ) );
- if ( !compiledTemplates[ moduleName ] ) {
- compiledTemplates[ moduleName ] = {};
- }
- compiledTemplates[ moduleName ][ templateName ] = compiled;
-
- return compiled;
- },
-
- /**
- * Get a compiled template by module and template name.
- *
- * @param {string} moduleName Name of the module to retrieve the template from
- * @param {string} templateName Name of template to be retrieved
- * @return {Object} Compiled template
- */
- get: function ( moduleName, templateName ) {
- var moduleTemplates;
-
- // Try cache first
- if ( compiledTemplates[ moduleName ] && compiledTemplates[ moduleName ][ templateName ] ) {
- return compiledTemplates[ moduleName ][ templateName ];
- }
-
- moduleTemplates = mw.templates.get( moduleName );
- if ( !moduleTemplates || !moduleTemplates[ templateName ] ) {
- throw new Error( 'Template ' + templateName + ' not found in module ' + moduleName );
- }
-
- // Compiled and add to cache
- return this.add( moduleName, templateName, moduleTemplates[ templateName ] );
- },
-
- /**
- * Compile a string of template markup with an engine of choice.
- *
- * @param {string} templateBody Template body
- * @param {string} compilerName The name of a registered compiler
- * @return {Object} Compiled template
- */
- compile: function ( templateBody, compilerName ) {
- return this.getCompiler( compilerName ).compile( templateBody );
- }
- };
-
- // Register basic html compiler
- mw.template.registerCompiler( 'html', {
- compile: function ( src ) {
- return {
- render: function () {
- return $( $.parseHTML( src.trim() ) );
- }
- };
- }
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-mediaWiki.template.registerCompiler( 'regexp', {
- compile: function ( src ) {
- return {
- render: function () {
- return new RegExp(
- src
- // Remove whitespace
- .replace( /\s+/g, '' )
- // Remove named capturing groups
- .replace( /\?<\w+?>/g, '' )
- );
- }
- };
- }
-} );
+++ /dev/null
-/*!
- * 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 `<ul>` element of the gallery.
- */
- mw.GallerySlideshow = function ( gallery ) {
- // Properties
- this.$gallery = $( gallery );
- this.$galleryCaption = this.$gallery.find( '.gallerycaption' );
- this.$galleryBox = this.$gallery.find( '.gallerybox' );
- this.$currentImage = null;
- this.imageInfoCache = {};
- if ( this.$gallery.parent().attr( 'id' ) !== 'mw-content-text' ) {
- this.$container = this.$gallery.parent();
- }
-
- // Initialize
- this.drawCarousel();
- this.setSizeRequirement();
- this.toggleThumbnails( !!this.$gallery.attr( 'data-showthumbnails' ) );
- this.showCurrentImage();
-
- // Events
- $( window ).on(
- 'resize',
- OO.ui.debounce(
- this.setSizeRequirement.bind( this ),
- 100
- )
- );
-
- // Disable thumbnails' link, instead show the image in the carousel
- this.$galleryBox.on( 'click', function ( e ) {
- this.$currentImage = $( e.currentTarget );
- this.showCurrentImage();
- return false;
- }.bind( this ) );
- };
-
- /* Properties */
- /**
- * @property {jQuery} $gallery The `<ul>` element of the gallery.
- */
-
- /**
- * @property {jQuery} $galleryCaption The `<li>` that has the gallery caption.
- */
-
- /**
- * @property {jQuery} $galleryBox Selection of `<li>` elements that have thumbnails.
- */
-
- /**
- * @property {jQuery} $carousel The `<li>` elements that contains the carousel.
- */
-
- /**
- * @property {jQuery} $interface The `<div>` elements that contains the interface buttons.
- */
-
- /**
- * @property {jQuery} $img The `<img>` element that'll display the current image.
- */
-
- /**
- * @property {jQuery} $imgLink The `<a>` element that links to the image's File page.
- */
-
- /**
- * @property {jQuery} $imgCaption The `<p>` element that holds the image caption.
- */
-
- /**
- * @property {jQuery} $imgContainer The `<div>` element that contains the image.
- */
-
- /**
- * @property {jQuery} $currentImage The `<li>` element of the current image.
- */
-
- /**
- * @property {jQuery} $container If the gallery contained in an element that is
- * not the main content element, then it stores that element.
- */
-
- /**
- * @property {Object} imageInfoCache A key value pair of thumbnail URLs and image info.
- */
-
- /**
- * @property {number} imageWidth Width of the image based on viewport size
- */
-
- /**
- * @property {number} imageHeight Height of the image based on viewport size
- * the URLs in the required size.
- */
-
- /* Setup */
- OO.initClass( mw.GallerySlideshow );
-
- /* Methods */
- /**
- * Draws the carousel and the interface around it.
- */
- mw.GallerySlideshow.prototype.drawCarousel = function () {
- var next, prev, toggle, interfaceElements, carouselStack;
-
- this.$carousel = $( '<li>' ).addClass( 'gallerycarousel' );
-
- // Buttons for the interface
- prev = new OO.ui.ButtonWidget( {
- framed: false,
- icon: 'previous'
- } ).on( 'click', this.prevImage.bind( this ) );
-
- next = new OO.ui.ButtonWidget( {
- framed: false,
- icon: 'next'
- } ).on( 'click', this.nextImage.bind( this ) );
-
- toggle = new OO.ui.ButtonWidget( {
- framed: false,
- icon: 'imageGallery',
- title: mw.msg( 'gallery-slideshow-toggle' )
- } ).on( 'click', this.toggleThumbnails.bind( this ) );
-
- interfaceElements = new OO.ui.PanelLayout( {
- expanded: false,
- classes: [ 'mw-gallery-slideshow-buttons' ],
- $content: $( '<div>' ).append(
- prev.$element,
- toggle.$element,
- next.$element
- )
- } );
- this.$interface = interfaceElements.$element;
-
- // Containers for the current image, caption etc.
- this.$img = $( '<img>' );
- this.$imgLink = $( '<a>' ).append( this.$img );
- this.$imgCaption = $( '<p>' ).attr( 'class', 'mw-gallery-slideshow-caption' );
- this.$imgContainer = $( '<div>' )
- .attr( 'class', 'mw-gallery-slideshow-img-container' )
- .append( this.$imgLink );
-
- carouselStack = new OO.ui.StackLayout( {
- continuous: true,
- expanded: false,
- items: [
- interfaceElements,
- new OO.ui.PanelLayout( {
- expanded: false,
- $content: this.$imgContainer
- } ),
- new OO.ui.PanelLayout( {
- expanded: false,
- $content: this.$imgCaption
- } )
- ]
- } );
- this.$carousel.append( carouselStack.$element );
-
- // Append below the caption or as the first element in the gallery
- if ( this.$galleryCaption.length !== 0 ) {
- this.$galleryCaption.after( this.$carousel );
- } else {
- this.$gallery.prepend( this.$carousel );
- }
- };
-
- /**
- * Sets the {@link #imageWidth} and {@link #imageHeight} properties
- * based on the size of the window. Also flushes the
- * {@link #imageInfoCache} as we'll now need URLs for a different
- * size.
- */
- mw.GallerySlideshow.prototype.setSizeRequirement = function () {
- var w, h;
-
- if ( this.$container !== undefined ) {
- w = this.$container.width() * 0.9;
- h = ( this.$container.height() - this.getChromeHeight() ) * 0.9;
- } else {
- w = this.$imgContainer.width();
- h = Math.min( $( window ).height() * ( 3 / 4 ), this.$imgContainer.width() ) - this.getChromeHeight();
- }
-
- // Only update and flush the cache if the size changed
- if ( w !== this.imageWidth || h !== this.imageHeight ) {
- this.imageWidth = w;
- this.imageHeight = h;
- this.imageInfoCache = {};
- this.setImageSize();
- }
- };
-
- /**
- * Gets the height of the interface elements and the
- * gallery's caption.
- *
- * @return {number} Height
- */
- mw.GallerySlideshow.prototype.getChromeHeight = function () {
- return this.$interface.outerHeight() + this.$galleryCaption.outerHeight();
- };
-
- /**
- * Sets the height and width of {@link #$img} based on the
- * proportion of the image and the values generated by
- * {@link #setSizeRequirement}.
- *
- * @return {boolean} Whether or not the image was sized.
- */
- mw.GallerySlideshow.prototype.setImageSize = function () {
- if ( this.$img === undefined || this.$thumbnail === undefined ) {
- return false;
- }
-
- // Reset height and width
- this.$img
- .removeAttr( 'width' )
- .removeAttr( 'height' );
-
- // Stretch image to take up the required size
- this.$img.attr( 'height', ( this.imageHeight - this.$imgCaption.outerHeight() ) + 'px' );
-
- // Make the image smaller in case the current image
- // size is larger than the original file size.
- this.getImageInfo( this.$thumbnail ).done( function ( info ) {
- // NOTE: There will be a jump when resizing the window
- // because the cache is cleared and this a new network request.
- if (
- info.thumbwidth < this.$img.width() ||
- info.thumbheight < this.$img.height()
- ) {
- this.$img.attr( 'width', info.thumbwidth + 'px' );
- this.$img.attr( 'height', info.thumbheight + 'px' );
- }
- }.bind( this ) );
-
- return true;
- };
-
- /**
- * Displays the image set as {@link #$currentImage} in the carousel.
- */
- mw.GallerySlideshow.prototype.showCurrentImage = function () {
- var imageLi = this.getCurrentImage(),
- caption = imageLi.find( '.gallerytext' );
-
- // The order of the following is important for size calculations
- // 1. Highlight current thumbnail
- this.$gallery
- .find( '.gallerybox.slideshow-current' )
- .removeClass( 'slideshow-current' );
- imageLi.addClass( 'slideshow-current' );
-
- // 2. Show thumbnail
- this.$thumbnail = imageLi.find( 'img' );
- this.$img.attr( 'src', this.$thumbnail.attr( 'src' ) );
- this.$img.attr( 'alt', this.$thumbnail.attr( 'alt' ) );
- this.$imgLink.attr( 'href', imageLi.find( 'a' ).eq( 0 ).attr( 'href' ) );
-
- // 3. Copy caption
- this.$imgCaption
- .empty()
- .append( caption.clone() );
-
- // 4. Stretch thumbnail to correct size
- this.setImageSize();
-
- // 5. Load image at the required size
- this.loadImage( this.$thumbnail ).done( function ( info, $img ) {
- // Show this image to the user only if its still the current one
- if ( this.$thumbnail.attr( 'src' ) === $img.attr( 'src' ) ) {
- this.$img.attr( 'src', info.thumburl );
- this.setImageSize();
-
- // Keep the next image ready
- this.loadImage( this.getNextImage().find( 'img' ) );
- }
- }.bind( this ) );
- };
-
- /**
- * Loads the full image given the `<img>` element of the thumbnail.
- *
- * @param {Object} $img
- * @return {jQuery.Promise} Resolves with the images URL and original
- * element once the image has loaded.
- */
- mw.GallerySlideshow.prototype.loadImage = function ( $img ) {
- var img, d = $.Deferred();
-
- this.getImageInfo( $img ).done( function ( info ) {
- img = new Image();
- img.src = info.thumburl;
- img.onload = function () {
- d.resolve( info, $img );
- };
- img.onerror = function () {
- d.reject();
- };
- } ).fail( function () {
- d.reject();
- } );
-
- return d.promise();
- };
-
- /**
- * Gets the image's info given an `<img>` element.
- *
- * @param {Object} $img
- * @return {jQuery.Promise} Resolves with the image's info.
- */
- mw.GallerySlideshow.prototype.getImageInfo = function ( $img ) {
- var api, title, params,
- imageSrc = $img.attr( 'src' );
-
- // Reject promise if there is no thumbnail image
- if ( $img[ 0 ] === undefined ) {
- return $.Deferred().reject();
- }
-
- if ( this.imageInfoCache[ imageSrc ] === undefined ) {
- api = new mw.Api();
- // TODO: This supports only gallery of images
- title = mw.Title.newFromImg( $img );
- params = {
- action: 'query',
- formatversion: 2,
- titles: title.toString(),
- prop: 'imageinfo',
- iiprop: 'url'
- };
-
- // Check which dimension we need to request, based on
- // image and container proportions.
- if ( this.getDimensionToRequest( $img ) === 'height' ) {
- params.iiurlheight = this.imageHeight;
- } else {
- params.iiurlwidth = this.imageWidth;
- }
-
- this.imageInfoCache[ imageSrc ] = api.get( params ).then( function ( data ) {
- if ( OO.getProp( data, 'query', 'pages', 0, 'imageinfo', 0, 'thumburl' ) !== undefined ) {
- return data.query.pages[ 0 ].imageinfo[ 0 ];
- } else {
- return $.Deferred().reject();
- }
- } );
- }
-
- return this.imageInfoCache[ imageSrc ];
- };
-
- /**
- * Given an image, the method checks whether to use the height
- * or the width to request the larger image.
- *
- * @param {jQuery} $img
- * @return {string}
- */
- mw.GallerySlideshow.prototype.getDimensionToRequest = function ( $img ) {
- var ratio = $img.width() / $img.height();
-
- if ( this.imageHeight * ratio <= this.imageWidth ) {
- return 'height';
- } else {
- return 'width';
- }
- };
-
- /**
- * Toggles visibility of the thumbnails.
- *
- * @param {boolean} show Optional argument to control the state
- */
- mw.GallerySlideshow.prototype.toggleThumbnails = function ( show ) {
- this.$galleryBox.toggle( show );
- this.$carousel.toggleClass( 'mw-gallery-slideshow-thumbnails-toggled', show );
- };
-
- /**
- * Getter method for {@link #$currentImage}
- *
- * @return {jQuery}
- */
- mw.GallerySlideshow.prototype.getCurrentImage = function () {
- this.$currentImage = this.$currentImage || this.$galleryBox.eq( 0 );
- return this.$currentImage;
- };
-
- /**
- * Gets the image after the current one. Returns the first image if
- * the current one is the last.
- *
- * @return {jQuery}
- */
- mw.GallerySlideshow.prototype.getNextImage = function () {
- // Not the last image in the gallery
- if ( this.$currentImage.next( '.gallerybox' )[ 0 ] !== undefined ) {
- return this.$currentImage.next( '.gallerybox' );
- } else {
- return this.$galleryBox.eq( 0 );
- }
- };
-
- /**
- * Gets the image before the current one. Returns the last image if
- * the current one is the first.
- *
- * @return {jQuery}
- */
- mw.GallerySlideshow.prototype.getPrevImage = function () {
- // Not the first image in the gallery
- if ( this.$currentImage.prev( '.gallerybox' )[ 0 ] !== undefined ) {
- return this.$currentImage.prev( '.gallerybox' );
- } else {
- return this.$galleryBox.last();
- }
- };
-
- /**
- * Sets the {@link #$currentImage} to the next one and shows
- * it in the carousel
- */
- mw.GallerySlideshow.prototype.nextImage = function () {
- this.$currentImage = this.getNextImage();
- this.showCurrentImage();
- };
-
- /**
- * Sets the {@link #$currentImage} to the previous one and shows
- * it in the carousel
- */
- mw.GallerySlideshow.prototype.prevImage = function () {
- this.$currentImage = this.getPrevImage();
- this.showCurrentImage();
- };
-
- // Bootstrap all slideshow galleries
- mw.hook( 'wikipage.content' ).add( function ( $content ) {
- $content.find( '.mw-gallery-slideshow' ).each( function () {
- // eslint-disable-next-line no-new
- new mw.GallerySlideshow( this );
- } );
- } );
-}( mediaWiki, jQuery, OO ) );
+++ /dev/null
-/*!
- * 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 ) );
+++ /dev/null
-/*!
- * Implement AJAX navigation for multi-page images so the user may browse without a full page reload.
- */
-
-/* eslint-disable no-use-before-define */
-
-( function ( mw, $ ) {
- var jqXhr, $multipageimage, $spinner,
- cache = {},
- cacheOrder = [];
-
- /* Fetch the next page, caching up to 10 last-loaded pages.
- * @param {string} url
- * @return {jQuery.Promise}
- */
- function fetchPageData( url ) {
- if ( jqXhr && jqXhr.abort ) {
- // Prevent race conditions and piling up pending requests
- jqXhr.abort();
- }
- jqXhr = undefined;
-
- // Try the cache
- if ( cache[ url ] ) {
- // Update access freshness
- cacheOrder.splice( cacheOrder.indexOf( url ), 1 );
- cacheOrder.push( url );
- return $.Deferred().resolve( cache[ url ] ).promise();
- }
-
- // TODO Don't fetch the entire page. Ideally we'd only fetch the content portion or the data
- // (thumbnail urls) and update the interface manually.
- jqXhr = $.ajax( url ).then( function ( data ) {
- return $( data ).find( 'table.multipageimage' ).contents();
- } );
-
- // Handle cache updates
- jqXhr.done( function ( $contents ) {
- jqXhr = undefined;
-
- // Cache the newly loaded page
- cache[ url ] = $contents;
- cacheOrder.push( url );
-
- // Remove the oldest entry if we're over the limit
- if ( cacheOrder.length > 10 ) {
- delete cache[ cacheOrder[ 0 ] ];
- cacheOrder = cacheOrder.slice( 1 );
- }
- } );
-
- return jqXhr.promise();
- }
-
- /* Fetch the next page and use jQuery to swap the table.multipageimage contents.
- * @param {string} url
- * @param {boolean} [hist=false] Whether this is a load triggered by history navigation (if
- * true, this function won't push a new history state, for the browser did so already).
- */
- function switchPage( url, hist ) {
- var $tr, promise;
-
- // Start fetching data (might be cached)
- promise = fetchPageData( url );
-
- // Add a new spinner if one doesn't already exist and the data is not already ready
- if ( !$spinner && promise.state() !== 'resolved' ) {
- $tr = $multipageimage.find( 'tr' );
- $spinner = $.createSpinner( {
- size: 'large',
- type: 'block'
- } )
- // Copy the old content dimensions equal so that the current scroll position is not
- // lost between emptying the table is and receiving the new contents.
- .css( {
- height: $tr.outerHeight(),
- width: $tr.outerWidth()
- } );
-
- $multipageimage.empty().append( $spinner );
- }
-
- promise.done( function ( $contents ) {
- $spinner = undefined;
-
- // Replace table contents
- $multipageimage.empty().append( $contents.clone() );
-
- bindPageNavigation( $multipageimage );
-
- // Fire hook because the page's content has changed
- mw.hook( 'wikipage.content' ).fire( $multipageimage );
-
- // Update browser history and address bar. But not if we came here from a history
- // event, in which case the url is already updated by the browser.
- if ( history.pushState && !hist ) {
- history.pushState( { tag: 'mw-pagination' }, document.title, url );
- }
- } );
- }
-
- function bindPageNavigation( $container ) {
- $container.find( '.multipageimagenavbox' ).one( 'click', 'a', function ( e ) {
- var page, url;
-
- // Generate the same URL on client side as the one generated in ImagePage::openShowImage.
- // We avoid using the URL in the link directly since it could have been manipulated (T68608)
- page = Number( mw.util.getParamValue( 'page', this.href ) );
- url = mw.util.getUrl( mw.config.get( 'wgPageName' ), { page: page } );
-
- switchPage( url );
- e.preventDefault();
- } );
-
- $container.find( 'form[name="pageselector"]' ).one( 'change submit', function ( e ) {
- switchPage( this.action + '?' + $( this ).serialize() );
- e.preventDefault();
- } );
- }
-
- $( function () {
- if ( mw.config.get( 'wgCanonicalNamespace' ) !== 'File' ) {
- return;
- }
- $multipageimage = $( 'table.multipageimage' );
- if ( !$multipageimage.length ) {
- return;
- }
-
- bindPageNavigation( $multipageimage );
-
- // Update the url using the History API (if available)
- if ( history.pushState && history.replaceState ) {
- history.replaceState( { tag: 'mw-pagination' }, '' );
- $( window ).on( 'popstate', function ( e ) {
- var state = e.originalEvent.state;
- if ( state && state.tag === 'mw-pagination' ) {
- switchPage( location.href, true );
- }
- } );
- }
- } );
-}( mediaWiki, jQuery ) );
+++ /dev/null
-/*!
- * Animate patrol links to use asynchronous API requests to
- * patrol pages, rather than navigating to a different URI.
- *
- * @since 1.21
- * @author Marius Hoch <hoo@online.de>
- */
-( function ( mw, $ ) {
- if ( !mw.user.tokens.exists( 'patrolToken' ) ) {
- // Current user has no patrol right, or an old cached version of user.tokens
- // that didn't have patrolToken yet.
- return;
- }
- $( function () {
- var $patrolLinks = $( '.patrollink[data-mw="interface"] a' );
- $patrolLinks.on( 'click', function ( e ) {
- var $spinner, rcid, apiRequest;
-
- // Preload the notification module for mw.notify
- mw.loader.load( 'mediawiki.notification' );
-
- // Hide the link and create a spinner to show it inside the brackets.
- $spinner = $.createSpinner( {
- size: 'small',
- type: 'inline'
- } );
- $( this ).hide().after( $spinner );
-
- rcid = mw.util.getParamValue( 'rcid', this.href );
- apiRequest = new mw.Api();
-
- apiRequest.postWithToken( 'patrol', {
- formatversion: 2,
- action: 'patrol',
- rcid: rcid
- } ).done( function ( data ) {
- var title;
- // Remove all patrollinks from the page (including any spinners inside).
- $patrolLinks.closest( '.patrollink' ).remove();
- if ( data.patrol !== undefined ) {
- // Success
- title = new mw.Title( data.patrol.title );
- mw.notify( mw.msg( 'markedaspatrollednotify', title.toText() ) );
- } else {
- // This should never happen as errors should trigger fail
- mw.notify( mw.msg( 'markedaspatrollederrornotify' ), { type: 'error' } );
- }
- } ).fail( function ( error ) {
- $spinner.remove();
- // Restore the patrol link. This allows the user to try again
- // (or open it in a new window, bypassing this ajax module).
- $patrolLinks.show();
- if ( error === 'noautopatrol' ) {
- // Can't patrol own
- mw.notify( mw.msg( 'markedaspatrollederror-noautopatrol' ), { type: 'warn' } );
- } else {
- mw.notify( mw.msg( 'markedaspatrollederrornotify' ), { type: 'error' } );
- }
- } );
-
- e.preventDefault();
- } );
- } );
-}( mediaWiki, jQuery ) );
+++ /dev/null
-( function ( mw, $ ) {
- mw.hook( 'wikipage.content' ).add( function ( $content ) {
- var $sortable, $collapsible;
-
- $collapsible = $content.find( '.mw-collapsible' );
- if ( $collapsible.length ) {
- // Preloaded by Skin::getDefaultModules()
- mw.loader.using( 'jquery.makeCollapsible', function () {
- $collapsible.makeCollapsible();
- } );
- }
-
- $sortable = $content.find( 'table.sortable' );
- if ( $sortable.length ) {
- // Preloaded by Skin::getDefaultModules()
- mw.loader.using( 'jquery.tablesorter', function () {
- $sortable.tablesorter();
- } );
- }
-
- // Run jquery.checkboxShiftClick
- $content.find( 'input[type="checkbox"]:not(.noshiftselect)' ).checkboxShiftClick();
- } );
-
- // Things outside the wikipage content
- $( function () {
- var $nodes;
-
- // Add accesskey hints to the tooltips
- $( '[accesskey]' ).updateTooltipAccessKeys();
-
- $nodes = $( '.catlinks[data-mw="interface"]' );
- if ( $nodes.length ) {
- /**
- * Fired when categories are being added to the DOM
- *
- * It is encouraged to fire it before the main DOM is changed (when $content
- * is still detached). However, this order is not defined either way, so you
- * should only rely on $content itself.
- *
- * This includes the ready event on a page load (including post-edit loads)
- * and when content has been previewed with LivePreview.
- *
- * @event wikipage_categories
- * @member mw.hook
- * @param {jQuery} $content The most appropriate element containing the content,
- * such as .catlinks
- */
- mw.hook( 'wikipage.categories' ).fire( $nodes );
- }
-
- $( '#t-print a' ).click( function ( e ) {
- window.print();
- e.preventDefault();
- } );
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-/*!
- * Enhance rollback links by using asynchronous API requests,
- * rather than navigating to an action page.
- *
- * @since 1.28
- * @author Timo Tijhof
- */
-( function ( mw, $ ) {
-
- $( function () {
- $( '.mw-rollback-link' ).on( 'click', 'a[data-mw="interface"]', function ( e ) {
- var api, $spinner,
- $link = $( this ),
- url = this.href,
- page = mw.util.getParamValue( 'title', url ),
- user = mw.util.getParamValue( 'from', url );
-
- if ( !page || user === null ) {
- // Let native browsing handle the link
- return true;
- }
-
- // Preload the notification module for mw.notify
- mw.loader.load( 'mediawiki.notification' );
-
- // Remove event handler so that next click (re-try) uses server action
- $( e.delegateTarget ).off( 'click' );
-
- // Hide the link and create a spinner to show it inside the brackets.
- $spinner = $.createSpinner( { size: 'small', type: 'inline' } );
- $link.hide().after( $spinner );
-
- // @todo: data.messageHtml is no more. Convert to using errorformat=html.
- api = new mw.Api();
- api.rollback( page, user )
- .then( function ( data ) {
- mw.notify( $.parseHTML( data.messageHtml ), {
- title: mw.msg( 'actioncomplete' )
- } );
-
- // Remove link container and the subsequent text node containing " | ".
- if ( e.delegateTarget.nextSibling && e.delegateTarget.nextSibling.nodeType === Node.TEXT_NODE ) {
- $( e.delegateTarget.nextSibling ).remove();
- }
- $( e.delegateTarget ).remove();
- }, function ( errorCode, data ) {
- var message = data && data.error && data.error.messageHtml ?
- $.parseHTML( data.error.messageHtml ) :
- mw.msg( 'rollbackfailed' ),
- type = errorCode === 'alreadyrolled' ? 'warn' : 'error';
-
- mw.notify( message, {
- type: type,
- title: mw.msg( 'rollbackfailed' ),
- autoHide: false
- } );
-
- // Restore the link (enables user to try again)
- $spinner.remove();
- $link.show();
- } );
-
- e.preventDefault();
- } );
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-( function ( mw, $ ) {
- // Break out of framesets
- if ( mw.config.get( 'wgBreakFrames' ) ) {
- // Note: In IE < 9 strict comparison to window is non-standard (the standard didn't exist yet)
- // it works only comparing to window.self or window.window (http://stackoverflow.com/q/4850978/319266)
- if ( window.top !== window.self ) {
- // Un-trap us from framesets
- window.top.location.href = location.href;
- }
- }
-
- $( function () {
- var $diff;
-
- /**
- * Fired when wiki content is being added to the DOM
- *
- * It is encouraged to fire it before the main DOM is changed (when $content
- * is still detached). However, this order is not defined either way, so you
- * should only rely on $content itself.
- *
- * This includes the ready event on a page load (including post-edit loads)
- * and when content has been previewed with LivePreview.
- *
- * @event wikipage_content
- * @member mw.hook
- * @param {jQuery} $content The most appropriate element containing the content,
- * such as #mw-content-text (regular content root) or #wikiPreview (live preview
- * root)
- */
- mw.hook( 'wikipage.content' ).fire( $( '#mw-content-text' ) );
-
- $diff = $( 'table.diff[data-mw="interface"]' );
- if ( $diff.length ) {
- /**
- * Fired when the diff is added to a page containing a diff
- *
- * Similar to the {@link mw.hook#event-wikipage_content wikipage.content hook}
- * $diff may still be detached when the hook is fired.
- *
- * @event wikipage_diff
- * @member mw.hook
- * @param {jQuery} $diff The root element of the MediaWiki diff (`table.diff`).
- */
- mw.hook( 'wikipage.diff' ).fire( $diff.eq( 0 ) );
- }
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-/**
- * Animate watch/unwatch links to use asynchronous API requests to
- * watch pages, rather than navigating to a different URI.
- *
- * Usage:
- *
- * var watch = require( 'mediawiki.page.watch.ajax' );
- * watch.updateWatchLink(
- * $node,
- * 'watch',
- * 'loading'
- * );
- *
- * @class mw.plugin.page.watch.ajax
- * @singleton
- */
-( function ( mw, $ ) {
- var watch,
- // The name of the page to watch or unwatch
- title = mw.config.get( 'wgRelevantPageName' );
-
- /**
- * Update the link text, link href attribute and (if applicable)
- * "loading" class.
- *
- * @param {jQuery} $link Anchor tag of (un)watch link
- * @param {string} action One of 'watch', 'unwatch'
- * @param {string} [state="idle"] 'idle' or 'loading'. Default is 'idle'
- */
- function updateWatchLink( $link, action, state ) {
- var msgKey, $li, otherAction;
-
- // A valid but empty jQuery object shouldn't throw a TypeError
- if ( !$link.length ) {
- return;
- }
-
- // Invalid actions shouldn't silently turn the page in an unrecoverable state
- if ( action !== 'watch' && action !== 'unwatch' ) {
- throw new Error( 'Invalid action' );
- }
-
- // message keys 'watch', 'watching', 'unwatch' or 'unwatching'.
- msgKey = state === 'loading' ? action + 'ing' : action;
- otherAction = action === 'watch' ? 'unwatch' : 'watch';
- $li = $link.closest( 'li' );
-
- // Trigger a 'watchpage' event for this List item.
- // Announce the otherAction value as the first param.
- // Used to monitor the state of watch link.
- // TODO: Revise when system wide hooks are implemented
- if ( state === undefined ) {
- $li.trigger( 'watchpage.mw', otherAction );
- }
-
- $link
- .text( mw.msg( msgKey ) )
- .attr( 'title', mw.msg( 'tooltip-ca-' + action ) )
- .updateTooltipAccessKeys()
- .attr( 'href', mw.util.getUrl( title, { action: action } ) );
-
- // Most common ID style
- if ( $li.prop( 'id' ) === 'ca-' + otherAction ) {
- $li.prop( 'id', 'ca-' + action );
- }
-
- if ( state === 'loading' ) {
- $link.addClass( 'loading' );
- } else {
- $link.removeClass( 'loading' );
- }
- }
-
- /**
- * TODO: This should be moved somewhere more accessible.
- *
- * @private
- * @param {string} url
- * @return {string} The extracted action, defaults to 'view'
- */
- function mwUriGetAction( url ) {
- var action, actionPaths, key, m, parts;
-
- // TODO: Does MediaWiki give action path or query param
- // precedence? If the former, move this to the bottom
- action = mw.util.getParamValue( 'action', url );
- if ( action !== null ) {
- return action;
- }
-
- actionPaths = mw.config.get( 'wgActionPaths' );
- for ( key in actionPaths ) {
- if ( actionPaths.hasOwnProperty( key ) ) {
- parts = actionPaths[ key ].split( '$1' );
- parts = parts.map( mw.RegExp.escape );
- m = new RegExp( parts.join( '(.+)' ) ).exec( url );
- if ( m && m[ 1 ] ) {
- return key;
- }
-
- }
- }
-
- return 'view';
- }
-
- // Expose public methods
- watch = {
- updateWatchLink: updateWatchLink
- };
- module.exports = watch;
-
- $( function () {
- var $links = $( '.mw-watchlink a[data-mw="interface"], a.mw-watchlink[data-mw="interface"]' );
- if ( !$links.length ) {
- // Fallback to the class-based exclusion method for backwards-compatibility
- $links = $( '.mw-watchlink a, a.mw-watchlink' );
- // Restrict to core interfaces, ignore user-generated content
- $links = $links.filter( ':not( #bodyContent *, #content * )' );
- }
-
- $links.click( function ( e ) {
- var mwTitle, action, api, $link;
-
- mwTitle = mw.Title.newFromText( title );
- action = mwUriGetAction( this.href );
-
- if ( !mwTitle || ( action !== 'watch' && action !== 'unwatch' ) ) {
- // Let native browsing handle the link
- return true;
- }
- e.preventDefault();
- e.stopPropagation();
-
- $link = $( this );
-
- if ( $link.hasClass( 'loading' ) ) {
- return;
- }
-
- updateWatchLink( $link, action, 'loading' );
-
- // Preload the notification module for mw.notify
- mw.loader.load( 'mediawiki.notification' );
-
- api = new mw.Api();
-
- api[ action ]( title )
- .done( function ( watchResponse ) {
- var message, otherAction = action === 'watch' ? 'unwatch' : 'watch';
-
- if ( mwTitle.getNamespaceId() > 0 && mwTitle.getNamespaceId() % 2 === 1 ) {
- message = action === 'watch' ? 'addedwatchtext-talk' : 'removedwatchtext-talk';
- } else {
- message = action === 'watch' ? 'addedwatchtext' : 'removedwatchtext';
- }
-
- mw.notify( mw.message( message, mwTitle.getPrefixedText() ).parseDom(), {
- tag: 'watch-self'
- } );
-
- // Set link to opposite
- updateWatchLink( $link, otherAction );
-
- // Update the "Watch this page" checkbox on action=edit when the
- // page is watched or unwatched via the tab (T14395).
- $( '#wpWatchthis' ).prop( 'checked', watchResponse.watched === true );
- } )
- .fail( function () {
- var msg, link;
-
- // Reset link to non-loading mode
- updateWatchLink( $link, action );
-
- // Format error message
- link = mw.html.element(
- 'a', {
- href: mw.util.getUrl( title ),
- title: mwTitle.getPrefixedText()
- }, mwTitle.getPrefixedText()
- );
- msg = mw.message( 'watcherrortext', link );
-
- // Report to user about the error
- mw.notify( msg, {
- tag: 'watch-self',
- type: 'error'
- } );
- } );
- } );
- } );
-
-}( mediaWiki, jQuery ) );