From: Timo Tijhof Date: Wed, 9 May 2018 17:40:57 +0000 (+0100) Subject: resources: Move various single-file mediawiki.* modules to src/ X-Git-Tag: 1.34.0-rc.0~5473^2 X-Git-Url: http://git.cyclocoop.org/data/Luca_Pacioli_%28Gemaelde%29.jpeg?a=commitdiff_plain;h=667f4edb86a9f7c499796646fa099f4922604773;p=lhc%2Fweb%2Fwiklou.git resources: Move various single-file mediawiki.* modules to src/ This moves all files belonging to a 'mediawiki.*' module containing only a single JavaScript file with no references to other files. * Reduce clutter in src/mediawiki/. * Make these files and modules easier to discover and associate. Bug: T193826 Change-Id: I677edac3b5e9d02208c87164382c97035409df63 --- diff --git a/resources/Resources.php b/resources/Resources.php index 2a343c970d..d41352e5aa 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1125,7 +1125,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.inspect' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.inspect.js', + 'scripts' => 'resources/src/mediawiki.inspect.js', 'dependencies' => [ 'mediawiki.String', 'mediawiki.RegExp', @@ -1171,7 +1171,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.notify' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.notify.js', + 'scripts' => 'resources/src/mediawiki.notify.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.notification.convertmessagebox' => [ @@ -1188,11 +1188,11 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.RegExp' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.RegExp.js', + 'scripts' => 'resources/src/mediawiki.RegExp.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.String' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.String.js', + 'scripts' => 'resources/src/mediawiki.String.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.pager.tablePager' => [ @@ -1213,7 +1213,7 @@ return [ ], ], 'mediawiki.storage' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.storage.js', + 'scripts' => 'resources/src/mediawiki.storage.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.Title' => [ @@ -1368,7 +1368,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.user' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.user.js', + 'scripts' => 'resources/src/mediawiki.user.js', 'dependencies' => [ 'mediawiki.api', 'mediawiki.api.user', @@ -1379,7 +1379,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.userSuggest' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.userSuggest.js', + 'scripts' => 'resources/src/mediawiki.userSuggest.js', 'dependencies' => [ 'jquery.suggestions', 'mediawiki.api' @@ -1387,7 +1387,7 @@ return [ ], 'mediawiki.util' => [ 'class' => ResourceLoaderMediaWikiUtilModule::class, - 'scripts' => 'resources/src/mediawiki/mediawiki.util.js', + 'scripts' => 'resources/src/mediawiki.util.js', 'dependencies' => [ 'jquery.accessKeyLabel', 'mediawiki.RegExp', @@ -1396,7 +1396,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.viewport' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.viewport.js', + 'scripts' => 'resources/src/mediawiki.viewport.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.checkboxtoggle' => [ @@ -1406,7 +1406,7 @@ return [ 'styles' => 'resources/src/mediawiki/mediawiki.checkboxtoggle.css', ], 'mediawiki.cookie' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.cookie.js', + 'scripts' => 'resources/src/mediawiki.cookie.js', 'dependencies' => 'jquery.cookie', 'targets' => [ 'desktop', 'mobile' ], ], @@ -1417,7 +1417,7 @@ return [ 'dependencies' => 'jquery.textSelection', ], 'mediawiki.experiments' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.experiments.js', + 'scripts' => 'resources/src/mediawiki.experiments.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.editfont.styles' => [ @@ -1425,7 +1425,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.visibleTimeout' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.visibleTimeout.js', + 'scripts' => 'resources/src/mediawiki.visibleTimeout.js', 'targets' => [ 'desktop', 'mobile' ], ], diff --git a/resources/src/mediawiki.RegExp.js b/resources/src/mediawiki.RegExp.js new file mode 100644 index 0000000000..91cdc2d155 --- /dev/null +++ b/resources/src/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' ); // eslint-disable-line no-useless-escape + } + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.String.js b/resources/src/mediawiki.String.js new file mode 100644 index 0000000000..5d9bef0632 --- /dev/null +++ b/resources/src/mediawiki.String.js @@ -0,0 +1,205 @@ +( function () { + + /** + * @class mw.String + * @singleton + */ + + /** + * Calculate the byte length of a string (accounting for UTF-8). + * + * @author Jan Paul Posma, 2011 + * @author Timo Tijhof, 2012 + * @author David Chan, 2013 + * + * @param {string} str + * @return {number} + */ + function byteLength( str ) { + // This basically figures out how many bytes a UTF-16 string (which is what js sees) + // will take in UTF-8 by replacing a 2 byte character with 2 *'s, etc, and counting that. + // Note, surrogate (\uD800-\uDFFF) characters are counted as 2 bytes, since there's two of them + // and the actual character takes 4 bytes in UTF-8 (2*2=4). Might not work perfectly in + // edge cases such as illegal sequences, but that should never happen. + + // https://en.wikipedia.org/wiki/UTF-8#Description + // The mapping from UTF-16 code units to UTF-8 bytes is as follows: + // > Range 0000-007F: codepoints that become 1 byte of UTF-8 + // > Range 0080-07FF: codepoints that become 2 bytes of UTF-8 + // > Range 0800-D7FF: codepoints that become 3 bytes of UTF-8 + // > Range D800-DFFF: Surrogates (each pair becomes 4 bytes of UTF-8) + // > Range E000-FFFF: codepoints that become 3 bytes of UTF-8 (continued) + + return str + .replace( /[\u0080-\u07FF\uD800-\uDFFF]/g, '**' ) + .replace( /[\u0800-\uD7FF\uE000-\uFFFF]/g, '***' ) + .length; + } + + /** + * Calculate the character length of a string (accounting for UTF-16 surrogates). + * + * @param {string} str + * @return {number} + */ + function codePointLength( str ) { + return str + // Low surrogate + high surrogate pairs represent one character (codepoint) each + .replace( /[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '*' ) + .length; + } + + // Like String#charAt, but return the pair of UTF-16 surrogates for characters outside of BMP. + function codePointAt( string, offset, backwards ) { + // We don't need to check for offsets at the beginning or end of string, + // String#slice will simply return a shorter (or empty) substring. + var maybePair = backwards ? + string.slice( offset - 1, offset + 1 ) : + string.slice( offset, offset + 2 ); + if ( /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test( maybePair ) ) { + return maybePair; + } else { + return string.charAt( offset ); + } + } + + function trimLength( safeVal, newVal, length, lengthFn ) { + var startMatches, endMatches, matchesLen, inpParts, chopOff, oldChar, newChar, + oldVal = safeVal; + + // Run the hook if one was provided, but only on the length + // assessment. The value itself is not to be affected by the hook. + if ( lengthFn( newVal ) <= length ) { + // Limit was not reached, just remember the new value + // and let the user continue. + return { + newVal: newVal, + trimmed: false + }; + } + + // Current input is longer than the active limit. + // Figure out what was added and limit the addition. + startMatches = 0; + endMatches = 0; + + // It is important that we keep the search within the range of + // the shortest string's length. + // Imagine a user adds text that matches the end of the old value + // (e.g. "foo" -> "foofoo"). startMatches would be 3, but without + // limiting both searches to the shortest length, endMatches would + // also be 3. + matchesLen = Math.min( newVal.length, oldVal.length ); + + // Count same characters from the left, first. + // (if "foo" -> "foofoo", assume addition was at the end). + while ( startMatches < matchesLen ) { + oldChar = codePointAt( oldVal, startMatches, false ); + newChar = codePointAt( newVal, startMatches, false ); + if ( oldChar !== newChar ) { + break; + } + startMatches += oldChar.length; + } + + while ( endMatches < ( matchesLen - startMatches ) ) { + oldChar = codePointAt( oldVal, oldVal.length - 1 - endMatches, true ); + newChar = codePointAt( newVal, newVal.length - 1 - endMatches, true ); + if ( oldChar !== newChar ) { + break; + } + endMatches += oldChar.length; + } + + inpParts = [ + // Same start + newVal.slice( 0, startMatches ), + // Inserted content + newVal.slice( startMatches, newVal.length - endMatches ), + // Same end + newVal.slice( newVal.length - endMatches ) + ]; + + // Chop off characters from the end of the "inserted content" string + // until the limit is statisfied. + // Make sure to stop when there is nothing to slice (T43450). + while ( lengthFn( inpParts.join( '' ) ) > length && inpParts[ 1 ].length > 0 ) { + // Do not chop off halves of surrogate pairs + chopOff = /[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test( inpParts[ 1 ] ) ? 2 : 1; + inpParts[ 1 ] = inpParts[ 1 ].slice( 0, -chopOff ); + } + + return { + newVal: inpParts.join( '' ), + // For pathological lengthFn() that always returns a length greater than the limit, we might have + // ended up not trimming - check for this case to avoid infinite loops + trimmed: newVal !== inpParts.join( '' ) + }; + } + + /** + * Utility function to trim down a string, based on byteLimit + * and given a safe start position. It supports insertion anywhere + * in the string, so "foo" to "fobaro" if limit is 4 will result in + * "fobo", not "foba". Basically emulating the native maxlength by + * reconstructing where the insertion occurred. + * + * @param {string} safeVal Known value that was previously returned by this + * function, if none, pass empty string. + * @param {string} newVal New value that may have to be trimmed down. + * @param {number} byteLimit Number of bytes the value may be in size. + * @param {Function} [filterFn] Function to call on the string before assessing the length. + * @return {Object} + * @return {string} return.newVal + * @return {boolean} return.trimmed + */ + function trimByteLength( safeVal, newVal, byteLimit, filterFn ) { + var lengthFn; + if ( filterFn ) { + lengthFn = function ( val ) { + return byteLength( filterFn( val ) ); + }; + } else { + lengthFn = byteLength; + } + + return trimLength( safeVal, newVal, byteLimit, lengthFn ); + } + + /** + * Utility function to trim down a string, based on codePointLimit + * and given a safe start position. It supports insertion anywhere + * in the string, so "foo" to "fobaro" if limit is 4 will result in + * "fobo", not "foba". Basically emulating the native maxlength by + * reconstructing where the insertion occurred. + * + * @param {string} safeVal Known value that was previously returned by this + * function, if none, pass empty string. + * @param {string} newVal New value that may have to be trimmed down. + * @param {number} codePointLimit Number of characters the value may be in size. + * @param {Function} [filterFn] Function to call on the string before assessing the length. + * @return {Object} + * @return {string} return.newVal + * @return {boolean} return.trimmed + */ + function trimCodePointLength( safeVal, newVal, codePointLimit, filterFn ) { + var lengthFn; + if ( filterFn ) { + lengthFn = function ( val ) { + return codePointLength( filterFn( val ) ); + }; + } else { + lengthFn = codePointLength; + } + + return trimLength( safeVal, newVal, codePointLimit, lengthFn ); + } + + module.exports = { + byteLength: byteLength, + codePointLength: codePointLength, + trimByteLength: trimByteLength, + trimCodePointLength: trimCodePointLength + }; + +}() ); diff --git a/resources/src/mediawiki.cookie.js b/resources/src/mediawiki.cookie.js new file mode 100644 index 0000000000..d260fca64e --- /dev/null +++ b/resources/src/mediawiki.cookie.js @@ -0,0 +1,131 @@ +( function ( mw, $ ) { + 'use strict'; + + /** + * Provides an API for getting and setting cookies that is + * syntactically and functionally similar to the server-side cookie + * API (`WebRequest#getCookie` and `WebResponse#setcookie`). + * + * @author Sam Smith + * @author Matthew Flaschen + * @author Timo Tijhof + * + * @class mw.cookie + * @singleton + */ + mw.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 + * already in your options object (e.g. passing `{ secure: null }` or `{ secure: undefined }` + * overrides the default value for `options.secure`). + * + * @param {string} key + * @param {string|null} value Value of cookie. If `value` is `null` then this method will + * instead remove a cookie by name of `key`. + * @param {Object|Date} [options] Options object, or expiry date + * @param {Date|number|null} [options.expires] The expiry date of the cookie, or lifetime in seconds. + * + * If `options.expires` is null, then a session cookie is set. + * + * By default cookie expiration is based on `wgCookieExpiration`. Similar to `WebResponse` + * in PHP, we set a session cookie if `wgCookieExpiration` is 0. And for non-zero values + * it is interpreted as lifetime in seconds. + * + * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key + * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie + * @param {string} [options.path=wgCookiePath] The path attribute of the cookie + * @param {boolean} [options.secure=false] Whether or not to include the secure attribute. + * (Does **not** use the wgCookieSecure configuration variable) + */ + set: function ( key, value, options ) { + var config, defaultOptions, date; + + // wgCookieSecure is not used for now, since 'detect' could not work with + // ResourceLoaderStartUpModule, as module cache is not fragmented by protocol. + config = mw.config.get( [ + 'wgCookiePrefix', + 'wgCookieDomain', + 'wgCookiePath', + 'wgCookieExpiration' + ] ); + + defaultOptions = { + prefix: config.wgCookiePrefix, + domain: config.wgCookieDomain, + path: config.wgCookiePath, + secure: false + }; + + // Options argument can also be a shortcut for the expiry + // Expiry can be a Date or null + if ( $.type( options ) !== 'object' ) { + // Also takes care of options = undefined, in which case we also don't need $.extend() + defaultOptions.expires = options; + options = defaultOptions; + } else { + options = $.extend( defaultOptions, options ); + } + + // Default to using wgCookieExpiration (lifetime in seconds). + // If wgCookieExpiration is 0, that is considered a special value indicating + // all cookies should be session cookies by default. + if ( options.expires === undefined && config.wgCookieExpiration !== 0 ) { + date = new Date(); + date.setTime( Number( date ) + ( config.wgCookieExpiration * 1000 ) ); + options.expires = date; + } else if ( typeof options.expires === 'number' ) { + // Lifetime in seconds + date = new Date(); + date.setTime( Number( date ) + ( options.expires * 1000 ) ); + options.expires = date; + } else if ( options.expires === null ) { + // $.cookie makes a session cookie when options.expires is omitted + delete options.expires; + } + + // Process prefix + key = options.prefix + key; + delete options.prefix; + + // Process value + if ( value !== null ) { + value = String( value ); + } + + // Other options are handled by $.cookie + $.cookie( key, value, options ); + }, + + /** + * 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|null|Mixed} If the cookie exists, then the value of the + * cookie, otherwise `defaultValue` + */ + get: function ( key, prefix, defaultValue ) { + var result; + + if ( prefix === undefined || prefix === null ) { + prefix = mw.config.get( 'wgCookiePrefix' ); + } + + // Was defaultValue omitted? + if ( arguments.length < 3 ) { + defaultValue = null; + } + + result = $.cookie( prefix + key ); + + return result !== null ? result : defaultValue; + } + }; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.experiments.js b/resources/src/mediawiki.experiments.js new file mode 100644 index 0000000000..4fedbeaea9 --- /dev/null +++ b/resources/src/mediawiki.experiments.js @@ -0,0 +1,109 @@ +( function ( mw, $ ) { + + var CONTROL_BUCKET = 'control', + MAX_INT32_UNSIGNED = 4294967295; + + /** + * An implementation of Jenkins' one-at-a-time hash. + * + * @see https://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 + * @see https://jsbin.com/kejewi/4/watch?js,console + */ + function hashString( string ) { + /* eslint-disable no-bitwise */ + 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; + /* eslint-enable no-bitwise */ + } + + /** + * 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. + * + * @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 + * @return {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.inspect.js b/resources/src/mediawiki.inspect.js new file mode 100644 index 0000000000..6478fd96d3 --- /dev/null +++ b/resources/src/mediawiki.inspect.js @@ -0,0 +1,338 @@ +/*! + * Tools for inspecting page composition and performance. + * + * @author Ori Livneh + * @since 1.22 + */ + +/* eslint-disable no-console */ + +( function ( mw, $ ) { + + var inspect, + byteLength = require( 'mediawiki.String' ).byteLength, + hasOwn = Object.prototype.hasOwnProperty; + + 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; + } ); + } + + function humanSize( bytes ) { + var i, + units = [ '', ' KiB', ' MiB', ' GiB', ' TiB', ' PiB' ]; + + if ( !$.isNumeric( bytes ) || bytes === 0 ) { return bytes; } + + for ( i = 0; 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 ]; + } + + /** + * @class mw.inspect + * @singleton + */ + inspect = { + + /** + * Return a map of all dependency relationships between loaded modules. + * + * @return {Object} Maps module names to objects. Each sub-object has + * two properties, 'requires' and 'requiredBy'. + */ + getDependencyGraph: function () { + var modules = inspect.getLoadedModules(), + graph = {}; + + modules.forEach( function ( moduleName ) { + var dependencies = mw.loader.moduleRegistry[ moduleName ].dependencies || []; + + if ( !hasOwn.call( graph, moduleName ) ) { + graph[ moduleName ] = { requiredBy: [] }; + } + graph[ moduleName ].requires = dependencies; + + dependencies.forEach( function ( depName ) { + if ( !hasOwn.call( graph, depName ) ) { + graph[ depName ] = { requiredBy: [] }; + } + graph[ depName ].requiredBy.push( moduleName ); + } ); + } ); + return graph; + }, + + /** + * Calculate the byte size of a ResourceLoader module. + * + * @param {string} moduleName The name of the module + * @return {number|null} Module size in bytes or null + */ + getModuleSize: function ( moduleName ) { + var module = mw.loader.moduleRegistry[ moduleName ], + args, i, size; + + if ( module.state !== 'ready' ) { + return null; + } + + if ( !module.style && !module.script ) { + return 0; + } + + function getFunctionBody( func ) { + return String( func ) + // To ensure a deterministic result, replace the start of the function + // declaration with a fixed string. For example, in Chrome 55, it seems + // V8 seemingly-at-random decides to sometimes put a line break between + // the opening brace and first statement of the function body. T159751. + .replace( /^\s*function\s*\([^)]*\)\s*{\s*/, 'function(){' ) + .replace( /\s*}\s*$/, '}' ); + } + + // Based on the load.php response for this module. + // For example: `mw.loader.implement("example", function(){}, {"css":[".x{color:red}"]});` + // @see mw.loader.store.set(). + args = [ + moduleName, + module.script, + module.style, + module.messages, + module.templates + ]; + // Trim trailing null or empty object, as load.php would have done. + // @see ResourceLoader::makeLoaderImplementScript and ResourceLoader::trimArray. + i = args.length; + while ( i-- ) { + if ( args[ i ] === null || ( $.isPlainObject( args[ i ] ) && $.isEmptyObject( args[ i ] ) ) ) { + args.splice( i, 1 ); + } else { + break; + } + } + + size = 0; + for ( i = 0; i < args.length; i++ ) { + if ( typeof args[ i ] === 'function' ) { + size += byteLength( getFunctionBody( args[ i ] ) ); + } else { + size += byteLength( JSON.stringify( args[ i ] ) ); + } + } + + return size; + }, + + /** + * Given CSS source, count both the total number of selectors it + * contains and the number which match some element in the current + * document. + * + * @param {string} css CSS source + * @return {Object} Selector counts + * @return {number} return.selectors Total number of selectors + * @return {number} return.matched Number of matched selectors + */ + auditSelectors: function ( css ) { + var selectors = { total: 0, matched: 0 }, + style = document.createElement( 'style' ); + + style.textContent = css; + document.body.appendChild( style ); + $.each( style.sheet.cssRules, function ( index, rule ) { + selectors.total++; + // 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; + }, + + /** + * Get a list of all loaded ResourceLoader modules. + * + * @return {Array} List of module names + */ + getLoadedModules: function () { + return mw.loader.getModuleNames().filter( function ( module ) { + return mw.loader.getState( module ) === 'ready'; + } ); + }, + + /** + * Print tabular data to the console, using console.table, console.log, + * or mw.log (in declining order of preference). + * + * @param {Array} data Tabular data represented as an array of objects + * with common properties. + */ + dumpTable: function ( data ) { + try { + // Bartosz made me put this here. + if ( window.opera ) { throw window.opera; } + // Use Function.prototype#call to force an exception on Firefox, + // which doesn't define console#table but doesn't complain if you + // try to invoke it. + // eslint-disable-next-line no-useless-call + console.table.call( console, data ); + return; + } catch ( e ) {} + try { + console.log( JSON.stringify( data, null, 2 ) ); + return; + } catch ( e ) {} + mw.log( data ); + }, + + /** + * Generate and print one more reports. When invoked with no arguments, + * print all reports. + * + * @param {...string} [reports] Report names to run, or unset to print + * all available reports. + */ + runReports: function () { + var reports = arguments.length > 0 ? + Array.prototype.slice.call( arguments ) : + $.map( inspect.reports, function ( v, k ) { return k; } ); + + reports.forEach( function ( name ) { + inspect.dumpTable( inspect.reports[ name ]() ); + } ); + }, + + /** + * @class mw.inspect.reports + * @singleton + */ + reports: { + /** + * Generate a breakdown of all loaded modules and their size in + * kilobytes. Modules are ordered from largest to smallest. + * + * @return {Object[]} Size reports + */ + size: function () { + // Map each module to a descriptor object. + var modules = inspect.getLoadedModules().map( function ( module ) { + return { + name: module, + size: inspect.getModuleSize( module ) + }; + } ); + + // Sort module descriptors by size, largest first. + sortByProperty( modules, 'size', true ); + + // Convert size to human-readable string. + modules.forEach( function ( module ) { + module.sizeInBytes = module.size; + module.size = humanSize( module.size ); + } ); + + return modules; + }, + + /** + * For each module with styles, count the number of selectors, and + * count how many match against some element currently in the DOM. + * + * @return {Object[]} CSS reports + */ + css: function () { + var modules = []; + + inspect.getLoadedModules().forEach( function ( name ) { + var css, stats, module = mw.loader.moduleRegistry[ name ]; + + try { + css = module.style.css.join(); + } catch ( e ) { return; } // skip + + stats = inspect.auditSelectors( css ); + modules.push( { + module: name, + allSelectors: stats.total, + matchedSelectors: stats.matched, + percentMatched: stats.total !== 0 ? + ( stats.matched / stats.total * 100 ).toFixed( 2 ) + '%' : null + } ); + } ); + sortByProperty( modules, 'allSelectors', true ); + return modules; + }, + + /** + * Report stats on mw.loader.store: the number of localStorage + * cache hits and misses, the number of items purged from the + * cache, and the total size of the module blob in localStorage. + * + * @return {Object[]} Store stats + */ + store: function () { + var raw, stats = { enabled: mw.loader.store.enabled }; + if ( stats.enabled ) { + $.extend( stats, mw.loader.store.stats ); + try { + raw = localStorage.getItem( mw.loader.store.getStoreKey() ); + stats.totalSizeInBytes = byteLength( raw ); + stats.totalSize = humanSize( byteLength( raw ) ); + } catch ( e ) {} + } + return [ stats ]; + } + }, + + /** + * Perform a string search across the JavaScript and CSS source code + * of all loaded modules and return an array of the names of the + * modules that matched. + * + * @param {string|RegExp} pattern String or regexp to match. + * @return {Array} Array of the names of modules that matched. + */ + grep: function ( pattern ) { + if ( typeof pattern.test !== 'function' ) { + pattern = new RegExp( mw.RegExp.escape( pattern ), 'g' ); + } + + return inspect.getLoadedModules().filter( function ( moduleName ) { + var module = mw.loader.moduleRegistry[ moduleName ]; + + // Grep module's JavaScript + if ( $.isFunction( module.script ) && pattern.test( module.script.toString() ) ) { + return true; + } + + // Grep module's CSS + if ( + $.isPlainObject( module.style ) && Array.isArray( module.style.css ) && + pattern.test( module.style.css.join( '' ) ) + ) { + // Module's CSS source matches + return true; + } + + return false; + } ); + } + }; + + if ( mw.config.get( 'debug' ) ) { + mw.log( 'mw.inspect: reports are not available in debug mode.' ); + } + + mw.inspect = inspect; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.notify.js b/resources/src/mediawiki.notify.js new file mode 100644 index 0000000000..0f3a086774 --- /dev/null +++ b/resources/src/mediawiki.notify.js @@ -0,0 +1,28 @@ +/** + * @class mw.plugin.notify + */ +( function ( mw ) { + 'use strict'; + + /** + * @see mw.notification#notify + * @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 ) { + // Don't bother loading the whole notification system if we never use it. + return mw.loader.using( 'mediawiki.notification' ) + .then( function () { + // Call notify with the notification the user requested of us. + return mw.notification.notify( message, options ); + } ); + }; + + /** + * @class mw + * @mixins mw.plugin.notify + */ + +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.storage.js b/resources/src/mediawiki.storage.js new file mode 100644 index 0000000000..84e146a73f --- /dev/null +++ b/resources/src/mediawiki.storage.js @@ -0,0 +1,94 @@ +( function ( mw ) { + 'use strict'; + + // Catch exceptions to avoid fatal in Chrome's "Block data storage" mode + // which throws when accessing the localStorage property itself, as opposed + // to the standard behaviour of throwing on getItem/setItem. (T148998) + var + localStorage = ( function () { + try { + return window.localStorage; + } catch ( e ) {} + }() ), + sessionStorage = ( function () { + try { + return window.sessionStorage; + } catch ( e ) {} + }() ); + + /** + * A wrapper for an HTML5 Storage interface (`localStorage` or `sessionStorage`) + * that is safe to call on all browsers. + * + * @class mw.SafeStorage + * @private + * @param {Object|undefined} store The Storage instance to wrap around + */ + function SafeStorage( store ) { + this.store = store; + } + + /** + * Retrieve value from device storage. + * + * @param {string} key Key of item to retrieve + * @return {string|null|boolean} String value, null if no value exists, or false + * if localStorage is not available. + */ + SafeStorage.prototype.get = function ( key ) { + try { + return this.store.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 + * @return {boolean} Whether the save succeeded or not + */ + SafeStorage.prototype.set = function ( key, value ) { + try { + this.store.setItem( key, value ); + return true; + } catch ( e ) {} + return false; + }; + + /** + * Remove a value from device storage. + * + * @param {string} key Key of item to remove + * @return {boolean} Whether the save succeeded or not + */ + SafeStorage.prototype.remove = function ( key ) { + try { + this.store.removeItem( key ); + return true; + } catch ( e ) {} + return false; + }; + + /** + * A wrapper for the HTML5 `localStorage` interface + * that is safe to call on all browsers. + * + * @class + * @singleton + * @extends mw.SafeStorage + */ + mw.storage = new SafeStorage( localStorage ); + + /** + * A wrapper for the HTML5 `sessionStorage` interface + * that is safe to call on all browsers. + * + * @class + * @singleton + * @extends mw.SafeStorage + */ + mw.storage.session = new SafeStorage( sessionStorage ); + +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.user.js b/resources/src/mediawiki.user.js new file mode 100644 index 0000000000..5fc1990758 --- /dev/null +++ b/resources/src/mediawiki.user.js @@ -0,0 +1,189 @@ +/** + * @class mw.user + * @singleton + */ +/* global Uint32Array */ +( function ( mw, $ ) { + var userInfoPromise, stickyRandomSessionId; + + /** + * Get the current user's groups or rights + * + * @private + * @return {jQuery.Promise} + */ + function getUserInfo() { + if ( !userInfoPromise ) { + userInfoPromise = new mw.Api().getUserInfo(); + } + return userInfoPromise; + } + + // mw.user with the properties options and tokens gets defined in mediawiki.js. + $.extend( mw.user, { + + /** + * Generate a random user session ID. + * + * This information would potentially be stored in a cookie to identify a user during a + * session or series of sessions. Its uniqueness should not be depended on unless the + * browser supports the crypto API. + * + * Known problems with Math.random(): + * Using the Math.random function we have seen sets + * with 1% of non uniques among 200,000 values with Safari providing most of these. + * Given the prevalence of Safari in mobile the percentage of duplicates in + * mobile usages of this code is probably higher. + * + * Rationale: + * We need about 64 bits to make sure that probability of collision + * on 500 million (5*10^8) is <= 1% + * See https://en.wikipedia.org/wiki/Birthday_problem#Probability_table + * + * @return {string} 64 bit integer in hex format, padded + */ + generateRandomSessionId: function () { + var rnds, i, + hexRnds = new Array( 2 ), + // Support: IE 11 + crypto = window.crypto || window.msCrypto; + + if ( crypto && crypto.getRandomValues && typeof Uint32Array === 'function' ) { + // Fill an array with 2 random values, each of which is 32 bits. + // Note that Uint32Array is array-like but does not implement Array. + rnds = new Uint32Array( 2 ); + crypto.getRandomValues( rnds ); + } else { + rnds = [ + Math.floor( Math.random() * 0x100000000 ), + Math.floor( Math.random() * 0x100000000 ) + ]; + } + // Convert number to a string with 16 hex characters + for ( i = 0; i < 2; i++ ) { + // Add 0x100000000 before converting to hex and strip the extra character + // after converting to keep the leading zeros. + hexRnds[ i ] = ( rnds[ i ] + 0x100000000 ).toString( 16 ).slice( 1 ); + } + + // Concatenation of two random integers with entropy n and m + // returns a string with entropy n+m if those strings are independent + return hexRnds.join( '' ); + }, + + /** + * A sticky generateRandomSessionId for the current JS execution context, + * cached within this class. + * + * @return {string} 64 bit integer in hex format, padded + */ + stickyRandomId: function () { + if ( !stickyRandomSessionId ) { + stickyRandomSessionId = mw.user.generateRandomSessionId(); + } + + return stickyRandomSessionId; + }, + + /** + * Get the current user's database id + * + * Not to be confused with #id. + * + * @return {number} Current user's id, or 0 if user is anonymous + */ + getId: function () { + return mw.config.get( 'wgUserId' ) || 0; + }, + + /** + * Get the current user's name + * + * @return {string|null} User name string or null if user is anonymous + */ + getName: function () { + return mw.config.get( 'wgUserName' ); + }, + + /** + * Get date user registered, if available + * + * @return {boolean|null|Date} False for anonymous users, null if data is + * unavailable, or Date for when the user registered. + */ + getRegistration: function () { + var registration; + if ( mw.user.isAnon() ) { + return false; + } + registration = mw.config.get( 'wgUserRegistration' ); + // Registration may be unavailable if the user signed up before MediaWiki + // began tracking this. + return !registration ? null : new Date( registration ); + }, + + /** + * Whether the current user is anonymous + * + * @return {boolean} + */ + isAnon: function () { + return mw.user.getName() === null; + }, + + /** + * Get an automatically generated random ID (persisted in sessionStorage) + * + * This ID is ephemeral for everyone, staying in their browser only until they + * close their browsing session. + * + * @return {string} Random session ID + */ + sessionId: function () { + var sessionId = mw.storage.session.get( 'mwuser-sessionId' ); + if ( !sessionId ) { + sessionId = mw.user.generateRandomSessionId(); + mw.storage.session.set( 'mwuser-sessionId', sessionId ); + } + return sessionId; + }, + + /** + * Get the current user's name or the session ID + * + * Not to be confused with #getId. + * + * @return {string} User name or random session ID + */ + id: function () { + return mw.user.getName() || mw.user.sessionId(); + }, + + /** + * Get the current user's groups + * + * @param {Function} [callback] + * @return {jQuery.Promise} + */ + getGroups: function ( callback ) { + var userGroups = mw.config.get( 'wgUserGroups', [] ); + + // Uses promise for backwards compatibility + return $.Deferred().resolve( userGroups ).done( callback ); + }, + + /** + * Get the current user's rights + * + * @param {Function} [callback] + * @return {jQuery.Promise} + */ + getRights: function ( callback ) { + return getUserInfo().then( + function ( userInfo ) { return userInfo.rights; }, + function () { return []; } + ).done( callback ); + } + } ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.userSuggest.js b/resources/src/mediawiki.userSuggest.js new file mode 100644 index 0000000000..99e9dbe01b --- /dev/null +++ b/resources/src/mediawiki.userSuggest.js @@ -0,0 +1,42 @@ +/*! + * Add autocomplete suggestions for names of registered users. + */ +( function ( mw, $ ) { + var api, config; + + config = { + fetch: function ( userInput, response, maxRows ) { + var node = this[ 0 ]; + + api = api || new mw.Api(); + + $.data( node, 'request', api.get( { + formatversion: 2, + action: 'query', + list: 'allusers', + // Prefix of list=allusers is case sensitive. Normalise first + // character to uppercase so that "fo" may yield "Foo". + auprefix: userInput[ 0 ].toUpperCase() + userInput.slice( 1 ), + aulimit: maxRows + } ).done( function ( data ) { + var users = $.map( data.query.allusers, function ( userObj ) { + return userObj.name; + } ); + response( users ); + } ) ); + }, + cancel: function () { + var node = this[ 0 ], + request = $.data( node, 'request' ); + + if ( request ) { + request.abort(); + $.removeData( node, 'request' ); + } + } + }; + + $( function () { + $( '.mw-autocomplete-user' ).suggestions( config ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.util.js b/resources/src/mediawiki.util.js new file mode 100644 index 0000000000..1db890419c --- /dev/null +++ b/resources/src/mediawiki.util.js @@ -0,0 +1,602 @@ +( function ( mw, $ ) { + 'use strict'; + + var util; + + /** + * Encode the string like PHP's rawurlencode + * @ignore + * + * @param {string} str String to be encoded. + * @return {string} Encoded string + */ + function rawurlencode( str ) { + str = String( str ); + return encodeURIComponent( str ) + .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' ) + .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' ).replace( /~/g, '%7E' ); + } + + /** + * Private helper function used by util.escapeId*() + * @ignore + * + * @param {string} str String to be encoded + * @param {string} mode Encoding mode, see documentation for $wgFragmentMode + * in DefaultSettings.php + * @return {string} Encoded string + */ + function escapeIdInternal( str, mode ) { + str = String( str ); + + switch ( mode ) { + case 'html5': + return str.replace( / /g, '_' ); + case 'legacy': + return rawurlencode( str.replace( / /g, '_' ) ) + .replace( /%3A/g, ':' ) + .replace( /%/g, '.' ); + default: + throw new Error( 'Unrecognized ID escaping mode ' + mode ); + } + } + + /** + * Utility library + * @class mw.util + * @singleton + */ + util = { + + /* Main body */ + + /** + * Encode the string like PHP's rawurlencode + * + * @param {string} str String to be encoded. + * @return {string} Encoded string + */ + rawurlencode: rawurlencode, + + /** + * Encode string into HTML id compatible form suitable for use in HTML + * Analog to PHP Sanitizer::escapeIdForAttribute() + * + * @since 1.30 + * + * @param {string} str String to encode + * @return {string} Encoded string + */ + escapeIdForAttribute: function ( str ) { + var mode = mw.config.get( 'wgFragmentMode' )[ 0 ]; + + return escapeIdInternal( str, mode ); + }, + + /** + * Encode string into HTML id compatible form suitable for use in links + * Analog to PHP Sanitizer::escapeIdForLink() + * + * @since 1.30 + * + * @param {string} str String to encode + * @return {string} Encoded string + */ + escapeIdForLink: function ( str ) { + var mode = mw.config.get( 'wgFragmentMode' )[ 0 ]; + + return escapeIdInternal( str, mode ); + }, + + /** + * Encode page titles for use in a URL + * + * We want / and : to be included as literal characters in our title URLs + * as they otherwise fatally break the title. + * + * The others are decoded because we can, it's prettier and matches behaviour + * of `wfUrlencode` in PHP. + * + * @param {string} str String to be encoded. + * @return {string} Encoded string + */ + wikiUrlencode: function ( str ) { + return util.rawurlencode( str ) + .replace( /%20/g, '_' ) + // wfUrlencode replacements + .replace( /%3B/g, ';' ) + .replace( /%40/g, '@' ) + .replace( /%24/g, '$' ) + .replace( /%21/g, '!' ) + .replace( /%2A/g, '*' ) + .replace( /%28/g, '(' ) + .replace( /%29/g, ')' ) + .replace( /%2C/g, ',' ) + .replace( /%2F/g, '/' ) + .replace( /%7E/g, '~' ) + .replace( /%3A/g, ':' ); + }, + + /** + * Get the link to a page name (relative to `wgServer`), + * + * @param {string|null} [pageName=wgPageName] Page name + * @param {Object} [params] A mapping of query parameter names to values, + * e.g. `{ action: 'edit' }` + * @return {string} Url of the page with name of `pageName` + */ + getUrl: function ( pageName, params ) { + var titleFragmentStart, url, query, + fragment = '', + title = typeof pageName === 'string' ? pageName : mw.config.get( 'wgPageName' ); + + // Find any fragment + titleFragmentStart = title.indexOf( '#' ); + if ( titleFragmentStart !== -1 ) { + fragment = title.slice( titleFragmentStart + 1 ); + // Exclude the fragment from the page name + title = title.slice( 0, titleFragmentStart ); + } + + // Produce query string + if ( params ) { + query = $.param( params ); + } + if ( query ) { + url = title ? + util.wikiScript() + '?title=' + util.wikiUrlencode( title ) + '&' + query : + util.wikiScript() + '?' + query; + } else { + url = mw.config.get( 'wgArticlePath' ) + .replace( '$1', util.wikiUrlencode( title ).replace( /\$/g, '$$$$' ) ); + } + + // Append the encoded fragment + if ( fragment.length ) { + url += '#' + util.escapeIdForLink( fragment ); + } + + return url; + }, + + /** + * Get address to a script in the wiki root. + * For index.php use `mw.config.get( 'wgScript' )`. + * + * @since 1.18 + * @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'; + if ( str === 'index' ) { + return mw.config.get( 'wgScript' ); + } else if ( str === 'load' ) { + return mw.config.get( 'wgLoadScript' ); + } else { + return mw.config.get( 'wgScriptPath' ) + '/' + str + '.php'; + } + }, + + /** + * Append a new style block to the head and return the CSSStyleSheet object. + * Use .ownerNode to access the `