diff options
Diffstat (limited to 'resources/src/mediawiki')
46 files changed, 3104 insertions, 839 deletions
diff --git a/resources/src/mediawiki/images/feed-icon.png b/resources/src/mediawiki/images/feed-icon.png Binary files differnew file mode 100644 index 00000000..00f49f6c --- /dev/null +++ b/resources/src/mediawiki/images/feed-icon.png diff --git a/resources/src/mediawiki/images/feed-icon.svg b/resources/src/mediawiki/images/feed-icon.svg new file mode 100644 index 00000000..6e5f570a --- /dev/null +++ b/resources/src/mediawiki/images/feed-icon.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 256 256"><defs><linearGradient x1=".085" y1=".085" x2=".915" y2=".915" id="a"><stop offset="0" stop-color="#E3702D"/><stop offset=".107" stop-color="#EA7D31"/><stop offset=".35" stop-color="#F69537"/><stop offset=".5" stop-color="#FB9E3A"/><stop offset=".702" stop-color="#EA7C31"/><stop offset=".887" stop-color="#DE642B"/><stop offset="1" stop-color="#D95B29"/></linearGradient></defs><rect width="256" height="256" rx="55" ry="55" fill="#CC5D15"/><rect width="246" height="246" rx="50" ry="50" x="5" y="5" fill="#F49C52"/><rect width="236" height="236" rx="47" ry="47" x="10" y="10" fill="url(#a)"/><circle cx="68" cy="189" r="24" fill="#FFF"/><path d="M160 213h-34a82 82 0 0 0-82-82v-34a116 116 0 0 1 116 116zM184 213a140 140 0 0 0-140-140v-35a175 175 0 0 1 175 175z" fill="#FFF"/></svg>
\ No newline at end of file diff --git a/resources/src/mediawiki/images/question.png b/resources/src/mediawiki/images/question.png Binary files differnew file mode 100644 index 00000000..f7405d26 --- /dev/null +++ b/resources/src/mediawiki/images/question.png diff --git a/resources/src/mediawiki/images/question.svg b/resources/src/mediawiki/images/question.svg new file mode 100644 index 00000000..98fbe8dd --- /dev/null +++ b/resources/src/mediawiki/images/question.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="21.059" height="21.06"><path fill="#575757" d="M10.529 0c-5.814 0-10.529 4.714-10.529 10.529s4.715 10.53 10.529 10.53c5.816 0 10.529-4.715 10.529-10.53s-4.712-10.529-10.529-10.529zm-.002 16.767c-.861 0-1.498-.688-1.498-1.516 0-.862.637-1.534 1.498-1.534.828 0 1.5.672 1.5 1.534 0 .827-.672 1.516-1.5 1.516zm2.137-6.512c-.723.568-1 .931-1 1.739v.5h-2.205v-.603c0-1.517.449-2.136 1.154-2.688.707-.552 1.139-.845 1.139-1.637 0-.672-.414-1.051-1.24-1.051-.707 0-1.328.189-1.982.638l-1.051-1.807c.861-.604 1.93-1.034 3.342-1.034 1.912 0 3.516 1.051 3.516 3.066-.001 1.43-.794 2.188-1.673 2.877z"/></svg>
\ No newline at end of file diff --git a/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.css b/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.css new file mode 100644 index 00000000..41435208 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.css @@ -0,0 +1,5 @@ +.mw-foreignStructuredUpload-bookletLayout-license { + font-size: 90%; + line-height: 1.4em; + color: #555; +} diff --git a/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js b/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js new file mode 100644 index 00000000..86fb91bc --- /dev/null +++ b/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js @@ -0,0 +1,247 @@ +/*global moment */ +( function ( $, mw ) { + + /** + * mw.ForeignStructuredUpload.BookletLayout encapsulates the process + * of uploading a file to MediaWiki using the mw.ForeignStructuredUpload model. + * + * var uploadDialog = new mw.Upload.Dialog( { + * bookletClass: mw.ForeignStructuredUpload.BookletLayout, + * booklet: { + * target: 'local' + * } + * } ); + * var windowManager = new OO.ui.WindowManager(); + * $( 'body' ).append( windowManager.$element ); + * windowManager.addWindows( [ uploadDialog ] ); + * + * @class mw.ForeignStructuredUpload.BookletLayout + * @uses mw.ForeignStructuredUpload + * @extends mw.Upload.BookletLayout + * @cfg {string} [target] Used to choose the target repository. + * If nothing is passed, the {@link mw.ForeignUpload#property-target default} is used. + */ + mw.ForeignStructuredUpload.BookletLayout = function ( config ) { + config = config || {}; + // Parent constructor + mw.ForeignStructuredUpload.BookletLayout.parent.call( this, config ); + + this.target = config.target; + }; + + /* Setup */ + + OO.inheritClass( mw.ForeignStructuredUpload.BookletLayout, mw.Upload.BookletLayout ); + + /* Uploading */ + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.initialize = function () { + mw.ForeignStructuredUpload.BookletLayout.parent.prototype.initialize.call( this ); + // Point the CategorySelector to the right wiki as soon as we know what the right wiki is + this.upload.apiPromise.done( function ( api ) { + // If this is a ForeignApi, it will have a apiUrl, otherwise we don't need to do anything + if ( api.apiUrl ) { + // Can't reuse the same object, CategorySelector calls #abort on its mw.Api instance + this.categoriesWidget.api = new mw.ForeignApi( api.apiUrl ); + } + }.bind( this ) ); + }; + + /** + * Returns a {@link mw.ForeignStructuredUpload mw.ForeignStructuredUpload} + * with the {@link #cfg-target target} specified in config. + * + * @protected + * @return {mw.Upload} + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.createUpload = function () { + return new mw.ForeignStructuredUpload( this.target ); + }; + + /* Form renderers */ + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.renderUploadForm = function () { + var fieldset, $ownWorkMessage, $notOwnWorkMessage, + ownWorkMessage, notOwnWorkMessage, notOwnWorkLocal, + validTargets = mw.config.get( 'wgForeignUploadTargets' ), + target = this.target || validTargets[ 0 ] || 'local', + layout = this; + + // foreign-structured-upload-form-label-own-work-message-local + // foreign-structured-upload-form-label-own-work-message-shared + ownWorkMessage = mw.message( 'foreign-structured-upload-form-label-own-work-message-' + target ); + // foreign-structured-upload-form-label-not-own-work-message-local + // foreign-structured-upload-form-label-not-own-work-message-shared + notOwnWorkMessage = mw.message( 'foreign-structured-upload-form-label-not-own-work-message-' + target ); + // foreign-structured-upload-form-label-not-own-work-local-local + // foreign-structured-upload-form-label-not-own-work-local-shared + notOwnWorkLocal = mw.message( 'foreign-structured-upload-form-label-not-own-work-local-' + target ); + + if ( !ownWorkMessage.exists() ) { + ownWorkMessage = mw.message( 'foreign-structured-upload-form-label-own-work-message-default' ); + } + if ( !notOwnWorkMessage.exists() ) { + notOwnWorkMessage = mw.message( 'foreign-structured-upload-form-label-not-own-work-message-default' ); + } + if ( !notOwnWorkLocal.exists() ) { + notOwnWorkLocal = mw.message( 'foreign-structured-upload-form-label-not-own-work-local-default' ); + } + + $ownWorkMessage = $( '<p>' ).html( ownWorkMessage.parse() ) + .addClass( 'mw-foreignStructuredUpload-bookletLayout-license' ); + $notOwnWorkMessage = $( '<div>' ).append( + $( '<p>' ).html( notOwnWorkMessage.parse() ), + $( '<p>' ).html( notOwnWorkLocal.parse() ) + ); + $ownWorkMessage.add( $notOwnWorkMessage ).find( 'a' ).attr( 'target', '_blank' ); + + this.selectFileWidget = new OO.ui.SelectFileWidget(); + this.messageLabel = new OO.ui.LabelWidget( { + label: $notOwnWorkMessage + } ); + this.ownWorkCheckbox = new OO.ui.CheckboxInputWidget().on( 'change', function ( on ) { + layout.messageLabel.toggle( !on ); + } ); + + fieldset = new OO.ui.FieldsetLayout(); + fieldset.addItems( [ + new OO.ui.FieldLayout( this.selectFileWidget, { + align: 'top', + label: mw.msg( 'upload-form-label-select-file' ) + } ), + new OO.ui.FieldLayout( this.ownWorkCheckbox, { + align: 'inline', + label: $( '<div>' ).append( + $( '<p>' ).text( mw.msg( 'foreign-structured-upload-form-label-own-work' ) ), + $ownWorkMessage + ) + } ), + new OO.ui.FieldLayout( this.messageLabel, { + align: 'top' + } ) + ] ); + this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); + + // Validation + this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) ); + this.ownWorkCheckbox.on( 'change', this.onUploadFormChange.bind( this ) ); + + return this.uploadForm; + }; + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.onUploadFormChange = function () { + var file = this.selectFileWidget.getValue(), + ownWork = this.ownWorkCheckbox.isSelected(), + valid = !!file && ownWork; + this.emit( 'uploadValid', valid ); + }; + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.renderInfoForm = function () { + var fieldset; + + this.filenameWidget = new OO.ui.TextInputWidget( { + required: true, + validate: /.+/ + } ); + this.descriptionWidget = new OO.ui.TextInputWidget( { + required: true, + validate: /.+/, + multiline: true, + autosize: true + } ); + this.dateWidget = new mw.widgets.DateInputWidget( { + $overlay: this.$overlay, + required: true, + mustBeBefore: moment().add( 1, 'day' ).locale( 'en' ).format( 'YYYY-MM-DD' ) // Tomorrow + } ); + this.categoriesWidget = new mw.widgets.CategorySelector( { + // Can't be done here because we don't know the target wiki yet... done in #initialize. + // api: new mw.ForeignApi( ... ), + $overlay: this.$overlay + } ); + + fieldset = new OO.ui.FieldsetLayout( { + label: mw.msg( 'upload-form-label-infoform-title' ) + } ); + fieldset.addItems( [ + new OO.ui.FieldLayout( this.filenameWidget, { + label: mw.msg( 'upload-form-label-infoform-name' ), + align: 'top' + } ), + new OO.ui.FieldLayout( this.descriptionWidget, { + label: mw.msg( 'upload-form-label-infoform-description' ), + align: 'top' + } ), + new OO.ui.FieldLayout( this.categoriesWidget, { + label: mw.msg( 'foreign-structured-upload-form-label-infoform-categories' ), + align: 'top' + } ), + new OO.ui.FieldLayout( this.dateWidget, { + label: mw.msg( 'foreign-structured-upload-form-label-infoform-date' ), + align: 'top' + } ) + ] ); + this.infoForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); + + // Validation + this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + this.dateWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + + return this.infoForm; + }; + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.onInfoFormChange = function () { + var layout = this; + $.when( + this.filenameWidget.getValidity(), + this.descriptionWidget.getValidity(), + this.dateWidget.getValidity() + ).done( function () { + layout.emit( 'infoValid', true ); + } ).fail( function () { + layout.emit( 'infoValid', false ); + } ); + }; + + /* Getters */ + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.getText = function () { + this.upload.addDescription( 'en', this.descriptionWidget.getValue() ); + this.upload.setDate( this.dateWidget.getValue() ); + this.upload.addCategories( this.categoriesWidget.getItemsData() ); + return this.upload.getText(); + }; + + /* Setters */ + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.clear = function () { + mw.ForeignStructuredUpload.BookletLayout.parent.prototype.clear.call( this ); + + this.ownWorkCheckbox.setSelected( false ); + this.categoriesWidget.setItemsFromData( [] ); + this.dateWidget.setValue( '' ).setValidityFlag( true ); + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.js b/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.js new file mode 100644 index 00000000..dd28ddd4 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.js @@ -0,0 +1,184 @@ +( function ( mw, OO ) { + /** + * @class mw.ForeignStructuredUpload + * @extends mw.ForeignUpload + * + * 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.** + * + * @inheritdoc + */ + function ForeignStructuredUpload( target, apiconfig ) { + this.date = undefined; + this.descriptions = []; + this.categories = []; + + mw.ForeignUpload.call( this, target, apiconfig ); + } + + OO.inheritClass( ForeignStructuredUpload, mw.ForeignUpload ); + + /** + * Add categories to the upload. + * + * @param {string[]} categories Array of categories to which this upload will be added. + */ + ForeignStructuredUpload.prototype.addCategories = function ( categories ) { + var i, category; + + for ( i = 0; i < categories.length; i++ ) { + category = categories[ i ]; + this.categories.push( category ); + } + }; + + /** + * 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 + } ); + }; + + /** + * 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.getTemplateName() + + '\n|description=' + + this.getDescriptions() + + '\n|date=' + + this.getDate() + + '\n|source=' + + this.getSource() + + '\n|author=' + + this.getUser() + + '\n}}\n\n' + + this.getLicense() + + '\n\n' + + this.getCategories() + ); + }; + + /** + * 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(); + }; + + /** + * Gets the name of the template to use for creating the file metadata. + * Override in subclasses for other templates. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getTemplateName = function () { + return 'Information'; + }; + + /** + * Fetches the wikitext for any descriptions that have been added + * to the upload. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getDescriptions = function () { + var i, desc, templateCalls = []; + + for ( i = 0; i < this.descriptions.length; i++ ) { + desc = this.descriptions[ i ]; + templateCalls.push( '{{' + desc.language + '|' + desc.text + '}}' ); + } + + return templateCalls.join( '\n' ); + }; + + /** + * Fetches the wikitext for the categories to which the upload will + * be added. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getCategories = function () { + var i, cat, categoryLinks = []; + + for ( i = 0; i < this.categories.length; i++ ) { + cat = this.categories[ i ]; + categoryLinks.push( '[[Category:' + cat + ']]' ); + } + + return categoryLinks.join( '\n' ); + }; + + /** + * Gets the wikitext for the license of the upload. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getLicense = function () { + // Make sure this matches the messages for different targets in + // mw.ForeignStructuredUpload.BookletLayout.prototype.renderUploadForm + return this.target === 'shared' ? '{{self|cc-by-sa-4.0}}' : ''; + }; + + /** + * Get the source. This should be some sort of localised text for "Own work". + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getSource = function () { + return '{{own}}'; + }; + + /** + * Get the username. + * + * @private + * @return {string} + */ + ForeignStructuredUpload.prototype.getUser = function () { + return mw.config.get( 'wgUserName' ); + }; + + mw.ForeignStructuredUpload = ForeignStructuredUpload; +}( mediaWiki, OO ) ); diff --git a/resources/src/mediawiki/mediawiki.ForeignUpload.js b/resources/src/mediawiki/mediawiki.ForeignUpload.js new file mode 100644 index 00000000..61fb59f6 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.ForeignUpload.js @@ -0,0 +1,135 @@ +( function ( mw, OO, $ ) { + /** + * @class mw.ForeignUpload + * @extends mw.Upload + * + * 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. + * + * @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 ( this.target === 'local' ) { + // If local uploads were requested, but they are disabled, fail. + if ( !mw.config.get( 'wgEnableUploads' ) ) { + throw new Error( 'Local uploads are disabled' ); + } + // 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 + ); + } + } + + throw new Error( 'Can not upload to requested foreign repo' ); + } ); + } + + // 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 ); + + if ( this.target !== 'local' ) { + // Keep these untranslated. We don't know the content language of the foreign wiki, best to + // stick to English in the text. + this.setComment( 'Cross-wiki upload from ' + location.host ); + } + } + + 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. + */ + + /** + * Override from mw.Upload to make sure the API info is found and allowed + */ + 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 + */ + ForeignUpload.prototype.uploadToStash = function () { + var upload = this; + return this.apiPromise.then( function ( api ) { + upload.api = api; + return mw.Upload.prototype.uploadToStash.call( upload ); + } ); + }; + + mw.ForeignUpload = ForeignUpload; +}( mediaWiki, OO, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.RegExp.js b/resources/src/mediawiki/mediawiki.RegExp.js new file mode 100644 index 00000000..1da4ab4c --- /dev/null +++ b/resources/src/mediawiki/mediawiki.RegExp.js @@ -0,0 +1,22 @@ +( function ( mw ) { + /** + * @class mw.RegExp + */ + mw.RegExp = { + /** + * Escape string for safe inclusion in regular expression + * + * The following characters are escaped: + * + * \ { } ( ) | . ? * + - ^ $ [ ] + * + * @since 1.26 + * @static + * @param {string} str String to escape + * @return {string} Escaped string + */ + escape: function ( str ) { + return str.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ); + } + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki/mediawiki.Title.js b/resources/src/mediawiki/mediawiki.Title.js index 3efb7eca..910a78f8 100644 --- a/resources/src/mediawiki/mediawiki.Title.js +++ b/resources/src/mediawiki/mediawiki.Title.js @@ -4,6 +4,7 @@ * @since 1.18 */ ( function ( mw, $ ) { + /*jshint latedef:false */ /** * @class mw.Title @@ -108,7 +109,7 @@ return false; } ns = ns.toLowerCase(); - id = mw.config.get( 'wgNamespaceIds' )[ns]; + id = mw.config.get( 'wgNamespaceIds' )[ ns ]; if ( id === undefined ) { return false; } @@ -234,7 +235,7 @@ .replace( rUnderscoreTrim, '' ); // Process initial colon - if ( title !== '' && title.charAt( 0 ) === ':' ) { + if ( title !== '' && title[ 0 ] === ':' ) { // Initial colon means main namespace instead of specified default namespace = NS_MAIN; title = title @@ -251,16 +252,16 @@ // Process namespace prefix (if any) m = title.match( rSplit ); if ( m ) { - id = getNsIdByName( m[1] ); + id = getNsIdByName( m[ 1 ] ); if ( id !== false ) { // Ordinary namespace namespace = id; - title = m[2]; + title = m[ 2 ]; // For Talk:X pages, make sure X has no "namespace" prefix if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) { // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x) - if ( getNsIdByName( m[1] ) !== false ) { + if ( getNsIdByName( m[ 1 ] ) !== false ) { return false; } } @@ -325,7 +326,7 @@ } // Any remaining initial :s are illegal. - if ( title.charAt( 0 ) === ':' ) { + if ( title[ 0 ] === ':' ) { return false; } @@ -380,9 +381,9 @@ rules = sanitationRules; for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) { - rule = rules[i]; + rule = rules[ i ]; for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) { - if ( rule[filter[m]] ) { + if ( rule[ filter[ m ] ] ) { s = s.replace( rule.pattern, rule.replace ); } } @@ -480,11 +481,6 @@ * @param {number} [defaultNamespace=NS_MAIN] * If given, will used as default namespace for the given title. * @param {Object} [options] additional options - * @param {string} [options.fileExtension=''] - * If the title is about to be created for the Media or File namespace, - * ensures the resulting Title has the correct extension. Useful, for example - * on systems that predict the type by content-sniffing, not by file extension. - * If different from empty string, `forUploading` is assumed. * @param {boolean} [options.forUploading=true] * Makes sure that a file is uploadable under the title returned. * There are pages in the file namespace under which file upload is impossible. @@ -492,7 +488,7 @@ * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title */ Title.newFromUserInput = function ( title, defaultNamespace, options ) { - var namespace, m, id, ext, parts, normalizeExtension; + var namespace, m, id, ext, parts; // defaultNamespace is optional; check whether options moves up if ( arguments.length < 3 && $.type( defaultNamespace ) === 'object' ) { @@ -502,23 +498,16 @@ // merge options into defaults options = $.extend( { - fileExtension: '', forUploading: true }, options ); - normalizeExtension = function ( extension ) { - // Remove only trailing space (that is removed by MW anyway) - extension = extension.toLowerCase().replace( /\s*$/, '' ); - return extension; - }; - namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace; // Normalise whitespace and remove duplicates title = $.trim( title.replace( rWhitespace, ' ' ) ); // Process initial colon - if ( title !== '' && title.charAt( 0 ) === ':' ) { + if ( title !== '' && title[ 0 ] === ':' ) { // Initial colon means main namespace instead of specified default namespace = NS_MAIN; title = title @@ -531,16 +520,16 @@ // Process namespace prefix (if any) m = title.match( rSplit ); if ( m ) { - id = getNsIdByName( m[1] ); + id = getNsIdByName( m[ 1 ] ); if ( id !== false ) { // Ordinary namespace namespace = id; - title = m[2]; + title = m[ 2 ]; } } if ( namespace === NS_MEDIA - || ( ( options.forUploading || options.fileExtension ) && ( namespace === NS_FILE ) ) + || ( options.forUploading && ( namespace === NS_FILE ) ) ) { title = sanitize( title, [ 'generalRule', 'fileRule' ] ); @@ -555,18 +544,6 @@ // Get the last part, which is supposed to be the file extension ext = parts.pop(); - // Does the supplied file name carry the desired file extension? - if ( options.fileExtension - && normalizeExtension( ext ) !== normalizeExtension( options.fileExtension ) - ) { - - // No, push back, whatever there was after the dot - parts.push( ext ); - - // And add the desired file extension later - ext = options.fileExtension; - } - // Remove whitespace of the name part (that W/O extension) title = $.trim( parts.join( '.' ) ); @@ -578,16 +555,8 @@ // Missing file extension title = $.trim( parts.join( '.' ) ); - if ( options.fileExtension ) { - - // Cut, if too long and append the desired file extension - title = trimFileNameToByteLength( title, options.fileExtension ); - - } else { - - // Name has no file extension and a fallback wasn't provided either - return null; - } + // Name has no file extension and a fallback wasn't provided either + return null; } } else { @@ -614,13 +583,11 @@ * @static * @param {string} uncleanName The unclean file name including file extension but * without namespace - * @param {string} [fileExtension] the desired file extension * @return {mw.Title|null} A valid Title object or null if the title is invalid */ - Title.newFromFileName = function ( uncleanName, fileExtension ) { + Title.newFromFileName = function ( uncleanName ) { return Title.newFromUserInput( 'File:' + uncleanName, { - fileExtension: fileExtension, forUploading: true } ); }; @@ -655,7 +622,7 @@ recount = regexes.length; - src = img.jquery ? img[0].src : img.src; + src = img.jquery ? img[ 0 ].src : img.src; matches = src.match( thumbPhpRegex ); @@ -666,11 +633,11 @@ decodedSrc = decodeURIComponent( src ); for ( i = 0; i < recount; i++ ) { - regex = regexes[i]; + regex = regexes[ i ]; matches = decodedSrc.match( regex ); - if ( matches && matches[1] ) { - return mw.Title.newFromText( 'File:' + matches[1] ); + if ( matches && matches[ 1 ] ) { + return mw.Title.newFromText( 'File:' + matches[ 1 ] ); } } @@ -690,9 +657,9 @@ obj = Title.exist.pages; if ( type === 'string' ) { - match = obj[title]; + match = obj[ title ]; } else if ( type === 'object' && title instanceof Title ) { - match = obj[title.toString()]; + match = obj[ title.toString() ]; } else { throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' ); } @@ -729,19 +696,46 @@ pages: {}, set: function ( titles, state ) { - titles = $.isArray( titles ) ? titles : [titles]; + titles = $.isArray( titles ) ? titles : [ titles ]; state = state === undefined ? true : !!state; var i, pages = this.pages, len = titles.length; for ( i = 0; i < len; i++ ) { - pages[ titles[i] ] = state; + pages[ titles[ i ] ] = state; } return true; } }; + /** + * Normalize a file extension to the common form, making it lowercase and checking some synonyms, + * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded. + * Keep in sync with File::normalizeExtension() in PHP. + * + * @param {string} extension File extension (without the leading dot) + * @return {string} File extension in canonical form + */ + Title.normalizeExtension = function ( extension ) { + var + lower = extension.toLowerCase(), + squish = { + htm: 'html', + jpeg: 'jpg', + mpeg: 'mpg', + tiff: 'tif', + ogv: 'ogg' + }; + if ( squish.hasOwnProperty( lower ) ) { + return squish[ lower ]; + } else if ( /^[0-9a-z]+$/.test( lower ) ) { + return lower; + } else { + return ''; + } + }; + /* Public members */ Title.prototype = { @@ -782,11 +776,13 @@ * @return {string} */ getName: function () { - if ( $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ) { + if ( + $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 || + !this.title.length + ) { return this.title; - } else { - return $.ucFirst( this.title ); } + return this.title[ 0 ].toUpperCase() + this.title.slice( 1 ); }, /** diff --git a/resources/src/mediawiki/mediawiki.Upload.BookletLayout.js b/resources/src/mediawiki/mediawiki.Upload.BookletLayout.js new file mode 100644 index 00000000..dd199cef --- /dev/null +++ b/resources/src/mediawiki/mediawiki.Upload.BookletLayout.js @@ -0,0 +1,543 @@ +( function ( $, mw ) { + + /** + * mw.Upload.BookletLayout encapsulates the process of uploading a file + * to MediaWiki using the {@link mw.Upload upload model}. + * The booklet emits events that can be used to get the stashed + * upload and the final file. It can be extended to accept + * additional fields from the user for specific scenarios like + * for Commons, or campaigns. + * + * ## Structure + * + * The {@link OO.ui.BookletLayout booklet layout} has three steps: + * + * - **Upload**: Has a {@link OO.ui.SelectFileWidget field} to get the file object. + * + * - **Information**: Has a {@link OO.ui.FormLayout form} to collect metadata. This can be + * extended. + * + * - **Insert**: Has details on how to use the file that was uploaded. + * + * Each step has a form associated with it defined in + * {@link #renderUploadForm renderUploadForm}, + * {@link #renderInfoForm renderInfoForm}, and + * {@link #renderInsertForm renderInfoForm}. The + * {@link #getFile getFile}, + * {@link #getFilename getFilename}, and + * {@link #getText getText} methods are used to get + * the information filled in these forms, required to call + * {@link mw.Upload mw.Upload}. + * + * ## Usage + * + * See the {@link mw.Upload.Dialog upload dialog}. + * + * The {@link #event-fileUploaded fileUploaded}, + * and {@link #event-fileSaved fileSaved} events can + * be used to get details of the upload. + * + * ## Extending + * + * To extend using {@link mw.Upload mw.Upload}, override + * {@link #renderInfoForm renderInfoForm} to render + * the form required for the specific use-case. Update the + * {@link #getFilename getFilename}, and + * {@link #getText getText} methods to return data + * from your newly created form. If you added new fields you'll also have + * to update the {@link #clear} method. + * + * If you plan to use a different upload model, apart from what is mentioned + * above, you'll also have to override the + * {@link #createUpload createUpload} method to + * return the new model. The {@link #saveFile saveFile}, and + * the {@link #uploadFile uploadFile} methods need to be + * overriden to use the new model and data returned from the forms. + * + * @class + * @extends OO.ui.BookletLayout + * + * @constructor + * @param {Object} config Configuration options + * @cfg {jQuery} [$overlay] Overlay to use for widgets in the booklet + */ + mw.Upload.BookletLayout = function ( config ) { + // Parent constructor + mw.Upload.BookletLayout.parent.call( this, config ); + + this.$overlay = config.$overlay; + + this.renderUploadForm(); + this.renderInfoForm(); + this.renderInsertForm(); + + this.addPages( [ + new OO.ui.PageLayout( 'upload', { + scrollable: true, + padded: true, + content: [ this.uploadForm ] + } ), + new OO.ui.PageLayout( 'info', { + scrollable: true, + padded: true, + content: [ this.infoForm ] + } ), + new OO.ui.PageLayout( 'insert', { + scrollable: true, + padded: true, + content: [ this.insertForm ] + } ) + ] ); + }; + + /* Setup */ + + OO.inheritClass( mw.Upload.BookletLayout, OO.ui.BookletLayout ); + + /* Events */ + + /** + * The file has finished uploading + * + * @event fileUploaded + */ + + /** + * The file has been saved to the database + * + * @event fileSaved + * @param {Object} imageInfo See mw.Upload#getImageInfo + */ + + /** + * The upload form has changed + * + * @event uploadValid + * @param {boolean} isValid The form is valid + */ + + /** + * The info form has changed + * + * @event infoValid + * @param {boolean} isValid The form is valid + */ + + /* Properties */ + + /** + * @property {OO.ui.FormLayout} uploadForm + * The form rendered in the first step to get the file object. + * Rendered in {@link #renderUploadForm renderUploadForm}. + */ + + /** + * @property {OO.ui.FormLayout} infoForm + * The form rendered in the second step to get metadata. + * Rendered in {@link #renderInfoForm renderInfoForm} + */ + + /** + * @property {OO.ui.FormLayout} insertForm + * The form rendered in the third step to show usage + * Rendered in {@link #renderInsertForm renderInsertForm} + */ + + /* Methods */ + + /** + * Initialize for a new upload + */ + mw.Upload.BookletLayout.prototype.initialize = function () { + this.clear(); + this.upload = this.createUpload(); + this.setPage( 'upload' ); + }; + + /** + * Create a new upload model + * + * @protected + * @return {mw.Upload} Upload model + */ + mw.Upload.BookletLayout.prototype.createUpload = function () { + return new mw.Upload(); + }; + + /* Uploading */ + + /** + * Uploads the file that was added in the upload form. Uses + * {@link #getFile getFile} to get the HTML5 + * file object. + * + * @protected + * @fires fileUploaded + * @return {jQuery.Promise} + */ + mw.Upload.BookletLayout.prototype.uploadFile = function () { + var deferred = $.Deferred(), + layout = this, + file = this.getFile(); + + this.filenameWidget.setValue( file.name ); + this.setPage( 'info' ); + + this.upload.setFile( file ); + // Explicitly set the filename so that the old filename isn't used in case of retry + this.upload.setFilenameFromFile(); + + this.uploadPromise = this.upload.uploadToStash(); + this.uploadPromise.then( function () { + deferred.resolve(); + layout.emit( 'fileUploaded' ); + }, function () { + // These errors will be thrown while the user is on the info page. + // Pretty sure it's impossible to get a warning other than 'stashfailed' here, which should + // really be an error... + var errorMessage = layout.getErrorMessageForStateDetails(); + deferred.reject( errorMessage ); + } ); + + // If there is an error in uploading, come back to the upload page + deferred.fail( function () { + layout.setPage( 'upload' ); + } ); + + return deferred; + }; + + /** + * Saves the stash finalizes upload. Uses + * {@link #getFilename getFilename}, and + * {@link #getText getText} to get details from + * the form. + * + * @protected + * @fires fileSaved + * @returns {jQuery.Promise} Rejects the promise with an + * {@link OO.ui.Error error}, or resolves if the upload was successful. + */ + mw.Upload.BookletLayout.prototype.saveFile = function () { + var layout = this, + deferred = $.Deferred(); + + this.upload.setFilename( this.getFilename() ); + this.upload.setText( this.getText() ); + + this.uploadPromise.then( function () { + layout.upload.finishStashUpload().then( function () { + var name; + + // Normalize page name and localise the 'File:' prefix + name = new mw.Title( 'File:' + layout.upload.getFilename() ).toString(); + layout.filenameUsageWidget.setValue( '[[' + name + ']]' ); + layout.setPage( 'insert' ); + + deferred.resolve(); + layout.emit( 'fileSaved', layout.upload.getImageInfo() ); + }, function () { + var errorMessage = layout.getErrorMessageForStateDetails(); + deferred.reject( errorMessage ); + } ); + } ); + + return deferred.promise(); + }; + + /** + * Get an error message (as OO.ui.Error object) that should be displayed to the user for current + * state and state details. + * + * @protected + * @returns {OO.ui.Error} Error to display for given state and details. + */ + mw.Upload.BookletLayout.prototype.getErrorMessageForStateDetails = function () { + var message, + state = this.upload.getState(), + stateDetails = this.upload.getStateDetails(), + error = stateDetails.error, + warnings = stateDetails.upload && stateDetails.upload.warnings; + + if ( state === mw.Upload.State.ERROR ) { + // HACK We should either have a hook here to allow TitleBlacklist to handle this, or just have + // TitleBlacklist produce sane error messages that can be displayed without arcane knowledge + if ( error.info === 'TitleBlacklist prevents this title from being created' ) { + // HACK Apparently the only reliable way to determine whether TitleBlacklist was involved + return new OO.ui.Error( + $( '<p>' ).html( + // HACK TitleBlacklist doesn't have a sensible message, this one is from UploadWizard + mw.message( 'api-error-blacklisted' ).parse() + ), + { recoverable: false } + ); + } + + message = mw.message( 'api-error-' + error.code ); + if ( !message.exists() ) { + message = mw.message( 'api-error-unknownerror', JSON.stringify( stateDetails ) ); + } + return new OO.ui.Error( + $( '<p>' ).html( + message.parse() + ), + { recoverable: false } + ); + } + + if ( state === mw.Upload.State.WARNING ) { + // We could get more than one of these errors, these are in order + // of importance. For example fixing the thumbnail like file name + // won't help the fact that the file already exists. + if ( warnings.stashfailed !== undefined ) { + return new OO.ui.Error( + $( '<p>' ).html( + mw.message( 'api-error-stashfailed' ).parse() + ), + { recoverable: false } + ); + } else if ( warnings.exists !== undefined ) { + return new OO.ui.Error( + $( '<p>' ).html( + mw.message( 'fileexists', 'File:' + warnings.exists ).parse() + ), + { recoverable: false } + ); + } else if ( warnings[ 'page-exists' ] !== undefined ) { + return new OO.ui.Error( + $( '<p>' ).html( + mw.message( 'filepageexists', 'File:' + warnings[ 'page-exists' ] ).parse() + ), + { recoverable: false } + ); + } else if ( warnings.duplicate !== undefined ) { + return new OO.ui.Error( + $( '<p>' ).html( + mw.message( 'api-error-duplicate', warnings.duplicate.length ).parse() + ), + { recoverable: false } + ); + } else if ( warnings[ 'thumb-name' ] !== undefined ) { + return new OO.ui.Error( + $( '<p>' ).html( + mw.message( 'filename-thumb-name' ).parse() + ), + { recoverable: false } + ); + } else if ( warnings[ 'bad-prefix' ] !== undefined ) { + return new OO.ui.Error( + $( '<p>' ).html( + mw.message( 'filename-bad-prefix', warnings[ 'bad-prefix' ] ).parse() + ), + { recoverable: false } + ); + } else if ( warnings[ 'duplicate-archive' ] !== undefined ) { + return new OO.ui.Error( + $( '<p>' ).html( + mw.message( 'api-error-duplicate-archive', 1 ).parse() + ), + { recoverable: false } + ); + } else if ( warnings.badfilename !== undefined ) { + // Change the name if the current name isn't acceptable + // TODO This might not really be the best place to do this + this.filenameWidget.setValue( warnings.badfilename ); + return new OO.ui.Error( + $( '<p>' ).html( + mw.message( 'badfilename', warnings.badfilename ).parse() + ) + ); + } else { + return new OO.ui.Error( + $( '<p>' ).html( + // Let's get all the help we can if we can't pin point the error + mw.message( 'api-error-unknown-warning', JSON.stringify( stateDetails ) ).parse() + ), + { recoverable: false } + ); + } + } + }; + + /* Form renderers */ + + /** + * Renders and returns the upload form and sets the + * {@link #uploadForm uploadForm} property. + * + * @protected + * @fires selectFile + * @returns {OO.ui.FormLayout} + */ + mw.Upload.BookletLayout.prototype.renderUploadForm = function () { + var fieldset; + + this.selectFileWidget = new OO.ui.SelectFileWidget(); + fieldset = new OO.ui.FieldsetLayout( { label: mw.msg( 'upload-form-label-select-file' ) } ); + fieldset.addItems( [ this.selectFileWidget ] ); + this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); + + // Validation + this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) ); + + return this.uploadForm; + }; + + /** + * Handle change events to the upload form + * + * @protected + * @fires uploadValid + */ + mw.Upload.BookletLayout.prototype.onUploadFormChange = function () { + this.emit( 'uploadValid', !!this.selectFileWidget.getValue() ); + }; + + /** + * Renders and returns the information form for collecting + * metadata and sets the {@link #infoForm infoForm} + * property. + * + * @protected + * @returns {OO.ui.FormLayout} + */ + mw.Upload.BookletLayout.prototype.renderInfoForm = function () { + var fieldset; + + this.filenameWidget = new OO.ui.TextInputWidget( { + indicator: 'required', + required: true, + validate: /.+/ + } ); + this.descriptionWidget = new OO.ui.TextInputWidget( { + indicator: 'required', + required: true, + validate: /.+/, + multiline: true, + autosize: true + } ); + + fieldset = new OO.ui.FieldsetLayout( { + label: mw.msg( 'upload-form-label-infoform-title' ) + } ); + fieldset.addItems( [ + new OO.ui.FieldLayout( this.filenameWidget, { + label: mw.msg( 'upload-form-label-infoform-name' ), + align: 'top' + } ), + new OO.ui.FieldLayout( this.descriptionWidget, { + label: mw.msg( 'upload-form-label-infoform-description' ), + align: 'top' + } ) + ] ); + this.infoForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); + + this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + + return this.infoForm; + }; + + /** + * Handle change events to the info form + * + * @protected + * @fires infoValid + */ + mw.Upload.BookletLayout.prototype.onInfoFormChange = function () { + var layout = this; + $.when( + this.filenameWidget.getValidity(), + this.descriptionWidget.getValidity() + ).done( function () { + layout.emit( 'infoValid', true ); + } ).fail( function () { + layout.emit( 'infoValid', false ); + } ); + }; + + /** + * Renders and returns the insert form to show file usage and + * sets the {@link #insertForm insertForm} property. + * + * @protected + * @returns {OO.ui.FormLayout} + */ + mw.Upload.BookletLayout.prototype.renderInsertForm = function () { + var fieldset; + + this.filenameUsageWidget = new OO.ui.TextInputWidget(); + fieldset = new OO.ui.FieldsetLayout( { + label: mw.msg( 'upload-form-label-usage-title' ) + } ); + fieldset.addItems( [ + new OO.ui.FieldLayout( this.filenameUsageWidget, { + label: mw.msg( 'upload-form-label-usage-filename' ), + align: 'top' + } ) + ] ); + this.insertForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); + + return this.insertForm; + }; + + /* Getters */ + + /** + * Gets the file object from the + * {@link #uploadForm upload form}. + * + * @protected + * @returns {File|null} + */ + mw.Upload.BookletLayout.prototype.getFile = function () { + return this.selectFileWidget.getValue(); + }; + + /** + * Gets the file name from the + * {@link #infoForm information form}. + * + * @protected + * @returns {string} + */ + mw.Upload.BookletLayout.prototype.getFilename = function () { + return this.filenameWidget.getValue(); + }; + + /** + * Gets the page text from the + * {@link #infoForm information form}. + * + * @protected + * @returns {string} + */ + mw.Upload.BookletLayout.prototype.getText = function () { + return this.descriptionWidget.getValue(); + }; + + /* Setters */ + + /** + * Sets the file object + * + * @protected + * @param {File|null} file File to select + */ + mw.Upload.BookletLayout.prototype.setFile = function ( file ) { + this.selectFileWidget.setValue( file ); + }; + + /** + * Clear the values of all fields + * + * @protected + */ + mw.Upload.BookletLayout.prototype.clear = function () { + this.selectFileWidget.setValue( null ); + this.filenameWidget.setValue( null ).setValidityFlag( true ); + this.descriptionWidget.setValue( null ).setValidityFlag( true ); + this.filenameUsageWidget.setValue( null ); + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki/mediawiki.Upload.Dialog.js b/resources/src/mediawiki/mediawiki.Upload.Dialog.js new file mode 100644 index 00000000..03e39718 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.Upload.Dialog.js @@ -0,0 +1,205 @@ +( 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. + * + * @class mw.Upload.Dialog + * @uses mw.Upload + * @extends OO.ui.ProcessDialog + * @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 title + */ + /*jshint -W024*/ + 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', 'info' ] + }, + { + flags: [ 'primary', 'progressive' ], + label: mw.msg( 'upload-dialog-button-done' ), + action: 'insert', + modes: 'insert' + }, + { + flags: [ 'primary', 'constructive' ], + 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' + } + ]; + + /*jshint +W024*/ + + /* 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 () { + return new this.bookletClass( $.extend( { + $overlay: this.$overlay + }, this.bookletConfig ) ); + }; + + /** + * @inheritdoc + */ + mw.Upload.Dialog.prototype.getBodyHeight = function () { + return 300; + }; + + /** + * 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 () { + 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() ); + } + + return mw.Upload.Dialog.parent.prototype.getActionProcess.call( this, action ); + }; + + /** + * @inheritdoc + */ + mw.Upload.Dialog.prototype.getTeardownProcess = function ( data ) { + return mw.Upload.Dialog.parent.prototype.getTeardownProcess.call( this, data ) + .next( function () { + this.uploadBooklet.clear(); + }, this ); + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki/mediawiki.Upload.js b/resources/src/mediawiki/mediawiki.Upload.js new file mode 100644 index 00000000..4f8789de --- /dev/null +++ b/resources/src/mediawiki/mediawiki.Upload.js @@ -0,0 +1,368 @@ +( function ( mw, $ ) { + var UP; + + /** + * @class mw.Upload + * + * 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!' ); + * } ); + * } ); + * + * @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; + + /** + * 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; + }; + + /** + * 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} 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} + */ + 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.upload( 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.uploadToStash( this.getFile(), { + filename: this.getFilename() + } ).then( function ( finishStash ) { + upload.setState( Upload.State.STASHED ); + return finishStash; + }, function ( errorCode, result ) { + if ( result && result.upload && result.upload.warnings ) { + upload.setState( Upload.State.WARNING, result ); + } else { + upload.setState( Upload.State.ERROR, result ); + } + return $.Deferred().reject( errorCode, result ); + } ); + + return this.stashPromise; + }; + + /** + * Finish a stash upload. + * + * @return {jQuery.Promise} + */ + UP.finishStashUpload = function () { + var upload = this; + + if ( !this.stashPromise ) { + return $.Deferred().reject( 'This upload has not been stashed, please upload it to the stash first.' ); + } + + return this.stashPromise.then( function ( finishStash ) { + upload.setState( Upload.State.UPLOADING ); + + return finishStash( { + watchlist: ( upload.getWatchlist() ) ? 1 : undefined, + comment: upload.getComment(), + filename: upload.getFilename(), + text: upload.getText() + } ).then( function ( result ) { + upload.setState( Upload.State.UPLOADED ); + upload.imageinfo = result.upload.imageinfo; + return result; + }, function ( errorCode, result ) { + if ( result && result.upload && result.upload.warnings ) { + upload.setState( Upload.State.WARNING, result ); + } else { + upload.setState( Upload.State.ERROR, result ); + } + return $.Deferred().reject( errorCode, result ); + } ); + } ); + }; + + /** + * @enum mw.Upload.State + * State of uploads represented in simple terms. + */ + Upload.State = { + /** Upload not yet started */ + NEW: 0, + + /** Upload finished, but there was a warning */ + WARNING: 1, + + /** Upload finished, but there was an error */ + ERROR: 2, + + /** Upload in progress */ + UPLOADING: 3, + + /** Upload finished, but not published, call #finishStashUpload */ + STASHED: 4, + + /** Upload finished and published */ + UPLOADED: 5 + }; + + mw.Upload = Upload; +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.Uri.js b/resources/src/mediawiki/mediawiki.Uri.js index abfb2790..29b224ee 100644 --- a/resources/src/mediawiki/mediawiki.Uri.js +++ b/resources/src/mediawiki/mediawiki.Uri.js @@ -68,19 +68,25 @@ if ( val === undefined || val === null || val === '' ) { return ''; } + /* jshint latedef:false */ return pre + ( raw ? val : mw.Uri.encode( val ) ) + post; + /* jshint latedef:true */ } /** * Regular expressions to parse many common URIs. * + * As they are gnarly, they have been moved to separate files to allow us to format them in the + * 'extended' regular expression format (which JavaScript normally doesn't support). The subset of + * features handled is minimal, but just the free whitespace gives us a lot. + * * @private * @static * @property {Object} parser */ var parser = { - strict: /^(?:([^:\/?#]+):)?(?:\/\/(?:(?:([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?([^:\/?#]*)(?::(\d*))?)?((?:[^?#\/]*\/)*[^?#]*)(?:\?([^#]*))?(?:#(.*))?/, - loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?(?:(?:([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?([^:\/?#]*)(?::(\d*))?((?:\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?[^?#\/]*)(?:\?([^#]*))?(?:#(.*))?/ + strict: mw.template.get( 'mediawiki.Uri', 'strict.regexp' ).render(), + loose: mw.template.get( 'mediawiki.Uri', 'loose.regexp' ).render() }, /** @@ -169,6 +175,7 @@ * @param {boolean} [options.overrideKeys=false] Whether to let duplicate query parameters * override each other (`true`) or automagically convert them to an array (`false`). */ + /* jshint latedef:false */ function Uri( uri, options ) { var prop, defaultUri = getDefaultUri(); @@ -188,10 +195,10 @@ // Only copy direct properties, not inherited ones if ( uri.hasOwnProperty( prop ) ) { // Deep copy object properties - if ( $.isArray( uri[prop] ) || $.isPlainObject( uri[prop] ) ) { - this[prop] = $.extend( true, {}, uri[prop] ); + if ( $.isArray( uri[ prop ] ) || $.isPlainObject( uri[ prop ] ) ) { + this[ prop ] = $.extend( true, {}, uri[ prop ] ); } else { - this[prop] = uri[prop]; + this[ prop ] = uri[ prop ]; } } } @@ -216,7 +223,7 @@ this.port = defaultUri.port; } } - if ( this.path && this.path.charAt( 0 ) !== '/' ) { + if ( this.path && this.path[ 0 ] !== '/' ) { // A real relative URL, relative to defaultUri.path. We can't really handle that since we cannot // figure out whether the last path component of defaultUri.path is a directory or a file. throw new Error( 'Bad constructor arguments' ); diff --git a/resources/src/mediawiki/mediawiki.Uri.loose.regexp b/resources/src/mediawiki/mediawiki.Uri.loose.regexp new file mode 100644 index 00000000..300ab3ba --- /dev/null +++ b/resources/src/mediawiki/mediawiki.Uri.loose.regexp @@ -0,0 +1,22 @@ +^ +(?: + (?![^:@]+:[^:@/]*@) + (?<protocol>[^:/?#.]+): +)? +(?://)? +(?:(?: + (?<user>[^:@/?#]*) + (?::(?<password>[^:@/?#]*))? +)?@)? +(?<host>[^:/?#]*) +(?::(?<port>\d*))? +( + (?:/ + (?:[^?#] + (?![^?#/]*\.[^?#/.]+(?:[?#]|$)) + )*/? + )? + [^?#/]* +) +(?:\?(?<query>[^#]*))? +(?:\#(?<fragment>.*))? diff --git a/resources/src/mediawiki/mediawiki.Uri.strict.regexp b/resources/src/mediawiki/mediawiki.Uri.strict.regexp new file mode 100644 index 00000000..2ac7d2fc --- /dev/null +++ b/resources/src/mediawiki/mediawiki.Uri.strict.regexp @@ -0,0 +1,13 @@ +^ +(?:(?<protocol>[^:/?#]+):)? +(?://(?: + (?: + (?<user>[^:@/?#]*) + (?::(?<password>[^:@/?#]*))? + )?@)? + (?<host>[^:/?#]*) + (?::(?<port>\d*))? +)? +(?<path>(?:[^?#/]*/)*[^?#]*) +(?:\?(?<query>[^#]*))? +(?:\#(?<fragment>.*))? diff --git a/resources/src/mediawiki/mediawiki.apihelp.css b/resources/src/mediawiki/mediawiki.apihelp.css index d1272323..7d7b413a 100644 --- a/resources/src/mediawiki/mediawiki.apihelp.css +++ b/resources/src/mediawiki/mediawiki.apihelp.css @@ -29,6 +29,10 @@ div.apihelp-linktrail { color: red; } +.apihelp-unknown { + color: #888; +} + .apihelp-empty { color: #888; } diff --git a/resources/src/mediawiki/mediawiki.confirmCloseWindow.js b/resources/src/mediawiki/mediawiki.confirmCloseWindow.js index 7fc5c424..4d0c1352 100644 --- a/resources/src/mediawiki/mediawiki.confirmCloseWindow.js +++ b/resources/src/mediawiki/mediawiki.confirmCloseWindow.js @@ -1,3 +1,4 @@ +/* jshint devel: true */ ( function ( mw, $ ) { /** * @method confirmCloseWindow @@ -7,11 +8,22 @@ * work in most browsers.) * * This supersedes any previous onbeforeunload handler. If there was a handler before, it is - * restored when you execute the returned function. + * restored when you execute the returned release() function. * * var allowCloseWindow = mw.confirmCloseWindow(); * // ... do stuff that can't be interrupted ... - * allowCloseWindow(); + * allowCloseWindow.release(); + * + * The second function returned is a trigger function to trigger the check and an alert + * window manually, e.g.: + * + * var allowCloseWindow = mw.confirmCloseWindow(); + * // ... do stuff that can't be interrupted ... + * if ( allowCloseWindow.trigger() ) { + * // don't do anything (e.g. destroy the input field) + * } else { + * // do whatever you wanted to do + * } * * @param {Object} [options] * @param {string} [options.namespace] Namespace for the event registration @@ -19,12 +31,13 @@ * @param {string} options.message.return The string message to show in the confirm dialog. * @param {Function} [options.test] * @param {boolean} [options.test.return=true] Whether to show the dialog to the user. - * @return {Function} Execute this when you want to allow the user to close the window + * @return {Object} An object of functions to work with this module */ mw.confirmCloseWindow = function ( options ) { var savedUnloadHandler, mainEventName = 'beforeunload', - showEventName = 'pageshow'; + showEventName = 'pageshow', + message; options = $.extend( { message: mw.message( 'mwe-prevent-close' ).text(), @@ -36,6 +49,12 @@ showEventName += '.' + options.namespace; } + if ( $.isFunction( options.message ) ) { + message = options.message(); + } else { + message = options.message; + } + $( window ).on( mainEventName, function () { if ( options.test() ) { // remove the handler while the alert is showing - otherwise breaks caching in Firefox (3?). @@ -47,11 +66,7 @@ }, 1 ); // show an alert with this message - if ( $.isFunction( options.message ) ) { - return options.message(); - } else { - return options.message; - } + return message; } } ).on( showEventName, function () { // Re-add onbeforeunload handler @@ -60,9 +75,38 @@ } } ); - // return the function they can use to stop this - return function () { - $( window ).off( mainEventName + ' ' + showEventName ); + /** + * Return the object with functions to release and manually trigger the confirm alert + * + * @ignore + */ + return { + /** + * Remove all event listeners and don't show an alert anymore, if the user wants to leave + * the page. + * + * @ignore + */ + release: function () { + $( window ).off( mainEventName + ' ' + showEventName ); + }, + /** + * Trigger the module's function manually: Check, if options.test() returns true and show + * an alert to the user if he/she want to leave this page. Returns false, if options.test() returns + * false or the user cancelled the alert window (~don't leave the page), true otherwise. + * + * @ignore + * @return {boolean} + */ + trigger: function () { + // use confirm to show the message to the user (if options.text() is true) + if ( options.test() && !confirm( message ) ) { + // the user want to keep the actual page + return false; + } + // otherwise return true + return true; + } }; }; } )( mediaWiki, jQuery ); diff --git a/resources/src/mediawiki/mediawiki.cookie.js b/resources/src/mediawiki/mediawiki.cookie.js index 8f091e4d..d260fca6 100644 --- a/resources/src/mediawiki/mediawiki.cookie.js +++ b/resources/src/mediawiki/mediawiki.cookie.js @@ -16,7 +16,7 @@ mw.cookie = { /** - * Sets or deletes a cookie. + * Set or delete a cookie. * * While this is natural in JavaScript, contrary to `WebResponse#setcookie` in PHP, the * default values for the `options` properties only apply if that property isn't set @@ -101,13 +101,13 @@ }, /** - * Gets the value of a cookie. + * Get the value of a cookie. * * @param {string} key * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is * `undefined` or `null`, then `wgCookiePrefix` is used * @param {Mixed} [defaultValue=null] - * @return {string} If the cookie exists, then the value of the + * @return {string|null|Mixed} If the cookie exists, then the value of the * cookie, otherwise `defaultValue` */ get: function ( key, prefix, defaultValue ) { diff --git a/resources/src/mediawiki/mediawiki.debug.js b/resources/src/mediawiki/mediawiki.debug.js index bdff99f7..f7210095 100644 --- a/resources/src/mediawiki/mediawiki.debug.js +++ b/resources/src/mediawiki/mediawiki.debug.js @@ -222,7 +222,7 @@ className: 'mw-debug-pane', id: 'mw-debug-pane-' + id } ) - .append( panes[id] ) + .append( panes[ id ] ) .appendTo( $container ); } @@ -255,7 +255,7 @@ }; for ( i = 0, length = this.data.log.length; i < length; i += 1 ) { - entry = this.data.log[i]; + entry = this.data.log[ i ]; entry.typeText = entryTypeText( entry.type ); $( '<tr>' ) @@ -289,13 +289,13 @@ .appendTo( $table ); for ( i = 0, length = this.data.queries.length; i < length; i += 1 ) { - query = this.data.queries[i]; + query = this.data.queries[ i ]; $( '<tr>' ) .append( $( '<td>' ).text( i + 1 ) ) .append( $( '<td>' ).text( query.sql ) ) .append( $( '<td class="stats">' ).text( ( query.time * 1000 ).toFixed( 4 ) + 'ms' ) ) - .append( $( '<td>' ).text( query['function'] ) ) + .append( $( '<td>' ).text( query[ 'function' ] ) ) .appendTo( $table ); } @@ -312,7 +312,7 @@ $list = $( '<ul>' ); for ( i = 0, length = this.data.debugLog.length; i < length; i += 1 ) { - line = this.data.debugLog[i]; + line = this.data.debugLog[ i ]; $( '<li>' ) .html( mw.html.escape( line ).replace( /\n/g, '<br />\n' ) ) .appendTo( $list ); @@ -346,7 +346,7 @@ $( '<tr>' ) .append( $( '<th>' ).text( key ) ) - .append( $( '<td>' ).text( data[key] ) ) + .append( $( '<td>' ).text( data[ key ] ) ) .appendTo( $table ); } @@ -370,7 +370,7 @@ $table = $( '<table>' ); for ( i = 0, length = this.data.includes.length; i < length; i += 1 ) { - file = this.data.includes[i]; + file = this.data.includes[ i ]; $( '<tr>' ) .append( $( '<td>' ).text( file.name ) ) .append( $( '<td class="nr">' ).text( file.size ) ) diff --git a/resources/src/mediawiki/mediawiki.errorLogger.js b/resources/src/mediawiki/mediawiki.errorLogger.js index 9f4f19dd..46b84797 100644 --- a/resources/src/mediawiki/mediawiki.errorLogger.js +++ b/resources/src/mediawiki/mediawiki.errorLogger.js @@ -1,5 +1,6 @@ /** * Try to catch errors in modules which don't do their own error handling. + * * @class mw.errorLogger * @singleton */ @@ -24,6 +25,7 @@ /** * Install a window.onerror handler that will report via mw.track, while preserving * any previous handler. + * * @param {Object} window */ installGlobalHandler: function ( window ) { @@ -35,6 +37,7 @@ /** * Dumb window.onerror handler which forwards the errors via mw.track. + * * @fires global_error */ window.onerror = function ( errorMessage, url, lineNumber, columnNumber, errorObject ) { diff --git a/resources/src/mediawiki/mediawiki.experiments.js b/resources/src/mediawiki/mediawiki.experiments.js new file mode 100644 index 00000000..75b1f80d --- /dev/null +++ b/resources/src/mediawiki/mediawiki.experiments.js @@ -0,0 +1,110 @@ +/* jshint bitwise:false */ +( function ( mw, $ ) { + + var CONTROL_BUCKET = 'control', + MAX_INT32_UNSIGNED = 4294967295; + + /** + * An implementation of Jenkins' one-at-a-time hash. + * + * @see http://en.wikipedia.org/wiki/Jenkins_hash_function + * + * @param {String} string String to hash + * @return {Number} The hash as a 32-bit unsigned integer + * @ignore + * + * @author Ori Livneh <ori@wikimedia.org> + * @see http://jsbin.com/kejewi/4/watch?js,console + */ + function hashString( string ) { + var hash = 0, + i = string.length; + + while ( i-- ) { + hash += string.charCodeAt( i ); + hash += ( hash << 10 ); + hash ^= ( hash >> 6 ); + } + hash += ( hash << 3 ); + hash ^= ( hash >> 11 ); + hash += ( hash << 15 ); + + return hash >>> 0; + } + + /** + * Provides an API for bucketing users in experiments. + * + * @class mw.experiments + * @singleton + */ + mw.experiments = { + + /** + * Gets the bucket for the experiment given the token. + * + * The name of the experiment and the token are hashed. The hash is converted + * to a number which is then used to get a bucket. + * + * Consider the following experiment specification: + * + * ``` + * { + * name: 'My first experiment', + * enabled: true, + * buckets: { + * control: 0.5 + * A: 0.25, + * B: 0.25 + * } + * } + * ``` + * + * The experiment has three buckets: control, A, and B. The user has a 50% + * chance of being assigned to the control bucket, and a 25% chance of being + * assigned to either the A or B buckets. If the experiment were disabled, + * then the user would always be assigned to the control bucket. + * + * This function is based on the deprecated `mw.user.bucket` function. + * + * @param {Object} experiment + * @param {String} experiment.name The name of the experiment + * @param {Boolean} experiment.enabled Whether or not the experiment is + * enabled. If the experiment is disabled, then the user is always assigned + * to the control bucket + * @param {Object} experiment.buckets A map of bucket name to probability + * that the user will be assigned to that bucket + * @param {String} token A token that uniquely identifies the user for the + * duration of the experiment + * @returns {String} The bucket + */ + getBucket: function ( experiment, token ) { + var buckets = experiment.buckets, + key, + range = 0, + hash, + max, + acc = 0; + + if ( !experiment.enabled || $.isEmptyObject( experiment.buckets ) ) { + return CONTROL_BUCKET; + } + + for ( key in buckets ) { + range += buckets[ key ]; + } + + hash = hashString( experiment.name + ':' + token ); + max = ( hash / MAX_INT32_UNSIGNED ) * range; + + for ( key in buckets ) { + acc += buckets[ key ]; + + if ( max <= acc ) { + return key; + } + } + } + }; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.feedback.js b/resources/src/mediawiki/mediawiki.feedback.js index d9401001..d226ed9d 100644 --- a/resources/src/mediawiki/mediawiki.feedback.js +++ b/resources/src/mediawiki/mediawiki.feedback.js @@ -22,8 +22,8 @@ * dialog box. Submitting that dialog box appends its contents to a * wiki page that you specify, as a new section. * - * This feature works with classic MediaWiki pages - * and is not compatible with LiquidThreads or Flow. + * This feature works with any content model that defines a + * `mw.messagePoster.MessagePoster`. * * Minimal usage example: * @@ -86,6 +86,7 @@ * Respond to dialog submit event. If the information was * submitted, either successfully or with an error, open * a MessageDialog to thank the user. + * * @param {string} [status] A status of the end of operation * of the main feedback dialog. Empty if the dialog was * dismissed with no action or the user followed the button @@ -199,7 +200,7 @@ */ mw.Feedback.Dialog = function mwFeedbackDialog( config ) { // Parent constructor - mw.Feedback.Dialog.super.call( this, config ); + mw.Feedback.Dialog.parent.call( this, config ); this.status = ''; this.feedbackPageTitle = null; @@ -239,7 +240,7 @@ feedbackFieldsetLayout, termsOfUseLabel; // Parent method - mw.Feedback.Dialog.super.prototype.initialize.call( this ); + mw.Feedback.Dialog.parent.prototype.initialize.call( this ); this.feedbackPanel = new OO.ui.PanelLayout( { scrollable: false, @@ -329,7 +330,7 @@ * @inheritdoc */ mw.Feedback.Dialog.prototype.getSetupProcess = function ( data ) { - return mw.Feedback.Dialog.super.prototype.getSetupProcess.call( this, data ) + return mw.Feedback.Dialog.parent.prototype.getSetupProcess.call( this, data ) .next( function () { var plainMsg, parsedMsg, settings = data.settings; @@ -381,7 +382,7 @@ * @inheritdoc */ mw.Feedback.Dialog.prototype.getReadyProcess = function ( data ) { - return mw.Feedback.Dialog.super.prototype.getReadyProcess.call( this, data ) + return mw.Feedback.Dialog.parent.prototype.getReadyProcess.call( this, data ) .next( function () { this.feedbackSubjectInput.focus(); }, this ); @@ -431,7 +432,7 @@ }, this ); } // Fallback to parent handler - return mw.Feedback.Dialog.super.prototype.getActionProcess.call( this, action ); + return mw.Feedback.Dialog.parent.prototype.getActionProcess.call( this, action ); }; /** @@ -472,7 +473,7 @@ * @inheritdoc */ mw.Feedback.Dialog.prototype.getTeardownProcess = function ( data ) { - return mw.Feedback.Dialog.super.prototype.getTeardownProcess.call( this, data ) + return mw.Feedback.Dialog.parent.prototype.getTeardownProcess.call( this, data ) .first( function () { this.emit( 'submit', this.status, this.feedbackPageName, this.feedbackPageUrl ); // Cleanup @@ -486,6 +487,7 @@ /** * Set the bug report link + * * @param {string} link Link to the external bug report form */ mw.Feedback.Dialog.prototype.setBugReportLink = function ( link ) { @@ -494,6 +496,7 @@ /** * Get the bug report link + * * @returns {string} Link to the external bug report form */ mw.Feedback.Dialog.prototype.getBugReportLink = function () { diff --git a/resources/src/mediawiki/mediawiki.feedlink.css b/resources/src/mediawiki/mediawiki.feedlink.css new file mode 100644 index 00000000..a07a4031 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.feedlink.css @@ -0,0 +1,16 @@ +/* Styles for links to RSS/Atom feeds in sidebar */ + +a.feedlink { + /* SVG support using a transparent gradient to guarantee cross-browser + * compatibility (browsers able to understand gradient syntax support also SVG). + * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique */ + background-image: url(images/feed-icon.png); + /* @embed */ + background-image: -webkit-linear-gradient(transparent, transparent), url(images/feed-icon.svg); + /* @embed */ + background-image: linear-gradient(transparent, transparent), url(images/feed-icon.svg); + background-position: center left; + background-repeat: no-repeat; + background-size: 12px 12px; + padding-left: 16px; +} diff --git a/resources/src/mediawiki/mediawiki.filewarning.less b/resources/src/mediawiki/mediawiki.filewarning.less index 489ac428..f4af4bae 100644 --- a/resources/src/mediawiki/mediawiki.filewarning.less +++ b/resources/src/mediawiki/mediawiki.filewarning.less @@ -1,7 +1,7 @@ -@import "mediawiki.ui/variables" +@import "mediawiki.ui/variables"; .mediawiki-filewarning { - display: none; + visibility: hidden; .mediawiki-filewarning-header { padding: 0; @@ -17,7 +17,7 @@ } .mediawiki-filewarning-anchor:hover & { - display: block; + visibility: visible; } } diff --git a/resources/src/mediawiki/mediawiki.htmlform.css b/resources/src/mediawiki/mediawiki.htmlform.css new file mode 100644 index 00000000..e41248c1 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.htmlform.css @@ -0,0 +1,51 @@ +/* HTMLForm styles */ + +table.mw-htmlform-nolabel td.mw-label { + width: 1px; +} + +.mw-htmlform-invalid-input td.mw-input input { + border-color: red; +} + +.mw-htmlform-flatlist div.mw-htmlform-flatlist-item { + display: inline; + margin-right: 1em; + white-space: nowrap; +} + +/* HTMLCheckMatrix */ + +.mw-htmlform-matrix td { + padding-left: 0.5em; + padding-right: 0.5em; +} + +tr.mw-htmlform-vertical-label td.mw-label { + text-align: left !important; +} + +.mw-icon-question { + /* SVG support using a transparent gradient to guarantee cross-browser + * compatibility (browsers able to understand gradient syntax support also SVG). + * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique */ + background-image: url(images/question.png); + /* @embed */ + background-image: -webkit-linear-gradient(transparent, transparent), url(images/question.svg); + /* @embed */ + background-image: linear-gradient(transparent, transparent), url(images/question.svg); + background-repeat: no-repeat; + background-size: 13px 13px; + display: inline-block; + height: 13px; + width: 13px; + margin-left: 4px; +} + +.mw-icon-question:lang(ar), +.mw-icon-question:lang(fa), +.mw-icon-question:lang(ur) { + -webkit-transform: scaleX(-1); + -ms-transform: scaleX(-1); + transform: scaleX(-1); +} diff --git a/resources/src/mediawiki/mediawiki.htmlform.js b/resources/src/mediawiki/mediawiki.htmlform.js index 4a4a97e9..8c6f3ab9 100644 --- a/resources/src/mediawiki/mediawiki.htmlform.js +++ b/resources/src/mediawiki/mediawiki.htmlform.js @@ -53,7 +53,7 @@ function hideIfParse( $el, spec ) { var op, i, l, v, $field, $fields, fields, func, funcs, getVal; - op = spec[0]; + op = spec[ 0 ]; l = spec.length; switch ( op ) { case 'AND': @@ -63,12 +63,12 @@ funcs = []; fields = []; for ( i = 1; i < l; i++ ) { - if ( !$.isArray( spec[i] ) ) { + if ( !$.isArray( spec[ i ] ) ) { throw new Error( op + ' parameters must be arrays' ); } - v = hideIfParse( $el, spec[i] ); - fields = fields.concat( v[0].toArray() ); - funcs.push( v[1] ); + v = hideIfParse( $el, spec[ i ] ); + fields = fields.concat( v[ 0 ].toArray() ); + funcs.push( v[ 1 ] ); } $fields = $( fields ); @@ -78,7 +78,7 @@ func = function () { var i; for ( i = 0; i < l; i++ ) { - if ( !funcs[i]() ) { + if ( !funcs[ i ]() ) { return false; } } @@ -90,7 +90,7 @@ func = function () { var i; for ( i = 0; i < l; i++ ) { - if ( funcs[i]() ) { + if ( funcs[ i ]() ) { return true; } } @@ -102,7 +102,7 @@ func = function () { var i; for ( i = 0; i < l; i++ ) { - if ( !funcs[i]() ) { + if ( !funcs[ i ]() ) { return true; } } @@ -114,7 +114,7 @@ func = function () { var i; for ( i = 0; i < l; i++ ) { - if ( funcs[i]() ) { + if ( funcs[ i ]() ) { return false; } } @@ -129,12 +129,12 @@ if ( l !== 2 ) { throw new Error( 'NOT takes exactly one parameter' ); } - if ( !$.isArray( spec[1] ) ) { + if ( !$.isArray( spec[ 1 ] ) ) { throw new Error( 'NOT parameters must be arrays' ); } - v = hideIfParse( $el, spec[1] ); - $fields = v[0]; - func = v[1]; + v = hideIfParse( $el, spec[ 1 ] ); + $fields = v[ 0 ]; + func = v[ 1 ]; return [ $fields, function () { return !func(); } ]; @@ -144,13 +144,13 @@ if ( l !== 3 ) { throw new Error( op + ' takes exactly two parameters' ); } - $field = hideIfGetField( $el, spec[1] ); + $field = hideIfGetField( $el, spec[ 1 ] ); if ( !$field ) { return [ $(), function () { return false; } ]; } - v = spec[2]; + v = spec[ 2 ]; if ( $field.first().prop( 'type' ) === 'radio' || $field.first().prop( 'type' ) === 'checkbox' @@ -203,7 +203,7 @@ * jQuery plugin to fade or snap to hiding state. * * @param {boolean} [instantToggle=false] - * @return jQuery + * @return {jQuery} * @chainable */ $.fn.goOut = function ( instantToggle ) { @@ -222,7 +222,7 @@ * @param {Function} callback * @param {boolean|jQuery.Event} callback.immediate True when the event is called immediately, * an event object when triggered from an event. - * @return jQuery + * @return {jQuery} * @chainable */ mw.log.deprecate( $.fn, 'liveAndTestAtStart', function ( callback ) { @@ -304,8 +304,8 @@ } v = hideIfParse( $el, spec ); - $fields = v[0]; - test = v[1]; + $fields = v[ 0 ]; + test = v[ 1 ]; func = function () { if ( test() ) { $el.hide(); @@ -413,7 +413,7 @@ $ul = $( this ).prev( 'ul.mw-htmlform-cloner-ul' ); html = $ul.data( 'template' ).replace( - new RegExp( $.escapeRE( $ul.data( 'uniqueId' ) ), 'g' ), + new RegExp( mw.RegExp.escape( $ul.data( 'uniqueId' ) ), 'g' ), 'clone' + ( ++cloneCounter ) ); diff --git a/resources/src/mediawiki/mediawiki.htmlform.ooui.css b/resources/src/mediawiki/mediawiki.htmlform.ooui.css new file mode 100644 index 00000000..309eb349 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.htmlform.ooui.css @@ -0,0 +1,31 @@ +/* OOUIHTMLForm styles */ + +.mw-htmlform-ooui-wrapper { + width: 50em; + margin: 1em 0; +} + +.oo-ui-fieldLayout.mw-htmlform-ooui-header-empty, +.oo-ui-fieldLayout.mw-htmlform-ooui-header-empty .oo-ui-fieldLayout-body { + display: none; +} + +.oo-ui-fieldLayout.mw-htmlform-ooui-header-errors { + /* Override 'display: none' from above */ + display: block; +} + +.mw-htmlform-ooui .mw-htmlform-submit-buttons { + margin-top: 1em; +} + +.mw-htmlform-ooui .mw-htmlform-field-HTMLCheckMatrix, +.mw-htmlform-ooui .mw-htmlform-matrix, +.mw-htmlform-ooui .mw-htmlform-matrix tr { + width: 100%; +} + +.mw-htmlform-ooui .mw-htmlform-matrix tr td.first { + margin-right: 5%; + width: 39%; +} diff --git a/resources/src/mediawiki/mediawiki.inspect.js b/resources/src/mediawiki/mediawiki.inspect.js index 22d3cbb3..4859953d 100644 --- a/resources/src/mediawiki/mediawiki.inspect.js +++ b/resources/src/mediawiki/mediawiki.inspect.js @@ -13,7 +13,7 @@ function sortByProperty( array, prop, descending ) { var order = descending ? -1 : 1; return array.sort( function ( a, b ) { - return a[prop] > b[prop] ? order : a[prop] < b[prop] ? -order : 0; + return a[ prop ] > b[ prop ] ? order : a[ prop ] < b[ prop ] ? -order : 0; } ); } @@ -25,7 +25,7 @@ for ( ; bytes >= 1024; bytes /= 1024 ) { i++; } // Maintain one decimal for kB and above, but don't // add ".0" for bytes. - return bytes.toFixed( i > 0 ? 1 : 0 ) + units[i]; + return bytes.toFixed( i > 0 ? 1 : 0 ) + units[ i ]; } /** @@ -45,18 +45,18 @@ graph = {}; $.each( modules, function ( moduleIndex, moduleName ) { - var dependencies = mw.loader.moduleRegistry[moduleName].dependencies || []; + var dependencies = mw.loader.moduleRegistry[ moduleName ].dependencies || []; if ( !hasOwn.call( graph, moduleName ) ) { - graph[moduleName] = { requiredBy: [] }; + graph[ moduleName ] = { requiredBy: [] }; } - graph[moduleName].requires = dependencies; + graph[ moduleName ].requires = dependencies; $.each( dependencies, function ( depIndex, depName ) { if ( !hasOwn.call( graph, depName ) ) { - graph[depName] = { requiredBy: [] }; + graph[ depName ] = { requiredBy: [] }; } - graph[depName].requiredBy.push( moduleName ); + graph[ depName ].requiredBy.push( moduleName ); } ); } ); return graph; @@ -101,7 +101,7 @@ * document. * * @param {string} css CSS source - * @return Selector counts + * @return {Object} Selector counts * @return {number} return.selectors Total number of selectors * @return {number} return.matched Number of matched selectors */ @@ -117,9 +117,15 @@ rules = sheet.cssRules || sheet.rules; $.each( rules, function ( index, rule ) { selectors.total++; - if ( document.querySelector( rule.selectorText ) !== null ) { - selectors.matched++; - } + // document.querySelector() on prefixed pseudo-elements can throw exceptions + // in Firefox and Safari. Ignore these exceptions. + // https://bugs.webkit.org/show_bug.cgi?id=149160 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1204880 + try { + if ( document.querySelector( rule.selectorText ) !== null ) { + selectors.matched++; + } + } catch ( e ) {} } ); document.body.removeChild( style ); return selectors; @@ -173,7 +179,7 @@ $.map( inspect.reports, function ( v, k ) { return k; } ); $.each( reports, function ( index, name ) { - inspect.dumpTable( inspect.reports[name]() ); + inspect.dumpTable( inspect.reports[ name ]() ); } ); }, @@ -214,7 +220,7 @@ var modules = []; $.each( inspect.getLoadedModules(), function ( index, name ) { - var css, stats, module = mw.loader.moduleRegistry[name]; + var css, stats, module = mw.loader.moduleRegistry[ name ]; try { css = module.style.css.join(); @@ -247,7 +253,7 @@ stats.totalSize = humanSize( $.byteLength( raw ) ); } catch ( e ) {} } - return [stats]; + return [ stats ]; } }, @@ -261,12 +267,11 @@ */ grep: function ( pattern ) { if ( typeof pattern.test !== 'function' ) { - // Based on Y.Escape.regex from YUI v3.15.0 - pattern = new RegExp( pattern.replace( /[\-$\^*()+\[\]{}|\\,.?\s]/g, '\\$&' ), 'g' ); + pattern = new RegExp( mw.RegExp.escape( pattern ), 'g' ); } return $.grep( inspect.getLoadedModules(), function ( moduleName ) { - var module = mw.loader.moduleRegistry[moduleName]; + var module = mw.loader.moduleRegistry[ moduleName ]; // Grep module's JavaScript if ( $.isFunction( module.script ) && pattern.test( module.script.toString() ) ) { diff --git a/resources/src/mediawiki/mediawiki.jqueryMsg.js b/resources/src/mediawiki/mediawiki.jqueryMsg.js index 79939f64..d179c825 100644 --- a/resources/src/mediawiki/mediawiki.jqueryMsg.js +++ b/resources/src/mediawiki/mediawiki.jqueryMsg.js @@ -15,14 +15,12 @@ slice = Array.prototype.slice, parserDefaults = { magic: { - 'SITENAME': mw.config.get( 'wgSiteName' ) + SITENAME: mw.config.get( 'wgSiteName' ) }, - // This is a whitelist based on, but simpler than, Sanitizer.php. + // Whitelist for allowed HTML elements in wikitext. // Self-closing tags are not currently supported. - allowedHtmlElements: [ - 'b', - 'i' - ], + // Can be populated via setPrivateData(). + allowedHtmlElements: [], // Key tag name, value allowed attributes for that tag. // See Sanitizer::setupAttributeWhitelist allowedHtmlCommonAttributes: [ @@ -62,6 +60,9 @@ * Wrapper around jQuery append that converts all non-objects to TextNode so append will not * convert what it detects as an htmlString to an element. * + * If our own htmlEmitter jQuery object is given, its children will be unwrapped and appended to + * new parent. + * * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is. * * @private @@ -73,12 +74,15 @@ var i, len; if ( !$.isArray( children ) ) { - children = [children]; + children = [ children ]; } for ( i = 0, len = children.length; i < len; i++ ) { - if ( typeof children[i] !== 'object' ) { - children[i] = document.createTextNode( children[i] ); + if ( typeof children[ i ] !== 'object' ) { + children[ i ] = document.createTextNode( children[ i ] ); + } + if ( children[ i ] instanceof jQuery && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) { + children[ i ] = children[ i ].contents(); } } @@ -102,11 +106,26 @@ } /** + * Turn input into a string. + * + * @private + * @param {string|jQuery} input + * @return {string} Textual value of input + */ + function textify( input ) { + if ( input instanceof jQuery ) { + input = input.text(); + } + return String( input ); + } + + /** * Given parser options, return a function that parses a key and replacements, returning jQuery object * * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes. * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it. + * * @private * @param {Object} options Parser options * @return {Function} @@ -118,8 +137,8 @@ return function ( args ) { var fallback, - key = args[0], - argsArray = $.isArray( args[1] ) ? args[1] : slice.call( args, 1 ); + key = args[ 0 ], + argsArray = $.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 ); try { return parser.parse( key, argsArray ); } catch ( e ) { @@ -133,6 +152,22 @@ mw.jqueryMsg = {}; /** + * Initialize parser defaults. + * + * ResourceLoaderJqueryMsgModule calls this to provide default values from + * Sanitizer.php for allowed HTML elements. To override this data for individual + * parsers, pass the relevant options to mw.jqueryMsg.parser. + * + * @private + * @param {Object} data + */ + mw.jqueryMsg.setParserDefaults = function ( data ) { + if ( data.allowedHtmlElements ) { + parserDefaults.allowedHtmlElements = data.allowedHtmlElements; + } + }; + + /** * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements). * e.g. * @@ -154,8 +189,7 @@ * @return {string} return.return Rendered HTML. */ mw.jqueryMsg.getMessageFunction = function ( options ) { - var failableParserFn = getFailableParserFn( options ), - format; + var failableParserFn, format; if ( options && options.format !== undefined ) { format = options.format; @@ -164,6 +198,9 @@ } return function () { + if ( !failableParserFn ) { + failableParserFn = getFailableParserFn( options ); + } var failableResult = failableParserFn( arguments ); if ( format === 'text' || format === 'escaped' ) { return failableResult.text(); @@ -196,15 +233,14 @@ * @return {jQuery} return.return */ mw.jqueryMsg.getPlugin = function ( options ) { - var failableParserFn = getFailableParserFn( options ); + var failableParserFn; return function () { + if ( !failableParserFn ) { + failableParserFn = getFailableParserFn( options ); + } var $target = this.empty(); - // TODO: Simply appendWithoutParsing( $target, failableParserFn( arguments ).contents() ) - // or Simply appendWithoutParsing( $target, failableParserFn( arguments ) ) - $.each( failableParserFn( arguments ).contents(), function ( i, node ) { - appendWithoutParsing( $target, node ); - } ); + appendWithoutParsing( $target, failableParserFn( arguments ) ); return $target; }; }; @@ -226,32 +262,10 @@ mw.jqueryMsg.parser.prototype = { /** - * Cache mapping MediaWiki message keys and the value onlyCurlyBraceTransform, to the AST of the message. - * - * In most cases, the message is a string so this is identical. - * (This is why we would like to move this functionality server-side). - * - * The two parts of the key are separated by colon. For example: - * - * "message-key:true": ast - * - * if they key is "message-key" and onlyCurlyBraceTransform is true. - * - * This cache is shared by all instances of mw.jqueryMsg.parser. - * - * NOTE: We promise, it's static - when you create this empty object - * in the prototype, each new instance of the class gets a reference - * to the same object. - * - * @static - * @property {Object} - */ - astCache: {}, - - /** * Where the magic happens. * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery * If an error is thrown, returns original key, and logs the error + * * @param {string} key Message key. * @param {Array} replacements Variable replacements for $1, $2... $n * @return {jQuery} @@ -263,21 +277,16 @@ /** * Fetch the message string associated with a key, return parsed structure. Memoized. * Note that we pass '[' + key + ']' back for a missing message here. + * * @param {string} key * @return {string|Array} string of '[key]' if message missing, simple string if possible, array of arrays if needs parsing */ getAst: function ( key ) { - var wikiText, - cacheKey = [key, this.settings.onlyCurlyBraceTransform].join( ':' ); - - if ( this.astCache[ cacheKey ] === undefined ) { - wikiText = this.settings.messages.get( key ); - if ( typeof wikiText !== 'string' ) { - wikiText = '\\[' + key + '\\]'; - } - this.astCache[ cacheKey ] = this.wikiTextToAst( wikiText ); + var wikiText = this.settings.messages.get( key ); + if ( typeof wikiText !== 'string' ) { + wikiText = '\\[' + key + '\\]'; } - return this.astCache[ cacheKey ]; + return this.wikiTextToAst( wikiText ); }, /** @@ -297,7 +306,7 @@ escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral, whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue, htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag, - openExtlink, closeExtlink, wikilinkPage, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon, + openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon, templateContents, openTemplate, closeTemplate, nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result, settings = this.settings, @@ -313,6 +322,7 @@ /** * Try parsers until one works, if none work return null + * * @private * @param {Function[]} ps * @return {string|null} @@ -321,7 +331,7 @@ return function () { var i, result; for ( i = 0; i < ps.length; i++ ) { - result = ps[i](); + result = ps[ i ](); if ( result !== null ) { return result; } @@ -333,6 +343,7 @@ /** * Try several ps in a row, all must succeed or return null. * This is the only eager one. + * * @private * @param {Function[]} ps * @return {string|null} @@ -342,7 +353,7 @@ originalPos = pos, result = []; for ( i = 0; i < ps.length; i++ ) { - res = ps[i](); + res = ps[ i ](); if ( res === null ) { pos = originalPos; return null; @@ -355,6 +366,7 @@ /** * Run the same parser over and over until it fails. * Must succeed a minimum of n times or return null. + * * @private * @param {number} n * @param {Function} p @@ -397,6 +409,7 @@ /** * Just make parsers out of simpler JS builtin types + * * @private * @param {string} s * @return {Function} @@ -429,8 +442,8 @@ if ( matches === null ) { return null; } - pos += matches[0].length; - return matches[0]; + pos += matches[ 0 ].length; + return matches[ 0 ]; }; } @@ -470,7 +483,7 @@ backslash, anyCharacter ] ); - return result === null ? null : result[1]; + return result === null ? null : result[ 1 ]; } escapedOrLiteralWithoutSpace = choice( [ escapedLiteral, @@ -496,13 +509,6 @@ return result === null ? null : result.join( '' ); } - // Used for wikilink page names. Like literalWithoutBar, but - // without allowing escapes. - function unescapedLiteralWithoutBar() { - var result = nOrMore( 1, regularLiteralWithoutBar )(); - return result === null ? null : result.join( '' ); - } - function literal() { var result = nOrMore( 1, escapedOrRegularLiteral )(); return result === null ? null : result.join( '' ); @@ -529,48 +535,37 @@ if ( result === null ) { return null; } - return [ 'REPLACE', parseInt( result[1], 10 ) - 1 ]; + return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ]; } openExtlink = makeStringParser( '[' ); closeExtlink = makeStringParser( ']' ); // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed function extlink() { - var result, parsedResult; + var result, parsedResult, target; result = null; parsedResult = sequence( [ openExtlink, - nonWhitespaceExpression, + nOrMore( 1, nonWhitespaceExpression ), whitespace, nOrMore( 1, expression ), closeExtlink ] ); if ( parsedResult !== null ) { - result = [ 'EXTLINK', parsedResult[1] ]; - // TODO (mattflaschen, 2013-03-22): Clean this up if possible. - // It's avoiding CONCAT for single nodes, so they at least doesn't get the htmlEmitter span. - if ( parsedResult[3].length === 1 ) { - result.push( parsedResult[3][0] ); - } else { - result.push( ['CONCAT'].concat( parsedResult[3] ) ); - } + // When the entire link target is a single parameter, we can't use CONCAT, as we allow + // passing fancy parameters (like a whole jQuery object or a function) to use for the + // link. Check only if it's a single match, since we can either do CONCAT or not for + // singles with the same effect. + target = parsedResult[ 1 ].length === 1 ? + parsedResult[ 1 ][ 0 ] : + [ 'CONCAT' ].concat( parsedResult[ 1 ] ); + result = [ + 'EXTLINK', + target, + [ 'CONCAT' ].concat( parsedResult[ 3 ] ) + ]; } return result; } - // this is the same as the above extlink, except that the url is being passed on as a parameter - function extLinkParam() { - var result = sequence( [ - openExtlink, - dollar, - digits, - whitespace, - expression, - closeExtlink - ] ); - if ( result === null ) { - return null; - } - return [ 'EXTLINKPARAM', parseInt( result[2], 10 ) - 1, result[4] ]; - } openWikilink = makeStringParser( '[[' ); closeWikilink = makeStringParser( ']]' ); pipe = makeStringParser( '|' ); @@ -581,26 +576,33 @@ templateContents, closeTemplate ] ); - return result === null ? null : result[1]; + return result === null ? null : result[ 1 ]; } - wikilinkPage = choice( [ - unescapedLiteralWithoutBar, - template - ] ); - function pipedWikilink() { var result = sequence( [ - wikilinkPage, + nOrMore( 1, paramExpression ), pipe, - expression + nOrMore( 1, expression ) + ] ); + return result === null ? null : [ + [ 'CONCAT' ].concat( result[ 0 ] ), + [ 'CONCAT' ].concat( result[ 2 ] ) + ]; + } + + function unpipedWikilink() { + var result = sequence( [ + nOrMore( 1, paramExpression ) ] ); - return result === null ? null : [ result[0], result[2] ]; + return result === null ? null : [ + [ 'CONCAT' ].concat( result[ 0 ] ) + ]; } wikilinkContents = choice( [ pipedWikilink, - wikilinkPage // unpiped link + unpipedWikilink ] ); function wikilink() { @@ -613,7 +615,7 @@ closeWikilink ] ); if ( parsedResult !== null ) { - parsedLinkContents = parsedResult[1]; + parsedLinkContents = parsedResult[ 1 ]; result = [ 'WIKILINK' ].concat( parsedLinkContents ); } return result; @@ -626,7 +628,7 @@ htmlDoubleQuoteAttributeValue, doubleQuote ] ); - return parsedResult === null ? null : parsedResult[1]; + return parsedResult === null ? null : parsedResult[ 1 ]; } function singleQuotedHtmlAttributeValue() { @@ -635,7 +637,7 @@ htmlSingleQuoteAttributeValue, singleQuote ] ); - return parsedResult === null ? null : parsedResult[1]; + return parsedResult === null ? null : parsedResult[ 1 ]; } function htmlAttribute() { @@ -648,7 +650,7 @@ singleQuotedHtmlAttributeValue ] ) ] ); - return parsedResult === null ? null : [parsedResult[1], parsedResult[3]]; + return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ]; } /** @@ -670,9 +672,9 @@ } for ( i = 0, len = attributes.length; i < len; i += 2 ) { - attributeName = attributes[i]; + attributeName = attributes[ i ]; if ( $.inArray( attributeName, settings.allowedHtmlCommonAttributes ) === -1 && - $.inArray( attributeName, settings.allowedHtmlAttributesByElement[startTagName] || [] ) === -1 ) { + $.inArray( attributeName, settings.allowedHtmlAttributesByElement[ startTagName ] || [] ) === -1 ) { return false; } } @@ -683,7 +685,7 @@ function htmlAttributes() { var parsedResult = nOrMore( 0, htmlAttribute )(); // Un-nest attributes array due to structure of jQueryMsg operations (see emit). - return concat.apply( ['HTMLATTRIBUTES'], parsedResult ); + return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult ); } // Subset of allowed HTML markup. @@ -714,7 +716,7 @@ } endOpenTagPos = pos; - startTagName = parsedOpenTagResult[1]; + startTagName = parsedOpenTagResult[ 1 ]; parsedHtmlContents = nOrMore( 0, expression )(); @@ -732,8 +734,8 @@ } endCloseTagPos = pos; - endTagName = parsedCloseTagResult[1]; - wrappedAttributes = parsedOpenTagResult[2]; + endTagName = parsedCloseTagResult[ 1 ]; + wrappedAttributes = parsedOpenTagResult[ 2 ]; attributes = wrappedAttributes.slice( 1 ); if ( isAllowedHtml( startTagName, endTagName, attributes ) ) { result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ] @@ -773,9 +775,9 @@ if ( result === null ) { return null; } - expr = result[1]; + expr = result[ 1 ]; // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw. - return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[0]; + return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ]; } function templateWithReplacement() { @@ -784,7 +786,7 @@ colon, replacement ] ); - return result === null ? null : [ result[0], result[2] ]; + return result === null ? null : [ result[ 0 ], result[ 2 ] ]; } function templateWithOutReplacement() { var result = sequence( [ @@ -792,14 +794,14 @@ colon, paramExpression ] ); - return result === null ? null : [ result[0], result[2] ]; + return result === null ? null : [ result[ 0 ], result[ 2 ] ]; } function templateWithOutFirstParameter() { var result = sequence( [ templateName, colon ] ); - return result === null ? null : [ result[0], '' ]; + return result === null ? null : [ result[ 0 ], '' ]; } colon = makeStringParser( ':' ); templateContents = choice( [ @@ -810,7 +812,7 @@ choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ), nOrMore( 0, templateParam ) ] ); - return res === null ? null : res[0].concat( res[1] ); + return res === null ? null : res[ 0 ].concat( res[ 1 ] ); }, function () { var res = sequence( [ @@ -820,7 +822,7 @@ if ( res === null ) { return null; } - return [ res[0] ].concat( res[1] ); + return [ res[ 0 ] ].concat( res[ 1 ] ); } ] ); openTemplate = makeStringParser( '{{' ); @@ -828,7 +830,6 @@ nonWhitespaceExpression = choice( [ template, wikilink, - extLinkParam, extlink, replacement, literalWithoutSpace @@ -836,7 +837,6 @@ paramExpression = choice( [ template, wikilink, - extLinkParam, extlink, replacement, literalWithoutBar @@ -845,7 +845,6 @@ expression = choice( [ template, wikilink, - extLinkParam, extlink, replacement, html, @@ -876,7 +875,6 @@ // I am deferring the work of turning it into prototypes & objects. It's quite fast enough // finally let's do some actual work... - // If you add another possible rootExpression, you must update the astCache key scheme. result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression ); /* @@ -907,6 +905,7 @@ /** * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.) * Walk entire node structure, applying replacements and template functions when appropriate + * * @param {Mixed} node Abstract syntax tree (top node or subnode) * @param {Array} replacements for $1, $2, ... $n * @return {Mixed} single-string node or array of nodes suitable for jQuery appending @@ -925,8 +924,8 @@ subnodes = $.map( node.slice( 1 ), function ( n ) { return jmsg.emit( n, replacements ); } ); - operation = node[0].toLowerCase(); - if ( typeof jmsg[operation] === 'function' ) { + operation = node[ 0 ].toLowerCase(); + if ( typeof jmsg[ operation ] === 'function' ) { ret = jmsg[ operation ]( subnodes, replacements ); } else { throw new Error( 'Unknown operation "' + operation + '"' ); @@ -956,21 +955,16 @@ * Parsing has been applied depth-first we can assume that all nodes here are single nodes * Must return a single node to parents -- a jQuery with synthetic span * However, unwrap any other synthetic spans in our children and pass them upwards + * * @param {Mixed[]} nodes Some single nodes, some arrays of nodes * @return {jQuery} */ concat: function ( nodes ) { var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' ); $.each( nodes, function ( i, node ) { - if ( node instanceof jQuery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) { - $.each( node.contents(), function ( j, childNode ) { - appendWithoutParsing( $span, childNode ); - } ); - } else { - // Let jQuery append nodes, arrays of nodes and jQuery objects - // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings) - appendWithoutParsing( $span, node ); - } + // Let jQuery append nodes, arrays of nodes and jQuery objects + // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings) + appendWithoutParsing( $span, node ); } ); return $span; }, @@ -988,10 +982,10 @@ * @return {String} replacement */ replace: function ( nodes, replacements ) { - var index = parseInt( nodes[0], 10 ); + var index = parseInt( nodes[ 0 ], 10 ); if ( index < replacements.length ) { - return replacements[index]; + return replacements[ index ]; } else { // index not found, fallback to displaying variable return '$' + ( index + 1 ); @@ -1010,12 +1004,17 @@ * from the server, since the replacement is done at save time. * It may, though, if the wikitext appears in extension-controlled content. * - * @param nodes + * @param {String[]} nodes */ wikilink: function ( nodes ) { - var page, anchor, url; + var page, anchor, url, $el; - page = nodes[0]; + page = textify( nodes[ 0 ] ); + // Strip leading ':', which is used to suppress special behavior in wikitext links, + // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]] + if ( page.charAt( 0 ) === ':' ) { + page = page.slice( 1 ); + } url = mw.util.getUrl( page ); if ( nodes.length === 1 ) { @@ -1023,13 +1022,14 @@ anchor = page; } else { // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]] - anchor = nodes[1]; + anchor = nodes[ 1 ]; } - return $( '<a>' ).attr( { + $el = $( '<a>' ).attr( { title: page, href: url - } ).text( anchor ); + } ); + return appendWithoutParsing( $el, anchor ); }, /** @@ -1042,7 +1042,7 @@ htmlattributes: function ( nodes ) { var i, len, mapping = {}; for ( i = 0, len = nodes.length; i < len; i += 2 ) { - mapping[nodes[i]] = decodePrimaryHtmlEntities( nodes[i + 1] ); + mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] ); } return mapping; }, @@ -1064,11 +1064,12 @@ }, /** - * Transform parsed structure into external link - * If the href is a jQuery object, treat it as "enclosing" the link text. + * Transform parsed structure into external link. * - * - ... function, treat it as the click handler. - * - ... string, treat it as a URI. + * The "href" can be: + * - a jQuery object, treat it as "enclosing" the link text. + * - a function, treat it as the click handler. + * - a string, or our htmlEmitter jQuery object, treat it as a URI after stringifying. * * TODO: throw an error if nodes.length > 2 ? * @@ -1077,9 +1078,9 @@ */ extlink: function ( nodes ) { var $el, - arg = nodes[0], - contents = nodes[1]; - if ( arg instanceof jQuery ) { + arg = nodes[ 0 ], + contents = nodes[ 1 ]; + if ( arg instanceof jQuery && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) { $el = arg; } else { $el = $( '<a>' ); @@ -1090,39 +1091,17 @@ } ) .click( arg ); } else { - $el.attr( 'href', arg.toString() ); + $el.attr( 'href', textify( arg ) ); } } return appendWithoutParsing( $el, contents ); }, /** - * This is basically use a combination of replace + external link (link with parameter - * as url), but we don't want to run the regular replace here-on: inserting a - * url as href-attribute of a link will automatically escape it already, so - * we don't want replace to (manually) escape it as well. - * - * TODO: throw error if nodes.length > 1 ? - * - * @param {Array} nodes List of one element, integer, n >= 0 - * @param {Array} replacements List of at least n strings - * @return {string} replacement - */ - extlinkparam: function ( nodes, replacements ) { - var replacement, - index = parseInt( nodes[0], 10 ); - if ( index < replacements.length ) { - replacement = replacements[index]; - } else { - replacement = '$' + ( index + 1 ); - } - return this.extlink( [ replacement, nodes[1] ] ); - }, - - /** * Transform parsed structure into pluralization * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number). * So convert it back with the current language's convertNumber. + * * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ] * @return {string} selected pluralized form according to current language */ @@ -1130,30 +1109,30 @@ var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count, explicitPluralForms = {}; - count = parseFloat( this.language.convertNumber( nodes[0], true ) ); + count = parseFloat( this.language.convertNumber( nodes[ 0 ], true ) ); forms = nodes.slice( 1 ); for ( formIndex = 0; formIndex < forms.length; formIndex++ ) { - form = forms[formIndex]; + form = forms[ formIndex ]; - if ( form.jquery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) { + if ( form instanceof jQuery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) { // This is a nested node, may be an explicit plural form like 5=[$2 linktext] firstChild = form.contents().get( 0 ); if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) { firstChildText = firstChild.textContent; if ( /^\d+=/.test( firstChildText ) ) { - explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[0], 10 ); + explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 ); // Use the digit part as key and rest of first text node and // rest of child nodes as value. firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 ); - explicitPluralForms[explicitPluralFormNumber] = form; - forms[formIndex] = undefined; + explicitPluralForms[ explicitPluralFormNumber ] = form; + forms[ formIndex ] = undefined; } } } else if ( /^\d+=/.test( form ) ) { // Simple explicit plural forms like 12=a dozen - explicitPluralFormNumber = parseInt( form.split( /=/ )[0], 10 ); - explicitPluralForms[explicitPluralFormNumber] = form.slice( form.indexOf( '=' ) + 1 ); - forms[formIndex] = undefined; + explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 ); + explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 ); + forms[ formIndex ] = undefined; } } @@ -1180,7 +1159,7 @@ */ gender: function ( nodes ) { var gender, - maybeUser = nodes[0], + maybeUser = nodes[ 0 ], forms = nodes.slice( 1 ); if ( maybeUser === '' ) { @@ -1201,35 +1180,39 @@ /** * Transform parsed structure into grammar conversion. * Invoked by putting `{{grammar:form|word}}` in a message + * * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}] * @return {string} selected grammatical form according to current language */ grammar: function ( nodes ) { - var form = nodes[0], - word = nodes[1]; + var form = nodes[ 0 ], + word = nodes[ 1 ]; return word && form && this.language.convertGrammar( word, form ); }, /** * Tranform parsed structure into a int: (interface language) message include * Invoked by putting `{{int:othermessage}}` into a message + * * @param {Array} nodes List of nodes * @return {string} Other message */ 'int': function ( nodes ) { - return mw.jqueryMsg.getMessageFunction()( nodes[0].toLowerCase() ); + var msg = nodes[ 0 ]; + return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) ); }, /** * Takes an unformatted number (arab, no group separators and . as decimal separator) * and outputs it in the localized digit script and formatted with decimal * separator, according to the current language. + * * @param {Array} nodes List of nodes * @return {number|string} Formatted number */ formatnum: function ( nodes ) { - var isInteger = ( nodes[1] && nodes[1] === 'R' ) ? true : false, - number = nodes[0]; + var isInteger = ( nodes[ 1 ] && nodes[ 1 ] === 'R' ) ? true : false, + number = nodes[ 0 ]; return this.language.convertNumber( number, isInteger ); } @@ -1264,9 +1247,9 @@ } messageFunction = mw.jqueryMsg.getMessageFunction( { - 'messages': this.map, + messages: this.map, // For format 'escaped', escaping part is handled by mediawiki.js - 'format': this.format + format: this.format } ); return messageFunction( this.key, this.parameters ); }; diff --git a/resources/src/mediawiki/mediawiki.js b/resources/src/mediawiki/mediawiki.js index ee57c21f..9436dbf2 100644 --- a/resources/src/mediawiki/mediawiki.js +++ b/resources/src/mediawiki/mediawiki.js @@ -7,6 +7,8 @@ * @alternateClassName mediaWiki * @singleton */ +/*jshint latedef:false */ +/*global sha1 */ ( function ( $ ) { 'use strict'; @@ -14,6 +16,7 @@ hasOwn = Object.prototype.hasOwnProperty, slice = Array.prototype.slice, trackCallbacks = $.Callbacks( 'memory' ), + trackHandlers = [], trackQueue = []; /** @@ -66,7 +69,7 @@ if ( $.isPlainObject( selection ) ) { for ( s in selection ) { - setGlobalMapValue( this, s, selection[s] ); + setGlobalMapValue( this, s, selection[ s ] ); } return true; } @@ -93,13 +96,13 @@ * @param {Mixed} value */ function setGlobalMapValue( map, key, value ) { - map.values[key] = value; + map.values[ key ] = value; mw.log.deprecate( - window, - key, - value, - // Deprecation notice for mw.config globals (T58550, T72470) - map === mw.config && 'Use mw.config instead.' + window, + key, + value, + // Deprecation notice for mw.config globals (T58550, T72470) + map === mw.config && 'Use mw.config instead.' ); } @@ -126,7 +129,7 @@ selection = slice.call( selection ); results = {}; for ( i = 0; i < selection.length; i++ ) { - results[selection[i]] = this.get( selection[i], fallback ); + results[ selection[ i ] ] = this.get( selection[ i ], fallback ); } return results; } @@ -135,7 +138,7 @@ if ( !hasOwn.call( this.values, selection ) ) { return fallback; } - return this.values[selection]; + return this.values[ selection ]; } if ( selection === undefined ) { @@ -158,12 +161,12 @@ if ( $.isPlainObject( selection ) ) { for ( s in selection ) { - this.values[s] = selection[s]; + this.values[ s ] = selection[ s ]; } return true; } if ( typeof selection === 'string' && arguments.length > 1 ) { - this.values[selection] = value; + this.values[ selection ] = value; return true; } return false; @@ -180,7 +183,7 @@ if ( $.isArray( selection ) ) { for ( s = 0; s < selection.length; s++ ) { - if ( typeof selection[s] !== 'string' || !hasOwn.call( this.values, selection[s] ) ) { + if ( typeof selection[ s ] !== 'string' || !hasOwn.call( this.values, selection[ s ] ) ) { return false; } } @@ -282,7 +285,7 @@ params: function ( parameters ) { var i; for ( i = 0; i < parameters.length; i += 1 ) { - this.parameters.push( parameters[i] ); + this.parameters.push( parameters[ i ] ); } return this; }, @@ -420,7 +423,7 @@ var parameters = slice.call( arguments, 1 ); return formatString.replace( /\$(\d+)/g, function ( str, match ) { var index = parseInt( match, 10 ) - 1; - return parameters[index] !== undefined ? parameters[index] : '$' + match; + return parameters[ index ] !== undefined ? parameters[ index ] : '$' + match; } ); }, @@ -461,8 +464,7 @@ */ trackSubscribe: function ( topic, callback ) { var seen = 0; - - trackCallbacks.add( function ( trackQueue ) { + function handler( trackQueue ) { var event; for ( ; seen < trackQueue.length; seen++ ) { event = trackQueue[ seen ]; @@ -470,6 +472,26 @@ callback.call( event, event.topic, event.data ); } } + } + + trackHandlers.push( [ handler, callback ] ); + + trackCallbacks.add( handler ); + }, + + /** + * Stop handling events for a particular handler + * + * @param {Function} callback + */ + trackUnsubscribe: function ( callback ) { + trackHandlers = $.grep( trackHandlers, function ( fns ) { + if ( fns[ 1 ] === callback ) { + trackCallbacks.remove( fns[ 0 ] ); + // Ensure the tuple is removed to avoid holding on to closures + return false; + } + return true; } ); }, @@ -560,6 +582,7 @@ /** * Dummy placeholder for {@link mw.log} + * * @method */ log: ( function () { @@ -574,7 +597,6 @@ /** * Write a message the console's warning channel. - * Also logs a stacktrace for easier debugging. * Actions not supported by the browser console are silently ignored. * * @param {string...} msg Messages to output to console @@ -583,9 +605,22 @@ var console = window.console; if ( console && console.warn && console.warn.apply ) { console.warn.apply( console, arguments ); - if ( console.trace ) { - console.trace(); - } + } + }; + + /** + * Write a message the console's error channel. + * + * Most browsers provide a stacktrace by default if the argument + * is a caught Error object. + * + * @since 1.26 + * @param {Error|string...} msg Messages to output to console + */ + log.error = function () { + var console = window.console; + if ( console && console.error && console.error.apply ) { + console.error.apply( console, arguments ); } }; @@ -599,7 +634,7 @@ * @param {string} [msg] Optional text to include in the deprecation message */ log.deprecate = !Object.defineProperty ? function ( obj, key, val ) { - obj[key] = val; + obj[ key ] = val; } : function ( obj, key, val, msg ) { msg = 'Use of "' + key + '" is deprecated.' + ( msg ? ( ' ' + msg ) : '' ); // Support: IE8 @@ -621,7 +656,7 @@ } ); } catch ( err ) { // Fallback to creating a copy of the value to the object. - obj[key] = val; + obj[ key ] = val; } }; @@ -678,28 +713,54 @@ /** * Mapping of registered modules. * - * See #implement for exact details on support for script, style and messages. + * See #implement and #execute for exact details on support for script, style and messages. * * Format: * * { * 'moduleName': { - * // From startup mdoule - * 'version': ############## (unix timestamp) + * // From mw.loader.register() + * 'version': '########' (hash) * 'dependencies': ['required.foo', 'bar.also', ...], (or) function () {} * 'group': 'somegroup', (or) null * 'source': 'local', (or) 'anotherwiki' * 'skip': 'return !!window.Example', (or) null + * + * // Set from execute() or mw.loader.state() * 'state': 'registered', 'loaded', 'loading', 'ready', 'error', or 'missing' * - * // Added during implementation + * // Optionally added at run-time by mw.loader.implement() * 'skipped': true - * 'script': ... - * 'style': ... - * 'messages': { 'key': 'value' } + * 'script': closure, array of urls, or string + * 'style': { ... } (see #execute) + * 'messages': { 'key': 'value', ... } * } * } * + * State machine: + * + * - `registered`: + * The module is known to the system but not yet requested. + * Meta data is registered via mw.loader#register. Calls to that method are + * generated server-side by the startup module. + * - `loading`: + * The module is requested through mw.loader (either directly or as dependency of + * another module). The client will be fetching module contents from the server. + * The contents are then stashed in the registry via mw.loader#implement. + * - `loaded`: + * The module has been requested from the server and stashed via mw.loader#implement. + * If the module has no more dependencies in-fight, the module will be executed + * right away. Otherwise execution is deferred, controlled via #handlePending. + * - `executing`: + * The module is being executed. + * - `ready`: + * The module has been successfully executed. + * - `error`: + * The module (or one of its dependencies) produced an error during execution. + * - `missing`: + * The module was registered client-side and requested, but the server denied knowledge + * of the module's existence. + * * @property * @private */ @@ -720,7 +781,25 @@ // List of modules to be loaded queue = [], - // List of callback functions waiting for modules to be ready to be called + /** + * List of callback jobs waiting for modules to be ready. + * + * Jobs are created by #request() and run by #handlePending(). + * + * Typically when a job is created for a module, the job's dependencies contain + * both the module being requested and all its recursive dependencies. + * + * Format: + * + * { + * 'dependencies': [ module names ], + * 'ready': Function callback + * 'error': Function callback + * } + * + * @property {Object[]} jobs + * @private + */ jobs = [], // Selector cache for the marker element. Use getMarker() to get/use the marker! @@ -760,7 +839,7 @@ if ( nextnode ) { $( nextnode ).before( s ); } else { - document.getElementsByTagName( 'head' )[0].appendChild( s ); + document.getElementsByTagName( 'head' )[ 0 ].appendChild( s ); } if ( s.styleSheet ) { // Support: IE6-10 @@ -784,7 +863,15 @@ * @param {Function} [callback] */ function addEmbeddedCSS( cssText, callback ) { - var $style, styleEl; + var $style, styleEl, newCssText; + + function fireCallbacks() { + var oldCallbacks = cssCallbacks; + // Reset cssCallbacks variable so it's not polluted by any calls to + // addEmbeddedCSS() from one of the callbacks (T105973) + cssCallbacks = $.Callbacks(); + oldCallbacks.fire().empty(); + } if ( callback ) { cssCallbacks.add( callback ); @@ -837,149 +924,61 @@ // Verify that the element before the marker actually is a // <style> tag and one that came from ResourceLoader // (not some other style tag or even a `<meta>` or `<script>`). - if ( $style.data( 'ResourceLoaderDynamicStyleTag' ) === true ) { + if ( $style.data( 'ResourceLoaderDynamicStyleTag' ) ) { // There's already a dynamic <style> tag present and // we are able to append more to it. styleEl = $style.get( 0 ); // Support: IE6-10 if ( styleEl.styleSheet ) { try { - styleEl.styleSheet.cssText += cssText; + // Support: IE9 + // We can't do styleSheet.cssText += cssText, since IE9 mangles this property on + // write, dropping @media queries from the CSS text. If we read it and used its + // value, we would accidentally apply @media-specific styles to all media. (T108727) + if ( document.documentMode === 9 ) { + newCssText = $style.data( 'ResourceLoaderDynamicStyleTag' ) + cssText; + styleEl.styleSheet.cssText = newCssText; + $style.data( 'ResourceLoaderDynamicStyleTag', newCssText ); + } else { + styleEl.styleSheet.cssText += cssText; + } } catch ( e ) { mw.track( 'resourceloader.exception', { exception: e, source: 'stylesheet' } ); } } else { styleEl.appendChild( document.createTextNode( cssText ) ); } - cssCallbacks.fire().empty(); + fireCallbacks(); return; } } - $( newStyleTag( cssText, getMarker() ) ).data( 'ResourceLoaderDynamicStyleTag', true ); - - cssCallbacks.fire().empty(); - } - - /** - * Zero-pad three numbers. - * - * @private - * @param {number} a - * @param {number} b - * @param {number} c - * @return {string} - */ - function pad( a, b, c ) { - return ( - ( a < 10 ? '0' : '' ) + a + - ( b < 10 ? '0' : '' ) + b + - ( c < 10 ? '0' : '' ) + c - ); - } - - /** - * Convert UNIX timestamp to ISO8601 format. - * - * @private - * @param {number} timestamp UNIX timestamp - */ - function formatVersionNumber( timestamp ) { - var d = new Date(); - d.setTime( timestamp * 1000 ); - return [ - pad( d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate() ), - 'T', - pad( d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ), - 'Z' - ].join( '' ); - } - - /** - * Resolve dependencies and detect circular references. - * - * @private - * @param {string} module Name of the top-level module whose dependencies shall be - * resolved and sorted. - * @param {Array} resolved Returns a topological sort of the given module and its - * dependencies, such that later modules depend on earlier modules. The array - * contains the module names. If the array contains already some module names, - * this function appends its result to the pre-existing array. - * @param {Object} [unresolved] Hash used to track the current dependency - * chain; used to report loops in the dependency graph. - * @throws {Error} If any unregistered module or a dependency loop is encountered - */ - function sortDependencies( module, resolved, unresolved ) { - var n, deps, len, skip; - - if ( !hasOwn.call( registry, module ) ) { - throw new Error( 'Unknown dependency: ' + module ); - } - - if ( registry[module].skip !== null ) { - /*jshint evil:true */ - skip = new Function( registry[module].skip ); - registry[module].skip = null; - if ( skip() ) { - registry[module].skipped = true; - registry[module].dependencies = []; - registry[module].state = 'ready'; - handlePending( module ); - return; - } - } + $style = $( newStyleTag( cssText, getMarker() ) ); - // Resolves dynamic loader function and replaces it with its own results - if ( $.isFunction( registry[module].dependencies ) ) { - registry[module].dependencies = registry[module].dependencies(); - // Ensures the module's dependencies are always in an array - if ( typeof registry[module].dependencies !== 'object' ) { - registry[module].dependencies = [registry[module].dependencies]; - } - } - if ( $.inArray( module, resolved ) !== -1 ) { - // Module already resolved; nothing to do - return; - } - // Create unresolved if not passed in - if ( !unresolved ) { - unresolved = {}; + if ( document.documentMode === 9 ) { + // Support: IE9 + // Preserve original CSS text because IE9 mangles it on write + $style.data( 'ResourceLoaderDynamicStyleTag', cssText ); + } else { + $style.data( 'ResourceLoaderDynamicStyleTag', true ); } - // Tracks down dependencies - deps = registry[module].dependencies; - len = deps.length; - for ( n = 0; n < len; n += 1 ) { - if ( $.inArray( deps[n], resolved ) === -1 ) { - if ( unresolved[deps[n]] ) { - throw new Error( - 'Circular reference detected: ' + module + - ' -> ' + deps[n] - ); - } - // Add to unresolved - unresolved[module] = true; - sortDependencies( deps[n], resolved, unresolved ); - delete unresolved[module]; - } - } - resolved[resolved.length] = module; + fireCallbacks(); } /** - * Get a list of module names that a module depends on in their proper dependency - * order. - * - * @private - * @param {string[]} module Array of string module names - * @return {Array} List of dependencies, including 'module'. + * @since 1.26 + * @param {Array} modules List of module names + * @return {string} Hash of concatenated version hashes. */ - function resolve( modules ) { - var resolved = []; - $.each( modules, function ( idx, module ) { - sortDependencies( module, resolved ); + function getCombinedVersion( modules ) { + var hashes = $.map( modules, function ( module ) { + return registry[ module ].version; } ); - return resolved; + // Trim for consistency with server-side ResourceLoader::makeHash. It also helps + // save precious space in the limited query string. Otherwise modules are more + // likely to require multiple HTTP requests. + return sha1( hashes.join( '' ) ).slice( 0, 12 ); } /** @@ -993,7 +992,7 @@ function allReady( modules ) { var i; for ( i = 0; i < modules.length; i++ ) { - if ( mw.loader.getState( modules[i] ) !== 'ready' ) { + if ( mw.loader.getState( modules[ i ] ) !== 'ready' ) { return false; } } @@ -1011,7 +1010,7 @@ function anyFailed( modules ) { var i, state; for ( i = 0; i < modules.length; i++ ) { - state = mw.loader.getState( modules[i] ); + state = mw.loader.getState( modules[ i ] ); if ( state === 'error' || state === 'missing' ) { return true; } @@ -1033,16 +1032,16 @@ function handlePending( module ) { var j, job, hasErrors, m, stateChange; - if ( registry[module].state === 'error' || registry[module].state === 'missing' ) { + if ( registry[ module ].state === 'error' || registry[ module ].state === 'missing' ) { // If the current module failed, mark all dependent modules also as failed. // Iterate until steady-state to propagate the error state upwards in the // dependency tree. do { stateChange = false; for ( m in registry ) { - if ( registry[m].state !== 'error' && registry[m].state !== 'missing' ) { - if ( anyFailed( registry[m].dependencies ) ) { - registry[m].state = 'error'; + if ( registry[ m ].state !== 'error' && registry[ m ].state !== 'missing' ) { + if ( anyFailed( registry[ m ].dependencies ) ) { + registry[ m ].state = 'error'; stateChange = true; } } @@ -1052,16 +1051,16 @@ // Execute all jobs whose dependencies are either all satisfied or contain at least one failed module. for ( j = 0; j < jobs.length; j += 1 ) { - hasErrors = anyFailed( jobs[j].dependencies ); - if ( hasErrors || allReady( jobs[j].dependencies ) ) { + hasErrors = anyFailed( jobs[ j ].dependencies ); + if ( hasErrors || allReady( jobs[ j ].dependencies ) ) { // All dependencies satisfied, or some have errors - job = jobs[j]; + job = jobs[ j ]; jobs.splice( j, 1 ); j -= 1; try { if ( hasErrors ) { if ( $.isFunction( job.error ) ) { - job.error( new Error( 'Module ' + module + ' has failed dependencies' ), [module] ); + job.error( new Error( 'Module ' + module + ' has failed dependencies' ), [ module ] ); } } else { if ( $.isFunction( job.ready ) ) { @@ -1076,12 +1075,12 @@ } } - if ( registry[module].state === 'ready' ) { + if ( registry[ module ].state === 'ready' ) { // The current module became 'ready'. Set it in the module store, and recursively execute all // dependent modules that are loaded and now have all dependencies satisfied. - mw.loader.store.set( module, registry[module] ); + mw.loader.store.set( module, registry[ module ] ); for ( m in registry ) { - if ( registry[m].state === 'loaded' && allReady( registry[m].dependencies ) ) { + if ( registry[ m ].state === 'loaded' && allReady( registry[ m ].dependencies ) ) { execute( m ); } } @@ -1089,39 +1088,130 @@ } /** - * Adds a script tag to the DOM, either using document.write or low-level DOM manipulation, - * depending on whether document-ready has occurred yet and whether we are in async mode. + * Resolve dependencies and detect circular references. * * @private - * @param {string} src URL to script, will be used as the src attribute in the script tag - * @param {Function} [callback] Callback which will be run when the script is done - * @param {boolean} [async=false] Whether to load modules asynchronously. - * Ignored (and defaulted to `true`) if the document-ready event has already occurred. + * @param {string} module Name of the top-level module whose dependencies shall be + * resolved and sorted. + * @param {Array} resolved Returns a topological sort of the given module and its + * dependencies, such that later modules depend on earlier modules. The array + * contains the module names. If the array contains already some module names, + * this function appends its result to the pre-existing array. + * @param {Object} [unresolved] Hash used to track the current dependency + * chain; used to report loops in the dependency graph. + * @throws {Error} If any unregistered module or a dependency loop is encountered */ - function addScript( src, callback, async ) { - // Using isReady directly instead of storing it locally from a $().ready callback (bug 31895) - if ( $.isReady || async ) { - $.ajax( { - url: src, - dataType: 'script', - // Force jQuery behaviour to be for crossDomain. Otherwise jQuery would use - // XHR for a same domain request instead of <script>, which changes the request - // headers (potentially missing a cache hit), and reduces caching in general - // since browsers cache XHR much less (if at all). And XHR means we retreive - // text, so we'd need to $.globalEval, which then messes up line numbers. - crossDomain: true, - cache: true, - async: true - } ).always( callback ); - } else { + function sortDependencies( module, resolved, unresolved ) { + var n, deps, len, skip; + + if ( !hasOwn.call( registry, module ) ) { + throw new Error( 'Unknown dependency: ' + module ); + } + + if ( registry[ module ].skip !== null ) { /*jshint evil:true */ - document.write( mw.html.element( 'script', { 'src': src }, '' ) ); - if ( callback ) { - // Document.write is synchronous, so this is called when it's done. - // FIXME: That's a lie. doc.write isn't actually synchronous. - callback(); + skip = new Function( registry[ module ].skip ); + registry[ module ].skip = null; + if ( skip() ) { + registry[ module ].skipped = true; + registry[ module ].dependencies = []; + registry[ module ].state = 'ready'; + handlePending( module ); + return; + } + } + + // Resolves dynamic loader function and replaces it with its own results + if ( $.isFunction( registry[ module ].dependencies ) ) { + registry[ module ].dependencies = registry[ module ].dependencies(); + // Ensures the module's dependencies are always in an array + if ( typeof registry[ module ].dependencies !== 'object' ) { + registry[ module ].dependencies = [ registry[ module ].dependencies ]; + } + } + if ( $.inArray( module, resolved ) !== -1 ) { + // Module already resolved; nothing to do + return; + } + // Create unresolved if not passed in + if ( !unresolved ) { + unresolved = {}; + } + // Tracks down dependencies + deps = registry[ module ].dependencies; + len = deps.length; + for ( n = 0; n < len; n += 1 ) { + if ( $.inArray( deps[ n ], resolved ) === -1 ) { + if ( unresolved[ deps[ n ] ] ) { + throw new Error( + 'Circular reference detected: ' + module + + ' -> ' + deps[ n ] + ); + } + + // Add to unresolved + unresolved[ module ] = true; + sortDependencies( deps[ n ], resolved, unresolved ); + delete unresolved[ module ]; } } + resolved[ resolved.length ] = module; + } + + /** + * Get a list of module names that a module depends on in their proper dependency + * order. + * + * @private + * @param {string[]} module Array of string module names + * @return {Array} List of dependencies, including 'module'. + */ + function resolve( modules ) { + var resolved = []; + $.each( modules, function ( idx, module ) { + sortDependencies( module, resolved ); + } ); + return resolved; + } + + /** + * Load and execute a script with callback. + * + * @private + * @param {string} src URL to script, will be used as the src attribute in the script tag + * @return {jQuery.Promise} + */ + function addScript( src ) { + return $.ajax( { + url: src, + dataType: 'script', + // Force jQuery behaviour to be for crossDomain. Otherwise jQuery would use + // XHR for a same domain request instead of <script>, which changes the request + // headers (potentially missing a cache hit), and reduces caching in general + // since browsers cache XHR much less (if at all). And XHR means we retreive + // text, so we'd need to $.globalEval, which then messes up line numbers. + crossDomain: true, + cache: true + } ); + } + + /** + * Utility function for execute() + * + * @ignore + */ + function addLink( media, url ) { + var el = document.createElement( 'link' ); + // Support: IE + // Insert in document *before* setting href + getMarker().before( el ); + el.rel = 'stylesheet'; + if ( media && media !== 'all' ) { + el.media = media; + } + // If you end up here from an IE exception "SCRIPT: Invalid property value.", + // see #addEmbeddedCSS, bug 31676, and bug 47277 for details. + el.href = url; } /** @@ -1131,47 +1221,30 @@ * @param {string} module Module name to execute */ function execute( module ) { - var key, value, media, i, urls, cssHandle, checkCssHandles, + var key, value, media, i, urls, cssHandle, checkCssHandles, runScript, cssHandlesRegistered = false; if ( !hasOwn.call( registry, module ) ) { throw new Error( 'Module has not been registered yet: ' + module ); - } else if ( registry[module].state === 'registered' ) { - throw new Error( 'Module has not been requested from the server yet: ' + module ); - } else if ( registry[module].state === 'loading' ) { - throw new Error( 'Module has not completed loading yet: ' + module ); - } else if ( registry[module].state === 'ready' ) { - throw new Error( 'Module has already been executed: ' + module ); } - - /** - * Define loop-function here for efficiency - * and to avoid re-using badly scoped variables. - * @ignore - */ - function addLink( media, url ) { - var el = document.createElement( 'link' ); - // Support: IE - // Insert in document *before* setting href - getMarker().before( el ); - el.rel = 'stylesheet'; - if ( media && media !== 'all' ) { - el.media = media; - } - // If you end up here from an IE exception "SCRIPT: Invalid property value.", - // see #addEmbeddedCSS, bug 31676, and bug 47277 for details. - el.href = url; + if ( registry[ module ].state !== 'loaded' ) { + throw new Error( 'Module in state "' + registry[ module ].state + '" may not be executed: ' + module ); } - function runScript() { - var script, markModuleReady, nestedAddScript; + registry[ module ].state = 'executing'; + + runScript = function () { + var script, markModuleReady, nestedAddScript, legacyWait, + // Expand to include dependencies since we have to exclude both legacy modules + // and their dependencies from the legacyWait (to prevent a circular dependency). + legacyModules = resolve( mw.config.get( 'wgResourceLoaderLegacyModules', [] ) ); try { - script = registry[module].script; + script = registry[ module ].script; markModuleReady = function () { - registry[module].state = 'ready'; + registry[ module ].state = 'ready'; handlePending( module ); }; - nestedAddScript = function ( arr, callback, async, i ) { + nestedAddScript = function ( arr, callback, i ) { // Recursively call addScript() in its own callback // for each element of arr. if ( i >= arr.length ) { @@ -1180,85 +1253,94 @@ return; } - addScript( arr[i], function () { - nestedAddScript( arr, callback, async, i + 1 ); - }, async ); + addScript( arr[ i ] ).always( function () { + nestedAddScript( arr, callback, i + 1 ); + } ); }; - if ( $.isArray( script ) ) { - nestedAddScript( script, markModuleReady, registry[module].async, 0 ); - } else if ( $.isFunction( script ) ) { - registry[module].state = 'ready'; - // Pass jQuery twice so that the signature of the closure which wraps - // the script can bind both '$' and 'jQuery'. - script( $, $ ); - handlePending( module ); - } + legacyWait = ( $.inArray( module, legacyModules ) !== -1 ) + ? $.Deferred().resolve() + : mw.loader.using( legacyModules ); + + legacyWait.always( function () { + if ( $.isArray( script ) ) { + nestedAddScript( script, markModuleReady, 0 ); + } else if ( $.isFunction( script ) ) { + // Pass jQuery twice so that the signature of the closure which wraps + // the script can bind both '$' and 'jQuery'. + script( $, $ ); + markModuleReady(); + } else if ( typeof script === 'string' ) { + // Site and user modules are a legacy scripts that run in the global scope. + // This is transported as a string instead of a function to avoid needing + // to use string manipulation to undo the function wrapper. + if ( module === 'user' ) { + // Implicit dependency on the site module. Not real dependency because + // it should run after 'site' regardless of whether it succeeds or fails. + mw.loader.using( 'site' ).always( function () { + $.globalEval( script ); + markModuleReady(); + } ); + } else { + $.globalEval( script ); + markModuleReady(); + } + } else { + // Module without script + markModuleReady(); + } + } ); } catch ( e ) { // This needs to NOT use mw.log because these errors are common in production mode // and not in debug mode, such as when a symbol that should be global isn't exported - registry[module].state = 'error'; + registry[ module ].state = 'error'; mw.track( 'resourceloader.exception', { exception: e, module: module, source: 'module-execute' } ); handlePending( module ); } - } - - // This used to be inside runScript, but since that is now fired asychronously - // (after CSS is loaded) we need to set it here right away. It is crucial that - // when execute() is called this is set synchronously, otherwise modules will get - // executed multiple times as the registry will state that it isn't loading yet. - registry[module].state = 'loading'; + }; // Add localizations to message system - if ( $.isPlainObject( registry[module].messages ) ) { - mw.messages.set( registry[module].messages ); + if ( registry[ module ].messages ) { + mw.messages.set( registry[ module ].messages ); } // Initialise templates - if ( registry[module].templates ) { - mw.templates.set( module, registry[module].templates ); + if ( registry[ module ].templates ) { + mw.templates.set( module, registry[ module ].templates ); } - if ( $.isReady || registry[module].async ) { - // Make sure we don't run the scripts until all (potentially asynchronous) - // stylesheet insertions have completed. - ( function () { - var pending = 0; - checkCssHandles = function () { - // cssHandlesRegistered ensures we don't take off too soon, e.g. when - // one of the cssHandles is fired while we're still creating more handles. - if ( cssHandlesRegistered && pending === 0 && runScript ) { - runScript(); - runScript = undefined; // Revoke + // Make sure we don't run the scripts until all stylesheet insertions have completed. + ( function () { + var pending = 0; + checkCssHandles = function () { + // cssHandlesRegistered ensures we don't take off too soon, e.g. when + // one of the cssHandles is fired while we're still creating more handles. + if ( cssHandlesRegistered && pending === 0 && runScript ) { + runScript(); + runScript = undefined; // Revoke + } + }; + cssHandle = function () { + var check = checkCssHandles; + pending++; + return function () { + if ( check ) { + pending--; + check(); + check = undefined; // Revoke } }; - cssHandle = function () { - var check = checkCssHandles; - pending++; - return function () { - if ( check ) { - pending--; - check(); - check = undefined; // Revoke - } - }; - }; - }() ); - } else { - // We are in blocking mode, and so we can't afford to wait for CSS - cssHandle = function () {}; - // Run immediately - checkCssHandles = runScript; - } + }; + }() ); // Process styles (see also mw.loader.implement) // * back-compat: { <media>: css } // * back-compat: { <media>: [url, ..] } // * { "css": [css, ..] } // * { "url": { <media>: [url, ..] } } - if ( $.isPlainObject( registry[module].style ) ) { - for ( key in registry[module].style ) { - value = registry[module].style[key]; + if ( registry[ module ].style ) { + for ( key in registry[ module ].style ) { + value = registry[ module ].style[ key ]; media = undefined; if ( key !== 'url' && key !== 'css' ) { @@ -1283,10 +1365,10 @@ for ( i = 0; i < value.length; i += 1 ) { if ( key === 'bc-url' ) { // back-compat: { <media>: [url, ..] } - addLink( media, value[i] ); + addLink( media, value[ i ] ); } else if ( key === 'css' ) { // { "css": [css, ..] } - addEmbeddedCSS( value[i], cssHandle() ); + addEmbeddedCSS( value[ i ], cssHandle() ); } } // Not an array, but a regular object @@ -1294,9 +1376,9 @@ } else if ( typeof value === 'object' ) { // { "url": { <media>: [url, ..] } } for ( media in value ) { - urls = value[media]; + urls = value[ media ]; for ( i = 0; i < urls.length; i += 1 ) { - addLink( media, urls[i] ); + addLink( media, urls[ i ] ); } } } @@ -1316,34 +1398,39 @@ * @param {string|string[]} dependencies Module name or array of string module names * @param {Function} [ready] Callback to execute when all dependencies are ready * @param {Function} [error] Callback to execute when any dependency fails - * @param {boolean} [async=false] Whether to load modules asynchronously. - * Ignored (and defaulted to `true`) if the document-ready event has already occurred. */ - function request( dependencies, ready, error, async ) { + function request( dependencies, ready, error ) { // Allow calling by single module name if ( typeof dependencies === 'string' ) { - dependencies = [dependencies]; + dependencies = [ dependencies ]; } // Add ready and error callbacks if they were given if ( ready !== undefined || error !== undefined ) { - jobs[jobs.length] = { + jobs.push( { + // Narrow down the list to modules that are worth waiting for dependencies: $.grep( dependencies, function ( module ) { var state = mw.loader.getState( module ); - return state === 'registered' || state === 'loaded' || state === 'loading'; + return state === 'registered' || state === 'loaded' || state === 'loading' || state === 'executing'; } ), ready: ready, error: error - }; + } ); } $.each( dependencies, function ( idx, module ) { var state = mw.loader.getState( module ); + // Only queue modules that are still in the initial 'registered' state + // (not ones already loading, ready or error). if ( state === 'registered' && $.inArray( module, queue ) === -1 ) { - queue.push( module ); - if ( async ) { - registry[module].async = true; + // Private modules must be embedded in the page. Don't bother queuing + // these as the server will deny them anyway (T101806). + if ( registry[ module ].group === 'private' ) { + registry[ module ].state = 'error'; + handlePending( module ); + return; } + queue.push( module ); } } ); @@ -1362,7 +1449,7 @@ } a.sort(); for ( key = 0; key < a.length; key += 1 ) { - sorted[a[key]] = o[a[key]]; + sorted[ a[ key ] ] = o[ a[ key ] ]; } return sorted; } @@ -1370,6 +1457,7 @@ /** * Converts a module map of the form { foo: [ 'bar', 'baz' ], bar: [ 'baz, 'quux' ] } * to a query string of the form foo.bar,baz|bar.baz,quux + * * @private */ function buildModulesString( moduleMap ) { @@ -1378,31 +1466,26 @@ for ( prefix in moduleMap ) { p = prefix === '' ? '' : prefix + '.'; - arr.push( p + moduleMap[prefix].join( ',' ) ); + arr.push( p + moduleMap[ prefix ].join( ',' ) ); } return arr.join( '|' ); } /** - * Asynchronously append a script tag to the end of the body - * that invokes load.php + * Load modules from load.php + * * @private * @param {Object} moduleMap Module map, see #buildModulesString * @param {Object} currReqBase Object with other parameters (other than 'modules') to use in the request * @param {string} sourceLoadScript URL of load.php - * @param {boolean} async Whether to load modules asynchronously. - * Ignored (and defaulted to `true`) if the document-ready event has already occurred. */ - function doRequest( moduleMap, currReqBase, sourceLoadScript, async ) { + function doRequest( moduleMap, currReqBase, sourceLoadScript ) { var request = $.extend( { modules: buildModulesString( moduleMap ) }, currReqBase ); request = sortQuery( request ); - // Support: IE6 - // Append &* to satisfy load.php's WebRequest::checkUrlExtension test. This script - // isn't actually used in IE6, but MediaWiki enforces it in general. - addScript( sourceLoadScript + '?' + $.param( request ) + '&*', null, async ); + addScript( sourceLoadScript + '?' + $.param( request ) ); } /** @@ -1418,9 +1501,9 @@ */ function resolveIndexedDependencies( modules ) { $.each( modules, function ( idx, module ) { - if ( module[2] ) { - module[2] = $.map( module[2], function ( dep ) { - return typeof dep === 'number' ? modules[dep][0] : dep; + if ( module[ 2 ] ) { + module[ 2 ] = $.map( module[ 2 ], function ( dep ) { + return typeof dep === 'number' ? modules[ dep ][ 0 ] : dep; } ); } } ); @@ -1449,9 +1532,9 @@ */ work: function () { var reqBase, splits, maxQueryLength, q, b, bSource, bGroup, bSourceGroup, - source, concatSource, origBatch, group, g, i, modules, maxVersion, sourceLoadScript, + source, concatSource, origBatch, group, i, modules, sourceLoadScript, currReqBase, currReqBaseLength, moduleMap, l, - lastDotIndex, prefix, suffix, bytesAdded, async; + lastDotIndex, prefix, suffix, bytesAdded; // Build a list of request parameters common to all requests. reqBase = { @@ -1466,12 +1549,12 @@ // Appends a list of modules from the queue to the batch for ( q = 0; q < queue.length; q += 1 ) { // Only request modules which are registered - if ( hasOwn.call( registry, queue[q] ) && registry[queue[q]].state === 'registered' ) { + if ( hasOwn.call( registry, queue[ q ] ) && registry[ queue[ q ] ].state === 'registered' ) { // Prevent duplicate entries - if ( $.inArray( queue[q], batch ) === -1 ) { - batch[batch.length] = queue[q]; + if ( $.inArray( queue[ q ], batch ) === -1 ) { + batch[ batch.length ] = queue[ q ]; // Mark registered modules as loading - registry[queue[q]].state = 'loading'; + registry[ queue[ q ] ].state = 'loading'; } } } @@ -1507,7 +1590,7 @@ // the error) instead of all of them. mw.track( 'resourceloader.exception', { exception: err, source: 'store-eval' } ); origBatch = $.grep( origBatch, function ( module ) { - return registry[module].state === 'loading'; + return registry[ module ].state === 'loading'; } ); batch = batch.concat( origBatch ); } @@ -1527,16 +1610,16 @@ // Split batch by source and by group. for ( b = 0; b < batch.length; b += 1 ) { - bSource = registry[batch[b]].source; - bGroup = registry[batch[b]].group; + bSource = registry[ batch[ b ] ].source; + bGroup = registry[ batch[ b ] ].group; if ( !hasOwn.call( splits, bSource ) ) { - splits[bSource] = {}; + splits[ bSource ] = {}; } - if ( !hasOwn.call( splits[bSource], bGroup ) ) { - splits[bSource][bGroup] = []; + if ( !hasOwn.call( splits[ bSource ], bGroup ) ) { + splits[ bSource ][ bGroup ] = []; } - bSourceGroup = splits[bSource][bGroup]; - bSourceGroup[bSourceGroup.length] = batch[b]; + bSourceGroup = splits[ bSource ][ bGroup ]; + bSourceGroup[ bSourceGroup.length ] = batch[ b ]; } // Clear the batch - this MUST happen before we append any @@ -1548,29 +1631,22 @@ for ( source in splits ) { - sourceLoadScript = sources[source]; + sourceLoadScript = sources[ source ]; - for ( group in splits[source] ) { + for ( group in splits[ source ] ) { // Cache access to currently selected list of // modules for this group from this source. - modules = splits[source][group]; + modules = splits[ source ][ group ]; - // Calculate the highest timestamp - maxVersion = 0; - for ( g = 0; g < modules.length; g += 1 ) { - if ( registry[modules[g]].version > maxVersion ) { - maxVersion = registry[modules[g]].version; - } - } - - currReqBase = $.extend( { version: formatVersionNumber( maxVersion ) }, reqBase ); + currReqBase = $.extend( { + version: getCombinedVersion( modules ) + }, reqBase ); // For user modules append a user name to the request. if ( group === 'user' && mw.config.get( 'wgUserName' ) !== null ) { currReqBase.user = mw.config.get( 'wgUserName' ); } currReqBaseLength = $.param( currReqBase ).length; - async = true; // We may need to split up the request to honor the query string length limit, // so build it piece by piece. l = currReqBaseLength + 9; // '&modules='.length == 9 @@ -1579,42 +1655,35 @@ for ( i = 0; i < modules.length; i += 1 ) { // Determine how many bytes this module would add to the query string - lastDotIndex = modules[i].lastIndexOf( '.' ); + lastDotIndex = modules[ i ].lastIndexOf( '.' ); // If lastDotIndex is -1, substr() returns an empty string - prefix = modules[i].substr( 0, lastDotIndex ); - suffix = modules[i].slice( lastDotIndex + 1 ); + prefix = modules[ i ].substr( 0, lastDotIndex ); + suffix = modules[ i ].slice( lastDotIndex + 1 ); bytesAdded = hasOwn.call( moduleMap, prefix ) ? suffix.length + 3 // '%2C'.length == 3 - : modules[i].length + 3; // '%7C'.length == 3 + : modules[ i ].length + 3; // '%7C'.length == 3 // If the request would become too long, create a new one, // but don't create empty requests if ( maxQueryLength > 0 && !$.isEmptyObject( moduleMap ) && l + bytesAdded > maxQueryLength ) { // This request would become too long, create a new one // and fire off the old one - doRequest( moduleMap, currReqBase, sourceLoadScript, async ); + doRequest( moduleMap, currReqBase, sourceLoadScript ); moduleMap = {}; - async = true; l = currReqBaseLength + 9; mw.track( 'resourceloader.splitRequest', { maxQueryLength: maxQueryLength } ); } if ( !hasOwn.call( moduleMap, prefix ) ) { - moduleMap[prefix] = []; - } - moduleMap[prefix].push( suffix ); - if ( !registry[modules[i]].async ) { - // If this module is blocking, make the entire request blocking - // This is slightly suboptimal, but in practice mixing of blocking - // and async modules will only occur in debug mode. - async = false; + moduleMap[ prefix ] = []; } + moduleMap[ prefix ].push( suffix ); l += bytesAdded; } // If there's anything left in moduleMap, request that too if ( !$.isEmptyObject( moduleMap ) ) { - doRequest( moduleMap, currReqBase, sourceLoadScript, async ); + doRequest( moduleMap, currReqBase, sourceLoadScript ); } } } @@ -1637,7 +1706,7 @@ // Allow multiple additions if ( typeof id === 'object' ) { for ( source in id ) { - mw.loader.addSource( source, id[source] ); + mw.loader.addSource( source, id[ source ] ); } return true; } @@ -1650,14 +1719,15 @@ loadUrl = loadUrl.loadScript; } - sources[id] = loadUrl; + sources[ id ] = loadUrl; return true; }, /** - * Register a module, letting the system know about it and its - * properties. Startup modules contain calls to this function. + * Register a module, letting the system know about it and its properties. + * + * The startup modules contain calls to this method. * * When using multiple module registration by passing an array, dependencies that * are specified as references to modules within the array will be resolved before @@ -1665,7 +1735,8 @@ * * @param {string|Array} module Module name or array of arrays, each containing * a list of arguments compatible with this method - * @param {number} version Module version number as a timestamp (falls backs to 0) + * @param {string|number} version Module version hash (falls backs to empty string) + * Can also be a number (timestamp) for compatibility with MediaWiki 1.25 and earlier. * @param {string|Array|Function} dependencies One string or array of strings of module * names on which this module depends, or a function that returns that array. * @param {string} [group=null] Group which the module is in @@ -1679,11 +1750,11 @@ resolveIndexedDependencies( module ); for ( i = 0, len = module.length; i < len; i++ ) { // module is an array of module names - if ( typeof module[i] === 'string' ) { - mw.loader.register( module[i] ); + if ( typeof module[ i ] === 'string' ) { + mw.loader.register( module[ i ] ); // module is an array of arrays - } else if ( typeof module[i] === 'object' ) { - mw.loader.register.apply( mw.loader, module[i] ); + } else if ( typeof module[ i ] === 'object' ) { + mw.loader.register.apply( mw.loader, module[ i ] ); } } return; @@ -1696,8 +1767,8 @@ throw new Error( 'module already registered: ' + module ); } // List the module as registered - registry[module] = { - version: version !== undefined ? parseInt( version, 10 ) : 0, + registry[ module ] = { + version: version !== undefined ? String( version ) : '', dependencies: [], group: typeof group === 'string' ? group : null, source: typeof source === 'string' ? source : 'local', @@ -1706,11 +1777,11 @@ }; if ( typeof dependencies === 'string' ) { // Allow dependencies to be given as a single module name - registry[module].dependencies = [ dependencies ]; + registry[ module ].dependencies = [ dependencies ]; } else if ( typeof dependencies === 'object' || $.isFunction( dependencies ) ) { // Allow dependencies to be given as an array of module names // or a function which returns an array - registry[module].dependencies = dependencies; + registry[ module ].dependencies = dependencies; } }, @@ -1738,22 +1809,22 @@ * The reason css strings are not concatenated anymore is bug 31676. We now check * whether it's safe to extend the stylesheet. * - * @param {Object} [msgs] List of key/value pairs to be added to mw#messages. + * @param {Object} [messages] List of key/value pairs to be added to mw#messages. * @param {Object} [templates] List of key/value pairs to be added to mw#templates. */ - implement: function ( module, script, style, msgs, templates ) { + implement: function ( module, script, style, messages, templates ) { // Validate input if ( typeof module !== 'string' ) { throw new Error( 'module must be of type string, not ' + typeof module ); } - if ( script && !$.isFunction( script ) && !$.isArray( script ) ) { - throw new Error( 'script must be of type function or array, not ' + typeof script ); + if ( script && !$.isFunction( script ) && !$.isArray( script ) && typeof script !== 'string' ) { + throw new Error( 'script must be of type function, array, or script; not ' + typeof script ); } if ( style && !$.isPlainObject( style ) ) { throw new Error( 'style must be of type object, not ' + typeof style ); } - if ( msgs && !$.isPlainObject( msgs ) ) { - throw new Error( 'msgs must be of type object, not a ' + typeof msgs ); + if ( messages && !$.isPlainObject( messages ) ) { + throw new Error( 'messages must be of type object, not a ' + typeof messages ); } if ( templates && !$.isPlainObject( templates ) ) { throw new Error( 'templates must be of type object, not a ' + typeof templates ); @@ -1763,18 +1834,18 @@ mw.loader.register( module ); } // Check for duplicate implementation - if ( hasOwn.call( registry, module ) && registry[module].script !== undefined ) { + if ( hasOwn.call( registry, module ) && registry[ module ].script !== undefined ) { throw new Error( 'module already implemented: ' + module ); } // Attach components - registry[module].script = script || []; - registry[module].style = style || {}; - registry[module].messages = msgs || {}; - registry[module].templates = templates || {}; + registry[ module ].script = script || null; + registry[ module ].style = style || null; + registry[ module ].messages = messages || null; + registry[ module ].templates = templates || null; // The module may already have been marked as erroneous - if ( $.inArray( registry[module].state, ['error', 'missing'] ) === -1 ) { - registry[module].state = 'loaded'; - if ( allReady( registry[module].dependencies ) ) { + if ( $.inArray( registry[ module ].state, [ 'error', 'missing' ] ) === -1 ) { + registry[ module ].state = 'loaded'; + if ( allReady( registry[ module ].dependencies ) ) { execute( module ); } } @@ -1841,24 +1912,18 @@ * @param {string} [type='text/javascript'] MIME type to use if calling with a URL of an * external script or style; acceptable values are "text/css" and * "text/javascript"; if no type is provided, text/javascript is assumed. - * @param {boolean} [async] Whether to load modules asynchronously. - * Ignored (and defaulted to `true`) if the document-ready event has already occurred. - * Defaults to `true` if loading a URL, `false` otherwise. */ - load: function ( modules, type, async ) { + load: function ( modules, type ) { var filtered, l; // Validate input if ( typeof modules !== 'object' && typeof modules !== 'string' ) { throw new Error( 'modules must be a string or an array, not a ' + typeof modules ); } - // Allow calling with an external url or single dependency as a string + // Allow calling with a url or single dependency as a string if ( typeof modules === 'string' ) { - if ( /^(https?:)?\/\//.test( modules ) ) { - if ( async === undefined ) { - // Assume async for bug 34542 - async = true; - } + // "https://example.org/x.js", "http://example.org/x.js", "//example.org/x.js", "/x.js" + if ( /^(https?:)?\/?\//.test( modules ) ) { if ( type === 'text/css' ) { // Support: IE 7-8 // Use properties instead of attributes as IE throws security @@ -1871,7 +1936,7 @@ return; } if ( type === 'text/javascript' || type === undefined ) { - addScript( modules, null, async ); + addScript( modules ); return; } // Unknown type @@ -1901,7 +1966,7 @@ return; } // Since some modules are not yet ready, queue up a request. - request( filtered, undefined, undefined, async ); + request( filtered, undefined, undefined ); }, /** @@ -1915,21 +1980,21 @@ if ( typeof module === 'object' ) { for ( m in module ) { - mw.loader.state( m, module[m] ); + mw.loader.state( m, module[ m ] ); } return; } if ( !hasOwn.call( registry, module ) ) { mw.loader.register( module ); } - if ( $.inArray( state, ['ready', 'error', 'missing'] ) !== -1 - && registry[module].state !== state ) { + if ( $.inArray( state, [ 'ready', 'error', 'missing' ] ) !== -1 + && registry[ module ].state !== state ) { // Make sure pending modules depending on this one get executed if their // dependencies are now fulfilled! - registry[module].state = state; + registry[ module ].state = state; handlePending( module ); } else { - registry[module].state = state; + registry[ module ].state = state; } }, @@ -1941,10 +2006,10 @@ * in the registry. */ getVersion: function ( module ) { - if ( !hasOwn.call( registry, module ) || registry[module].version === undefined ) { + if ( !hasOwn.call( registry, module ) || registry[ module ].version === undefined ) { return null; } - return formatVersionNumber( registry[module].version ); + return registry[ module ].version; }, /** @@ -1955,10 +2020,10 @@ * in the registry. */ getState: function ( module ) { - if ( !hasOwn.call( registry, module ) || registry[module].state === undefined ) { + if ( !hasOwn.call( registry, module ) || registry[ module ].state === undefined ) { return null; } - return registry[module].state; + return registry[ module ].state; }, /** @@ -1997,6 +2062,10 @@ // Whether the store is in use on this page. enabled: null, + // Modules whose string representation exceeds 100 kB are ineligible + // for storage due to bug T66721. + MODULE_SIZE_MAX: 100000, + // The contents of the store, mapping '[module name]@[version]' keys // to module implementations. items: {}, @@ -2006,6 +2075,7 @@ /** * Construct a JSON-serializable object representing the content of the store. + * * @return {Object} Module store contents. */ toJSON: function () { @@ -2024,6 +2094,7 @@ /** * Get a key on which to vary the module cache. + * * @return {string} String of concatenated vary conditions. */ getVary: function () { @@ -2042,7 +2113,7 @@ */ getModuleKey: function ( module ) { return hasOwn.call( registry, module ) ? - ( module + '@' + registry[module].version ) : null; + ( module + '@' + registry[ module ].version ) : null; }, /** @@ -2114,7 +2185,7 @@ key = mw.loader.store.getModuleKey( module ); if ( key in mw.loader.store.items ) { mw.loader.store.stats.hits++; - return mw.loader.store.items[key]; + return mw.loader.store.items[ key ]; } mw.loader.store.stats.misses++; return false; @@ -2127,7 +2198,7 @@ * @param {Object} descriptor The module's descriptor as set in the registry */ set: function ( module, descriptor ) { - var args, key; + var args, key, src; if ( !mw.loader.store.enabled ) { return false; @@ -2141,7 +2212,7 @@ // Module failed to load descriptor.state !== 'ready' || // Unversioned, private, or site-/user-specific - ( !descriptor.version || $.inArray( descriptor.group, [ 'private', 'user', 'site' ] ) !== -1 ) || + ( !descriptor.version || $.inArray( descriptor.group, [ 'private', 'user' ] ) !== -1 ) || // Partial descriptor $.inArray( undefined, [ descriptor.script, descriptor.style, descriptor.messages, descriptor.templates ] ) !== -1 @@ -2162,8 +2233,8 @@ ]; // Attempted workaround for a possible Opera bug (bug T59567). // This regex should never match under sane conditions. - if ( /^\s*\(/.test( args[1] ) ) { - args[1] = 'function' + args[1]; + if ( /^\s*\(/.test( args[ 1 ] ) ) { + args[ 1 ] = 'function' + args[ 1 ]; mw.track( 'resourceloader.assert', { source: 'bug-T59567' } ); } } catch ( e ) { @@ -2171,7 +2242,11 @@ return; } - mw.loader.store.items[key] = 'mw.loader.implement(' + args.join( ',' ) + ');'; + src = 'mw.loader.implement(' + args.join( ',' ) + ');'; + if ( src.length > mw.loader.store.MODULE_SIZE_MAX ) { + return false; + } + mw.loader.store.items[ key ] = src; mw.loader.store.update(); }, @@ -2190,7 +2265,10 @@ module = key.slice( 0, key.indexOf( '@' ) ); if ( mw.loader.store.getModuleKey( module ) !== key ) { mw.loader.store.stats.expired++; - delete mw.loader.store.items[key]; + delete mw.loader.store.items[ key ]; + } else if ( mw.loader.store.items[ key ].length > mw.loader.store.MODULE_SIZE_MAX ) { + // This value predates the enforcement of a size limit on cached modules. + delete mw.loader.store.items[ key ]; } } }, @@ -2319,7 +2397,7 @@ var v, attrName, s = '<' + name; for ( attrName in attrs ) { - v = attrs[attrName]; + v = attrs[ attrName ]; // Convert name=true, to name=name if ( v === true ) { v = attrName; @@ -2366,6 +2444,7 @@ /** * Wrapper object for raw HTML passed to mw.html.element(). + * * @class mw.html.Raw */ Raw: function ( value ) { @@ -2374,6 +2453,7 @@ /** * Wrapper object for CDATA element contents passed to mw.html.element() + * * @class mw.html.Cdata */ Cdata: function ( value ) { @@ -2388,6 +2468,9 @@ tokens: new Map() }, + // OOUI widgets specific to MediaWiki + widgets: {}, + /** * Registry and firing of events. * @@ -2440,12 +2523,13 @@ */ return function ( name ) { var list = hasOwn.call( lists, name ) ? - lists[name] : - lists[name] = $.Callbacks( 'memory' ); + lists[ name ] : + lists[ name ] = $.Callbacks( 'memory' ); return { /** * Register a hook handler + * * @param {Function...} handler Function to bind. * @chainable */ @@ -2453,6 +2537,7 @@ /** * Unregister a hook handler + * * @param {Function...} handler Function to unbind. * @chainable */ @@ -2460,6 +2545,7 @@ /** * Run a hook. + * * @param {Mixed...} data * @chainable */ @@ -2514,10 +2600,33 @@ } } - // subscribe to error streams + // Subscribe to error streams mw.trackSubscribe( 'resourceloader.exception', log ); mw.trackSubscribe( 'resourceloader.assert', log ); + /** + * Fired when all modules associated with the page have finished loading. + * + * @event resourceloader_loadEnd + * @member mw.hook + */ + $( function () { + var loading = $.grep( mw.loader.getModuleNames(), function ( module ) { + return mw.loader.getState( module ) === 'loading'; + } ); + // In order to use jQuery.when (which stops early if one of the promises got rejected) + // cast any loading failures into successes. We only need a callback, not the module. + loading = $.map( loading, function ( module ) { + return mw.loader.using( module ).then( null, function () { + return $.Deferred().resolve(); + } ); + } ); + $.when.apply( $, loading ).then( function () { + mwPerformance.mark( 'mwLoadEnd' ); + mw.hook( 'resourceloader.loadEnd' ).fire(); + } ); + } ); + // Attach to window and globally alias window.mw = window.mediaWiki = mw; }( jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.log.js b/resources/src/mediawiki/mediawiki.log.js index ad68967a..053fb1a1 100644 --- a/resources/src/mediawiki/mediawiki.log.js +++ b/resources/src/mediawiki/mediawiki.log.js @@ -79,6 +79,7 @@ // Restore original methods mw.log.warn = original.warn; + mw.log.error = original.error; mw.log.deprecate = original.deprecate; }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.notification.common.css b/resources/src/mediawiki/mediawiki.notification.common.css new file mode 100644 index 00000000..a1309c29 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.notification.common.css @@ -0,0 +1,7 @@ +.mw-notification-area { + position: absolute; +} + +.mw-notification-area-floating { + position: fixed; +} diff --git a/resources/src/mediawiki/mediawiki.notification.css b/resources/src/mediawiki/mediawiki.notification.css index ae399ce7..632ae821 100644 --- a/resources/src/mediawiki/mediawiki.notification.css +++ b/resources/src/mediawiki/mediawiki.notification.css @@ -1,5 +1,4 @@ .mw-notification-area { - position: absolute; top: 0; right: 0; padding: 1em 1em 0 0; @@ -8,10 +7,6 @@ z-index: 10000; } -.mw-notification-area-floating { - position: fixed; -} - .mw-notification { padding: 0.25em 1em; margin-bottom: 0.5em; @@ -25,3 +20,13 @@ .mw-notification-title { font-weight: bold; } + +.mw-notification-type-warn { + border-color: #F5BE00; /* yellow */ + background-color: #FFFFE8; +} + +.mw-notification-type-error { + border-color: #EB3941; /* red */ + background-color: #FFF8F8; +} diff --git a/resources/src/mediawiki/mediawiki.notification.js b/resources/src/mediawiki/mediawiki.notification.js index c46bd278..eeb7bb37 100644 --- a/resources/src/mediawiki/mediawiki.notification.js +++ b/resources/src/mediawiki/mediawiki.notification.js @@ -39,6 +39,12 @@ } } + if ( options.type ) { + // Sanitize options.type + options.type = options.type.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' ); + $notification.addClass( 'mw-notification-type-' + options.type ); + } + if ( options.title ) { $notificationTitle = $( '<div class="mw-notification-title"></div>' ) .text( options.title ) @@ -356,7 +362,7 @@ $notifications.each( function () { var notif = $( this ).data( 'mw.notification' ); if ( notif ) { - notif[fn](); + notif[ fn ](); } } ); } @@ -364,6 +370,7 @@ /** * Initialisation. * Must only be called once, and not before the document is ready. + * * @ignore */ function init() { @@ -414,6 +421,7 @@ /** * Pause auto-hide timers for all notifications. * Notifications will not auto-hide until resume is called. + * * @see mw.Notification#pause */ pause: function () { @@ -479,11 +487,16 @@ * - title: * An optional title for the notification. Will be displayed above the * content. Usually in bold. + * + * - type: + * An optional string for the type of the message used for styling: + * Examples: 'info', 'warn', 'error'. */ defaults: { autoHide: true, tag: false, - title: undefined + title: undefined, + type: false }, /** diff --git a/resources/src/mediawiki/mediawiki.notify.js b/resources/src/mediawiki/mediawiki.notify.js index c1e1dabf..0f3a0867 100644 --- a/resources/src/mediawiki/mediawiki.notify.js +++ b/resources/src/mediawiki/mediawiki.notify.js @@ -6,8 +6,9 @@ /** * @see mw.notification#notify - * @param message - * @param options + * @see mw.notification#defaults + * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message + * @param {Object} options See mw.notification#defaults for details. * @return {jQuery.Promise} */ mw.notify = function ( message, options ) { diff --git a/resources/src/mediawiki/mediawiki.searchSuggest.js b/resources/src/mediawiki/mediawiki.searchSuggest.js index 7b7ccf3f..6c7484e2 100644 --- a/resources/src/mediawiki/mediawiki.searchSuggest.js +++ b/resources/src/mediawiki/mediawiki.searchSuggest.js @@ -2,8 +2,22 @@ * Add search suggestions to the search form. */ ( function ( mw, $ ) { + mw.searchSuggest = { + request: function ( api, query, response, maxRows ) { + return api.get( { + action: 'opensearch', + search: query, + namespace: 0, + limit: maxRows, + suggest: '' + } ).done( function ( data ) { + response( data[ 1 ] ); + } ); + } + }; + $( function () { - var api, map, resultRenderCache, searchboxesSelectors, + var api, map, searchboxesSelectors, // Region where the suggestions box will appear directly below // (using the same width). Can be a container element or the input // itself, depending on what suits best in the environment. @@ -12,19 +26,20 @@ // element (not the search form, as that would leave the buttons // vertically between the input and the suggestions). $searchRegion = $( '#simpleSearch, #searchInput' ).first(), - $searchInput = $( '#searchInput' ); + $searchInput = $( '#searchInput' ), + previousSearchText = $searchInput.val(); // Compatibility map map = { // SimpleSearch is broken in Opera < 9.6 - opera: [['>=', 9.6]], + opera: [ [ '>=', 9.6 ] ], // Older Konquerors are unable to position the suggestions correctly (bug 50805) - konqueror: [['>=', '4.11']], + konqueror: [ [ '>=', '4.11' ] ], docomo: false, blackberry: false, // Support for iOS 6 or higher. It has not been tested on iOS 5 or lower - ipod: [['>=', 6]], - iphone: [['>=', 6]] + ipod: [ [ '>=', 6 ] ], + iphone: [ [ '>=', 6 ] ] }; if ( !$.client.test( map ) ) { @@ -32,52 +47,101 @@ } // Compute form data for search suggestions functionality. - function computeResultRenderCache( context ) { + function getFormData( context ) { var $form, baseHref, linkParams; - // Compute common parameters for links' hrefs - $form = context.config.$region.closest( 'form' ); + if ( !context.formData ) { + // Compute common parameters for links' hrefs + $form = context.config.$region.closest( 'form' ); + + baseHref = $form.attr( 'action' ); + baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?'; + + linkParams = $form.serializeObject(); + + context.formData = { + textParam: context.data.$textbox.attr( 'name' ), + linkParams: linkParams, + baseHref: baseHref + }; + } + + return context.formData; + } - baseHref = $form.attr( 'action' ); - baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?'; + /** + * Callback that's run when the user changes the search input text + * 'this' is the search input box (jQuery object) + * + * @ignore + */ + function onBeforeUpdate() { + var searchText = this.val(); + + if ( searchText && searchText !== previousSearchText ) { + mw.track( 'mediawiki.searchSuggest', { + action: 'session-start' + } ); + } + previousSearchText = searchText; + } - linkParams = $form.serializeObject(); + /** + * Callback that's run when suggestions have been updated either from the cache or the API + * 'this' is the search input box (jQuery object) + * + * @ignore + */ + function onAfterUpdate() { + var context = this.data( 'suggestionsContext' ); - return { - textParam: context.data.$textbox.attr( 'name' ), - linkParams: linkParams, - baseHref: baseHref - }; + mw.track( 'mediawiki.searchSuggest', { + action: 'impression-results', + numberOfResults: context.config.suggestions.length, + // FIXME: when other types of search become available change this value accordingly + // See the API call below (opensearch = prefix) + resultSetType: 'prefix' + } ); } // The function used to render the suggestions. function renderFunction( text, context ) { - if ( !resultRenderCache ) { - resultRenderCache = computeResultRenderCache( context ); - } + var formData = getFormData( context ); // linkParams object is modified and reused - resultRenderCache.linkParams[ resultRenderCache.textParam ] = text; + formData.linkParams[ formData.textParam ] = text; // this is the container <div>, jQueryfied this.text( text ) .wrap( $( '<a>' ) - .attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) ) + .attr( 'href', formData.baseHref + $.param( formData.linkParams ) ) .attr( 'title', text ) .addClass( 'mw-searchSuggest-link' ) ); } - function specialRenderFunction( query, context ) { - var $el = this; + // The function used when the user makes a selection + function selectFunction( $input ) { + var context = $input.data( 'suggestionsContext' ), + text = $input.val(); - if ( !resultRenderCache ) { - resultRenderCache = computeResultRenderCache( context ); - } + mw.track( 'mediawiki.searchSuggest', { + action: 'click-result', + numberOfResults: context.config.suggestions.length, + clickIndex: context.config.suggestions.indexOf( text ) + 1 + } ); + + // allow the form to be submitted + return true; + } + + function specialRenderFunction( query, context ) { + var $el = this, + formData = getFormData( context ); // linkParams object is modified and reused - resultRenderCache.linkParams[ resultRenderCache.textParam ] = query; + formData.linkParams[ formData.textParam ] = query; if ( $el.children().length === 0 ) { $el @@ -96,11 +160,11 @@ } if ( $el.parent().hasClass( 'mw-searchSuggest-link' ) ) { - $el.parent().attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) + '&fulltext=1' ); + $el.parent().attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' ); } else { $el.wrap( $( '<a>' ) - .attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) + '&fulltext=1' ) + .attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' ) .addClass( 'mw-searchSuggest-link' ) ); } @@ -120,22 +184,14 @@ $( searchboxesSelectors.join( ', ' ) ) .suggestions( { fetch: function ( query, response, maxRows ) { - var node = this[0]; + var node = this[ 0 ]; api = api || new mw.Api(); - $.data( node, 'request', api.get( { - action: 'opensearch', - search: query, - namespace: 0, - limit: maxRows, - suggest: '' - } ).done( function ( data ) { - response( data[ 1 ] ); - } ) ); + $.data( node, 'request', mw.searchSuggest.request( api, query, response, maxRows ) ); }, cancel: function () { - var node = this[0], + var node = this[ 0 ], request = $.data( node, 'request' ); if ( request ) { @@ -177,8 +233,16 @@ return; } - // Special suggestions functionality for skin-provided search box + // Special suggestions functionality and tracking for skin-provided search box $searchInput.suggestions( { + update: { + before: onBeforeUpdate, + after: onAfterUpdate + }, + result: { + render: renderFunction, + select: selectFunction + }, special: { render: specialRenderFunction, select: function ( $input ) { @@ -190,8 +254,17 @@ $region: $searchRegion } ); - // If the form includes any fallback fulltext search buttons, remove them - $searchInput.closest( 'form' ).find( '.mw-fallbackSearchButton' ).remove(); + $searchInput.closest( 'form' ) + // track the form submit event + .on( 'submit', function () { + var context = $searchInput.data( 'suggestionsContext' ); + mw.track( 'mediawiki.searchSuggest', { + action: 'submit-form', + numberOfResults: context.config.suggestions.length + } ); + } ) + // If the form includes any fallback fulltext search buttons, remove them + .find( '.mw-fallbackSearchButton' ).remove(); } ); }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.startUp.js b/resources/src/mediawiki/mediawiki.startUp.js deleted file mode 100644 index 028784c2..00000000 --- a/resources/src/mediawiki/mediawiki.startUp.js +++ /dev/null @@ -1,11 +0,0 @@ -/*! - * Auto-register from pre-loaded startup scripts - */ -( function ( $ ) { - 'use strict'; - - if ( $.isFunction( window.startUp ) ) { - window.startUp(); - window.startUp = undefined; - } -}( jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.storage.js b/resources/src/mediawiki/mediawiki.storage.js new file mode 100644 index 00000000..39583926 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.storage.js @@ -0,0 +1,58 @@ +( function ( mw ) { + 'use strict'; + + /** + * Library for storing device specific information. It should be used for storing simple + * strings and is not suitable for storing large chunks of data. + * + * @class mw.storage + * @singleton + */ + mw.storage = { + + localStorage: window.localStorage, + + /** + * Retrieve value from device storage. + * + * @param {string} key Key of item to retrieve + * @return {string|boolean} False when localStorage not available, otherwise string + */ + get: function ( key ) { + try { + return mw.storage.localStorage.getItem( key ); + } catch ( e ) {} + return false; + }, + + /** + * Set a value in device storage. + * + * @param {string} key Key name to store under + * @param {string} value Value to be stored + * @returns {boolean} Whether the save succeeded or not + */ + set: function ( key, value ) { + try { + mw.storage.localStorage.setItem( key, value ); + return true; + } catch ( e ) {} + return false; + }, + + /** + * Remove a value from device storage. + * + * @param {string} key Key of item to remove + * @returns {boolean} Whether the save succeeded or not + */ + remove: function ( key ) { + try { + mw.storage.localStorage.removeItem( key ); + return true; + } catch ( e ) {} + return false; + } + }; + +}( mediaWiki ) ); diff --git a/resources/src/mediawiki/mediawiki.template.js b/resources/src/mediawiki/mediawiki.template.js index 61bbb0d7..c3db69e6 100644 --- a/resources/src/mediawiki/mediawiki.template.js +++ b/resources/src/mediawiki/mediawiki.template.js @@ -17,7 +17,7 @@ if ( !compiler.compile ) { throw new Error( 'Compiler must implement compile method.' ); } - compilers[name] = compiler; + compilers[ name ] = compiler; }, /** @@ -63,12 +63,12 @@ var compiledTemplate, compilerName = this.getCompilerName( templateName ); - if ( !compiledTemplates[moduleName] ) { - compiledTemplates[moduleName] = {}; + if ( !compiledTemplates[ moduleName ] ) { + compiledTemplates[ moduleName ] = {}; } compiledTemplate = this.compile( templateBody, compilerName ); - compiledTemplates[moduleName][ templateName ] = compiledTemplate; + compiledTemplates[ moduleName ][ templateName ] = compiledTemplate; return compiledTemplate; }, diff --git a/resources/src/mediawiki/mediawiki.template.mustache.js b/resources/src/mediawiki/mediawiki.template.mustache.js index dcc3842b..624986a9 100644 --- a/resources/src/mediawiki/mediawiki.template.mustache.js +++ b/resources/src/mediawiki/mediawiki.template.mustache.js @@ -5,7 +5,7 @@ compile: function ( src ) { return { render: function ( data ) { - return $.parseHTML( Mustache.render( src, data ) ); + return $( $.parseHTML( Mustache.render( src, data ) ) ); } }; } diff --git a/resources/src/mediawiki/mediawiki.template.regexp.js b/resources/src/mediawiki/mediawiki.template.regexp.js new file mode 100644 index 00000000..3ec0a1f5 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.template.regexp.js @@ -0,0 +1,15 @@ +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, '' ) + ); + } + }; + } +} ); diff --git a/resources/src/mediawiki/mediawiki.toc.js b/resources/src/mediawiki/mediawiki.toc.js index 45338ea7..78627fca 100644 --- a/resources/src/mediawiki/mediawiki.toc.js +++ b/resources/src/mediawiki/mediawiki.toc.js @@ -15,25 +15,19 @@ $tocList.slideDown( 'fast' ); $tocToggleLink.text( mw.msg( 'hidetoc' ) ); $toc.removeClass( 'tochidden' ); - $.cookie( 'mw_hidetoc', null, { - expires: 30, - path: '/' - } ); + mw.cookie.set( 'hidetoc', null ); } else { $tocList.slideUp( 'fast' ); $tocToggleLink.text( mw.msg( 'showtoc' ) ); $toc.addClass( 'tochidden' ); - $.cookie( 'mw_hidetoc', '1', { - expires: 30, - path: '/' - } ); + mw.cookie.set( 'hidetoc', '1' ); } } // Only add it if there is a complete TOC and it doesn't // have a toggle added already if ( $toc.length && $tocTitle.length && $tocList.length && !$tocToggleLink.length ) { - hideToc = $.cookie( 'mw_hidetoc' ) === '1'; + hideToc = mw.cookie.get( 'hidetoc' ) === '1'; $tocToggleLink = $( '<a href="#" id="togglelink"></a>' ) .text( hideToc ? mw.msg( 'showtoc' ) : mw.msg( 'hidetoc' ) ) diff --git a/resources/src/mediawiki/mediawiki.user.js b/resources/src/mediawiki/mediawiki.user.js index 817c856c..b4baa66c 100644 --- a/resources/src/mediawiki/mediawiki.user.js +++ b/resources/src/mediawiki/mediawiki.user.js @@ -16,7 +16,7 @@ */ function getUserInfo( info ) { var api; - if ( !deferreds[info] ) { + if ( !deferreds[ info ] ) { deferreds.rights = $.Deferred(); deferreds.groups = $.Deferred(); @@ -38,13 +38,13 @@ } - return deferreds[info].promise(); + return deferreds[ info ].promise(); } // Map from numbers 0-255 to a hex string (with padding) for ( i = 0; i < 256; i++ ) { // Padding: Add a full byte (0x100, 256) and strip the extra character - byteToHex[i] = ( i + 256 ).toString( 16 ).slice( 1 ); + byteToHex[ i ] = ( i + 256 ).toString( 16 ).slice( 1 ); } // mw.user with the properties options and tokens gets defined in mediawiki.js. @@ -89,12 +89,12 @@ if ( ( i & 3 ) === 0 ) { r = Math.random() * 0x100000000; } - rnds[i] = r >>> ( ( i & 3 ) << 3 ) & 255; + rnds[ i ] = r >>> ( ( i & 3 ) << 3 ) & 255; } } // Convert from number to hex for ( i = 0; i < 8; i++ ) { - hexRnds[i] = byteToHex[rnds[i]]; + hexRnds[ i ] = byteToHex[ rnds[ i ] ]; } // Concatenation of two random integers with entrophy n and m @@ -159,10 +159,10 @@ * @return {string} Random session ID */ sessionId: function () { - var sessionId = $.cookie( 'mediaWiki.user.sessionId' ); - if ( sessionId === undefined || sessionId === null ) { + var sessionId = mw.cookie.get( 'mwuser-sessionId' ); + if ( sessionId === null ) { sessionId = mw.user.generateRandomSessionId(); - $.cookie( 'mediaWiki.user.sessionId', sessionId, { expires: null, path: '/' } ); + mw.cookie.set( 'mwuser-sessionId', sessionId, { expires: null } ); } return sessionId; }, @@ -208,14 +208,14 @@ expires: 30 }, options || {} ); - cookie = $.cookie( 'mediaWiki.user.bucket:' + key ); + cookie = mw.cookie.get( 'mwuser-bucket:' + key ); // Bucket information is stored as 2 integers, together as version:bucket like: "1:2" if ( typeof cookie === 'string' && cookie.length > 2 && cookie.indexOf( ':' ) !== -1 ) { parts = cookie.split( ':' ); - if ( parts.length > 1 && Number( parts[0] ) === options.version ) { - version = Number( parts[0] ); - bucket = String( parts[1] ); + if ( parts.length > 1 && Number( parts[ 0 ] ) === options.version ) { + version = Number( parts[ 0 ] ); + bucket = String( parts[ 1 ] ); } } @@ -229,7 +229,7 @@ // Find range range = 0; for ( k in options.buckets ) { - range += options.buckets[k]; + range += options.buckets[ k ]; } // Select random value within range @@ -239,16 +239,16 @@ total = 0; for ( k in options.buckets ) { bucket = k; - total += options.buckets[k]; + total += options.buckets[ k ]; if ( total >= rand ) { break; } } - $.cookie( - 'mediaWiki.user.bucket:' + key, + mw.cookie.set( + 'mwuser-bucket:' + key, version + ':' + bucket, - { path: '/', expires: Number( options.expires ) } + { expires: Number( options.expires ) * 86400 } ); } diff --git a/resources/src/mediawiki/mediawiki.userSuggest.js b/resources/src/mediawiki/mediawiki.userSuggest.js index 3964f0b2..02a90fc3 100644 --- a/resources/src/mediawiki/mediawiki.userSuggest.js +++ b/resources/src/mediawiki/mediawiki.userSuggest.js @@ -6,7 +6,7 @@ config = { fetch: function ( userInput, response, maxRows ) { - var node = this[0]; + var node = this[ 0 ]; api = api || new mw.Api(); @@ -15,7 +15,7 @@ list: 'allusers', // Prefix of list=allusers is case sensitive. Normalise first // character to uppercase so that "fo" may yield "Foo". - auprefix: userInput.charAt( 0 ).toUpperCase() + userInput.slice( 1 ), + auprefix: userInput[ 0 ].toUpperCase() + userInput.slice( 1 ), aulimit: maxRows } ).done( function ( data ) { var users = $.map( data.query.allusers, function ( userObj ) { @@ -25,7 +25,7 @@ } ) ); }, cancel: function () { - var node = this[0], + var node = this[ 0 ], request = $.data( node, 'request' ); if ( request ) { diff --git a/resources/src/mediawiki/mediawiki.util.js b/resources/src/mediawiki/mediawiki.util.js index 6723e5f9..50fd0b42 100644 --- a/resources/src/mediawiki/mediawiki.util.js +++ b/resources/src/mediawiki/mediawiki.util.js @@ -33,7 +33,7 @@ ]; for ( i = 0, l = selectors.length; i < l; i++ ) { - $node = $( selectors[i] ); + $node = $( selectors[ i ] ); if ( $node.length ) { return $node.first(); } @@ -82,6 +82,7 @@ .replace( /%29/g, ')' ) .replace( /%2C/g, ',' ) .replace( /%2F/g, '/' ) + .replace( /%7E/g, '~' ) .replace( /%3A/g, ':' ); }, @@ -111,8 +112,8 @@ * For index.php use `mw.config.get( 'wgScript' )`. * * @since 1.18 - * @param str string Name of script (eg. 'api'), defaults to 'index' - * @return string Address to script (eg. '/w/api.php' ) + * @param {string} str Name of script (e.g. 'api'), defaults to 'index' + * @return {string} Address to script (e.g. '/w/api.php' ) */ wikiScript: function ( str ) { str = str || 'index'; @@ -159,12 +160,12 @@ url = location.href; } // Get last match, stop at hash - var re = new RegExp( '^[^#]*[&?]' + $.escapeRE( param ) + '=([^&#]*)' ), + var re = new RegExp( '^[^#]*[&?]' + mw.RegExp.escape( param ) + '=([^&#]*)' ), m = re.exec( url ); if ( m ) { // Beware that decodeURIComponent is not required to understand '+' // by spec, as encodeURIComponent does not produce it. - return decodeURIComponent( m[1].replace( /\+/g, '%20' ) ); + return decodeURIComponent( m[ 1 ].replace( /\+/g, '%20' ) ); } return null; }, @@ -298,7 +299,7 @@ // Error: Invalid nextnode nextnode = undefined; } - if ( nextnode && ( nextnode.length !== 1 || nextnode[0].parentNode !== $ul[0] ) ) { + if ( nextnode && ( nextnode.length !== 1 || nextnode[ 0 ].parentNode !== $ul[ 0 ] ) ) { // Error: nextnode must resolve to a single node // Error: nextnode must have the associated <ul> as its parent nextnode = undefined; @@ -317,7 +318,7 @@ // to get a localized access key label (bug 67946). $link.updateTooltipAccessKeys(); - return $item[0]; + return $item[ 0 ]; }, /** |