diff options
author | Luke Shumaker <lukeshu@sbcglobal.net> | 2016-05-01 15:32:59 -0400 |
---|---|---|
committer | Luke Shumaker <lukeshu@sbcglobal.net> | 2016-05-01 15:32:59 -0400 |
commit | 6dc1997577fab2c366781fd7048144935afa0012 (patch) | |
tree | 8918d28c7ab4342f0738985e37af1dfc42d0e93a /resources/src | |
parent | 150f94f051128f367bc89f6b7e5f57eb2a69fc62 (diff) | |
parent | fa89acd685cb09cdbe1c64cbb721ec64975bbbc1 (diff) |
Merge commit 'fa89acd'
# Conflicts:
# .gitignore
# extensions/ArchInterWiki.sql
Diffstat (limited to 'resources/src')
169 files changed, 8763 insertions, 2410 deletions
diff --git a/resources/src/dom-level2-skip.js b/resources/src/dom-level2-skip.js new file mode 100644 index 00000000..484c295e --- /dev/null +++ b/resources/src/dom-level2-skip.js @@ -0,0 +1,6 @@ +/*! + * Skip function for dom-level2-shim module. + * + * Tests for window.Node because that's the only thing that this shim is adding. + */ +return !!window.Node; diff --git a/resources/src/jquery.tipsy/jquery.tipsy.js b/resources/src/jquery.tipsy/jquery.tipsy.js index 2a37fa86..29b7490f 100644 --- a/resources/src/jquery.tipsy/jquery.tipsy.js +++ b/resources/src/jquery.tipsy/jquery.tipsy.js @@ -16,6 +16,7 @@ this.$element = $(element); this.options = options; this.enabled = true; + this.keyHandler = $.proxy( this.closeOnEsc, this ); this.fixTitle(); } @@ -30,7 +31,10 @@ if (this.options.className) { $tip.addClass(maybeCall(this.options.className, this.$element[0])); } - $tip.remove().css({top: 0, left: 0, visibility: 'hidden', display: 'block'}).appendTo(document.body); + $tip.remove() + .css({top: 0, left: 0, visibility: 'hidden', display: 'block'}) + .attr( 'aria-hidden', 'true' ) + .appendTo(document.body); var pos = $.extend({}, this.$element.offset(), { width: this.$element[0].offsetWidth, @@ -82,15 +86,22 @@ } $tip.css(tp); + $( document ).on( 'keydown', this.keyHandler ); if (this.options.fade) { - $tip.stop().css({opacity: 0, display: 'block', visibility: 'visible'}).animate({opacity: this.options.opacity}, 100); + $tip.stop() + .css({opacity: 0, display: 'block', visibility: 'visible'}) + .attr( 'aria-hidden', 'false' ) + .animate({opacity: this.options.opacity}, 100); } else { - $tip.css({visibility: 'visible', opacity: this.options.opacity}); + $tip + .css({visibility: 'visible', opacity: this.options.opacity}) + .attr( 'aria-hidden', 'false' ); } } }, hide: function() { + $( document ).off( 'keydown', this.keyHandler ); if (this.options.fade) { this.tip().stop().fadeOut(100, function() { $(this).remove(); }); } else { @@ -120,7 +131,7 @@ tip: function() { if (!this.$tip) { - this.$tip = $('<div class="tipsy"></div>').html('<div class="tipsy-arrow"></div><div class="tipsy-inner"></div>'); + this.$tip = $('<div class="tipsy" role="tooltip"></div>').html('<div class="tipsy-arrow"></div><div class="tipsy-inner"></div>'); } return this.$tip; }, @@ -133,6 +144,13 @@ } }, + // $.proxy event handler + closeOnEsc: function ( e ) { + if ( e.keyCode === 27 ) { + this.hide(); + } + }, + enable: function() { this.enabled = true; }, disable: function() { this.enabled = false; }, toggleEnabled: function() { this.enabled = !this.enabled; } @@ -183,8 +201,8 @@ if (!options.live) this.each(function() { get(this); }); if ( options.trigger != 'manual' ) { - var eventIn = options.trigger == 'hover' ? 'mouseenter' : 'focus', - eventOut = options.trigger == 'hover' ? 'mouseleave' : 'blur'; + var eventIn = options.trigger == 'hover' ? 'mouseenter focus' : 'focus', + eventOut = options.trigger == 'hover' ? 'mouseleave blur' : 'blur'; if ( options.live ) { mw.track( 'mw.deprecate', 'tipsy-live' ); mw.log.warn( 'Use of the "live" option of jquery.tipsy is deprecated.' ); diff --git a/resources/src/jquery/jquery.accessKeyLabel.js b/resources/src/jquery/jquery.accessKeyLabel.js index 867c25e7..92f8eb9c 100644 --- a/resources/src/jquery/jquery.accessKeyLabel.js +++ b/resources/src/jquery/jquery.accessKeyLabel.js @@ -112,7 +112,7 @@ function getAccessKeyLabel( element ) { */ function updateTooltipOnElement( element, titleElement ) { var array = ( mw.msg( 'word-separator' ) + mw.msg( 'brackets' ) ).split( '$1' ), - regexp = new RegExp( $.map( array, $.escapeRE ).join( '.*?' ) + '$' ), + regexp = new RegExp( $.map( array, mw.RegExp.escape ).join( '.*?' ) + '$' ), oldTitle = titleElement.title, rawTitle = oldTitle.replace( regexp, '' ), newTitle = rawTitle, @@ -150,14 +150,14 @@ function updateTooltip( element ) { if ( id ) { $label = $( 'label[for="' + id + '"]' ); if ( $label.length === 1 ) { - updateTooltipOnElement( element, $label[0] ); + updateTooltipOnElement( element, $label[ 0 ] ); } } // Search it as parent, because the form control can also be inside the label element itself $labelParent = $element.parents( 'label' ); if ( $labelParent.length === 1 ) { - updateTooltipOnElement( element, $labelParent[0] ); + updateTooltipOnElement( element, $labelParent[ 0 ] ); } } } diff --git a/resources/src/jquery/jquery.autoEllipsis.js b/resources/src/jquery/jquery.autoEllipsis.js index 9a196b5d..e1115d65 100644 --- a/resources/src/jquery/jquery.autoEllipsis.js +++ b/resources/src/jquery/jquery.autoEllipsis.js @@ -69,16 +69,16 @@ $.fn.autoEllipsis = function ( options ) { // Try cache if ( options.matchText ) { if ( !( text in matchTextCache ) ) { - matchTextCache[text] = {}; + matchTextCache[ text ] = {}; } - if ( !( options.matchText in matchTextCache[text] ) ) { - matchTextCache[text][options.matchText] = {}; + if ( !( options.matchText in matchTextCache[ text ] ) ) { + matchTextCache[ text ][ options.matchText ] = {}; } - if ( !( w in matchTextCache[text][options.matchText] ) ) { - matchTextCache[text][options.matchText][w] = {}; + if ( !( w in matchTextCache[ text ][ options.matchText ] ) ) { + matchTextCache[ text ][ options.matchText ][ w ] = {}; } - if ( options.position in matchTextCache[text][options.matchText][w] ) { - $container.html( matchTextCache[text][options.matchText][w][options.position] ); + if ( options.position in matchTextCache[ text ][ options.matchText ][ w ] ) { + $container.html( matchTextCache[ text ][ options.matchText ][ w ][ options.position ] ); if ( options.tooltip ) { $container.attr( 'title', text ); } @@ -86,13 +86,13 @@ $.fn.autoEllipsis = function ( options ) { } } else { if ( !( text in cache ) ) { - cache[text] = {}; + cache[ text ] = {}; } - if ( !( w in cache[text] ) ) { - cache[text][w] = {}; + if ( !( w in cache[ text ] ) ) { + cache[ text ][ w ] = {}; } - if ( options.position in cache[text][w] ) { - $container.html( cache[text][w][options.position] ); + if ( options.position in cache[ text ][ w ] ) { + $container.html( cache[ text ][ w ][ options.position ] ); if ( options.tooltip ) { $container.attr( 'title', text ); } @@ -120,19 +120,19 @@ $.fn.autoEllipsis = function ( options ) { break; case 'center': // TODO: Use binary search like for 'right' - i = [Math.round( trimmableText.length / 2 ), Math.round( trimmableText.length / 2 )]; + i = [ Math.round( trimmableText.length / 2 ), Math.round( trimmableText.length / 2 ) ]; // Begin with making the end shorter side = 1; - while ( $trimmableText.outerWidth() + pw > w && i[0] > 0 ) { - $trimmableText.text( trimmableText.slice( 0, i[0] ) + '...' + trimmableText.slice( i[1] ) ); + while ( $trimmableText.outerWidth() + pw > w && i[ 0 ] > 0 ) { + $trimmableText.text( trimmableText.slice( 0, i[ 0 ] ) + '...' + trimmableText.slice( i[ 1 ] ) ); // Alternate between trimming the end and begining if ( side === 0 ) { // Make the begining shorter - i[0]--; + i[ 0 ]--; side = 1; } else { // Make the end shorter - i[1]++; + i[ 1 ]++; side = 0; } } @@ -152,9 +152,9 @@ $.fn.autoEllipsis = function ( options ) { } if ( options.matchText ) { $container.highlightText( options.matchText ); - matchTextCache[text][options.matchText][w][options.position] = $container.html(); + matchTextCache[ text ][ options.matchText ][ w ][ options.position ] = $container.html(); } else { - cache[text][w][options.position] = $container.html(); + cache[ text ][ w ][ options.position ] = $container.html(); } } ); diff --git a/resources/src/jquery/jquery.byteLimit.js b/resources/src/jquery/jquery.byteLimit.js index 5551232a..dd71a2bc 100644 --- a/resources/src/jquery/jquery.byteLimit.js +++ b/resources/src/jquery/jquery.byteLimit.js @@ -10,17 +10,17 @@ * "fobo", not "foba". Basically emulating the native maxlength by * reconstructing where the insertion occurred. * - * @private + * @static * @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} [fn] See jQuery.byteLimit. + * @param {Function} [fn] See jQuery#byteLimit. * @return {Object} * @return {string} return.newVal * @return {boolean} return.trimmed */ - function trimValForByteLength( safeVal, newVal, byteLimit, fn ) { + $.trimByteLength = function ( safeVal, newVal, byteLimit, fn ) { var startMatches, endMatches, matchesLen, inpParts, oldVal = safeVal; @@ -77,22 +77,22 @@ // until the limit is statisfied. if ( fn ) { // stop, when there is nothing to slice - bug 41450 - while ( $.byteLength( fn( inpParts.join( '' ) ) ) > byteLimit && inpParts[1].length > 0 ) { - inpParts[1] = inpParts[1].slice( 0, -1 ); + while ( $.byteLength( fn( inpParts.join( '' ) ) ) > byteLimit && inpParts[ 1 ].length > 0 ) { + inpParts[ 1 ] = inpParts[ 1 ].slice( 0, -1 ); } } else { while ( $.byteLength( inpParts.join( '' ) ) > byteLimit ) { - inpParts[1] = inpParts[1].slice( 0, -1 ); + inpParts[ 1 ] = inpParts[ 1 ].slice( 0, -1 ); } } - newVal = inpParts.join( '' ); - return { - newVal: newVal, - trimmed: true + newVal: inpParts.join( '' ), + // For pathological fn() that always returns a value longer than the limit, we might have + // ended up not trimming - check for this case to avoid infinite loops + trimmed: newVal !== inpParts.join( '' ) }; - } + }; var eventKeys = [ 'keyup.byteLimit', @@ -206,7 +206,7 @@ // See http://www.w3.org/TR/DOM-Level-3-Events/#events-keyboard-event-order for // the order and characteristics of the key events. $el.on( eventKeys, function () { - var res = trimValForByteLength( + var res = $.trimByteLength( prevSafeVal, this.value, elLimit, @@ -219,9 +219,12 @@ // This is a side-effect of limiting after the fact. if ( res.trimmed === true ) { this.value = res.newVal; + // Trigger a 'change' event to let other scripts attached to this node know that the value + // was changed. This will also call ourselves again, but that's okay, it'll be a no-op. + $el.trigger( 'change' ); } // Always adjust prevSafeVal to reflect the input value. Not doing this could cause - // trimValForByteLength to compare the new value to an empty string instead of the + // trimByteLength to compare the new value to an empty string instead of the // old value, resulting in trimming always from the end (bug 40850). prevSafeVal = res.newVal; } ); diff --git a/resources/src/jquery/jquery.color.js b/resources/src/jquery/jquery.color.js index 04f8047b..a3cc8fc3 100644 --- a/resources/src/jquery/jquery.color.js +++ b/resources/src/jquery/jquery.color.js @@ -28,7 +28,7 @@ } // We override the animation for all of these color styles - $.each([ + $.each( [ 'backgroundColor', 'borderBottomColor', 'borderLeftColor', @@ -37,17 +37,17 @@ 'color', 'outlineColor' ], function ( i, attr ) { - $.fx.step[attr] = function ( fx ) { + $.fx.step[ attr ] = function ( fx ) { if ( !fx.colorInit ) { fx.start = getColor( fx.elem, attr ); fx.end = $.colorUtil.getRGB( fx.end ); fx.colorInit = true; } - fx.elem.style[attr] = 'rgb(' + [ - Math.max( Math.min( parseInt( (fx.pos * (fx.end[0] - fx.start[0])) + fx.start[0], 10 ), 255 ), 0 ), - Math.max( Math.min( parseInt( (fx.pos * (fx.end[1] - fx.start[1])) + fx.start[1], 10 ), 255 ), 0 ), - Math.max( Math.min( parseInt( (fx.pos * (fx.end[2] - fx.start[2])) + fx.start[2], 10 ), 255 ), 0 ) + fx.elem.style[ attr ] = 'rgb(' + [ + Math.max( Math.min( parseInt( ( fx.pos * ( fx.end[ 0 ] - fx.start[ 0 ] ) ) + fx.start[ 0 ], 10 ), 255 ), 0 ), + Math.max( Math.min( parseInt( ( fx.pos * ( fx.end[ 1 ] - fx.start[ 1 ] ) ) + fx.start[ 1 ], 10 ), 255 ), 0 ), + Math.max( Math.min( parseInt( ( fx.pos * ( fx.end[ 2 ] - fx.start[ 2 ] ) ) + fx.start[ 2 ], 10 ), 255 ), 0 ) ].join( ',' ) + ')'; }; } ); diff --git a/resources/src/jquery/jquery.colorUtil.js b/resources/src/jquery/jquery.colorUtil.js index a6ff8bc8..c14f2c86 100644 --- a/resources/src/jquery/jquery.colorUtil.js +++ b/resources/src/jquery/jquery.colorUtil.js @@ -32,48 +32,48 @@ } // Look for rgb(num,num,num) - if (result = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(color)) { + if ( result = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec( color ) ) { return [ - parseInt( result[1], 10 ), - parseInt( result[2], 10 ), - parseInt( result[3], 10 ) + parseInt( result[ 1 ], 10 ), + parseInt( result[ 2 ], 10 ), + parseInt( result[ 3 ], 10 ) ]; } // Look for rgb(num%,num%,num%) - if (result = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(color)) { + if ( result = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec( color ) ) { return [ - parseFloat( result[1] ) * 2.55, - parseFloat( result[2] ) * 2.55, - parseFloat( result[3] ) * 2.55 + parseFloat( result[ 1 ] ) * 2.55, + parseFloat( result[ 2 ] ) * 2.55, + parseFloat( result[ 3 ] ) * 2.55 ]; } // Look for #a0b1c2 - if (result = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(color)) { + if ( result = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec( color ) ) { return [ - parseInt( result[1], 16 ), - parseInt( result[2], 16 ), - parseInt( result[3], 16 ) + parseInt( result[ 1 ], 16 ), + parseInt( result[ 2 ], 16 ), + parseInt( result[ 3 ], 16 ) ]; } // Look for #fff - if (result = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(color)) { + if ( result = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec( color ) ) { return [ - parseInt( result[1] + result[1], 16 ), - parseInt( result[2] + result[2], 16 ), - parseInt( result[3] + result[3], 16) + parseInt( result[ 1 ] + result[ 1 ], 16 ), + parseInt( result[ 2 ] + result[ 2 ], 16 ), + parseInt( result[ 3 ] + result[ 3 ], 16 ) ]; } // Look for rgba(0, 0, 0, 0) == transparent in Safari 3 - if (result = /rgba\(0, 0, 0, 0\)/.exec(color)) { + if ( result = /rgba\(0, 0, 0, 0\)/.exec( color ) ) { return $.colorUtil.colors.transparent; } // Otherwise, we're most likely dealing with a named color - return $.colorUtil.colors[$.trim(color).toLowerCase()]; + return $.colorUtil.colors[ $.trim( color ).toLowerCase() ]; }, /** @@ -85,50 +85,50 @@ * @property {Object} */ colors: { - aqua: [0, 255, 255], - azure: [240, 255, 255], - beige: [245, 245, 220], - black: [0, 0, 0], - blue: [0, 0, 255], - brown: [165, 42, 42], - cyan: [0, 255, 255], - darkblue: [0, 0, 139], - darkcyan: [0, 139, 139], - darkgrey: [169, 169, 169], - darkgreen: [0, 100, 0], - darkkhaki: [189, 183, 107], - darkmagenta: [139, 0, 139], - darkolivegreen: [85, 107, 47], - darkorange: [255, 140, 0], - darkorchid: [153, 50, 204], - darkred: [139, 0, 0], - darksalmon: [233, 150, 122], - darkviolet: [148, 0, 211], - fuchsia: [255, 0, 255], - gold: [255, 215, 0], - green: [0, 128, 0], - indigo: [75, 0, 130], - khaki: [240, 230, 140], - lightblue: [173, 216, 230], - lightcyan: [224, 255, 255], - lightgreen: [144, 238, 144], - lightgrey: [211, 211, 211], - lightpink: [255, 182, 193], - lightyellow: [255, 255, 224], - lime: [0, 255, 0], - magenta: [255, 0, 255], - maroon: [128, 0, 0], - navy: [0, 0, 128], - olive: [128, 128, 0], - orange: [255, 165, 0], - pink: [255, 192, 203], - purple: [128, 0, 128], - violet: [128, 0, 128], - red: [255, 0, 0], - silver: [192, 192, 192], - white: [255, 255, 255], - yellow: [255, 255, 0], - transparent: [255, 255, 255] + aqua: [ 0, 255, 255 ], + azure: [ 240, 255, 255 ], + beige: [ 245, 245, 220 ], + black: [ 0, 0, 0 ], + blue: [ 0, 0, 255 ], + brown: [ 165, 42, 42 ], + cyan: [ 0, 255, 255 ], + darkblue: [ 0, 0, 139 ], + darkcyan: [ 0, 139, 139 ], + darkgrey: [ 169, 169, 169 ], + darkgreen: [ 0, 100, 0 ], + darkkhaki: [ 189, 183, 107 ], + darkmagenta: [ 139, 0, 139 ], + darkolivegreen: [ 85, 107, 47 ], + darkorange: [ 255, 140, 0 ], + darkorchid: [ 153, 50, 204 ], + darkred: [ 139, 0, 0 ], + darksalmon: [ 233, 150, 122 ], + darkviolet: [ 148, 0, 211 ], + fuchsia: [ 255, 0, 255 ], + gold: [ 255, 215, 0 ], + green: [ 0, 128, 0 ], + indigo: [ 75, 0, 130 ], + khaki: [ 240, 230, 140 ], + lightblue: [ 173, 216, 230 ], + lightcyan: [ 224, 255, 255 ], + lightgreen: [ 144, 238, 144 ], + lightgrey: [ 211, 211, 211 ], + lightpink: [ 255, 182, 193 ], + lightyellow: [ 255, 255, 224 ], + lime: [ 0, 255, 0 ], + magenta: [ 255, 0, 255 ], + maroon: [ 128, 0, 0 ], + navy: [ 0, 0, 128 ], + olive: [ 128, 128, 0 ], + orange: [ 255, 165, 0 ], + pink: [ 255, 192, 203 ], + purple: [ 128, 0, 128 ], + violet: [ 128, 0, 128 ], + red: [ 255, 0, 0 ], + silver: [ 192, 192, 192 ], + white: [ 255, 255, 255 ], + yellow: [ 255, 255, 0 ], + transparent: [ 255, 255, 255 ] }, /** @@ -157,29 +157,29 @@ min = Math.min( r, g, b ), h, s, - l = (max + min) / 2; + l = ( max + min ) / 2; if ( max === min ) { // achromatic h = s = 0; } else { d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + s = l > 0.5 ? d / ( 2 - max - min ) : d / ( max + min ); switch ( max ) { case r: - h = (g - b) / d + (g < b ? 6 : 0); + h = ( g - b ) / d + ( g < b ? 6 : 0 ); break; case g: - h = (b - r) / d + 2; + h = ( b - r ) / d + 2; break; case b: - h = (r - g) / d + 4; + h = ( r - g ) / d + 4; break; } h /= 6; } - return [h, s, l]; + return [ h, s, l ]; }, /** @@ -212,25 +212,25 @@ t -= 1; } if ( t < 1 / 6 ) { - return p + (q - p) * 6 * t; + return p + ( q - p ) * 6 * t; } if ( t < 1 / 2 ) { return q; } if ( t < 2 / 3 ) { - return p + (q - p) * (2 / 3 - t) * 6; + return p + ( q - p ) * ( 2 / 3 - t ) * 6; } return p; }; - q = l < 0.5 ? l * (1 + s) : l + s - l * s; + q = l < 0.5 ? l * ( 1 + s ) : l + s - l * s; p = 2 * l - q; r = hue2rgb( p, q, h + 1 / 3 ); g = hue2rgb( p, q, h ); b = hue2rgb( p, q, h - 1 / 3 ); } - return [r * 255, g * 255, b * 255]; + return [ r * 255, g * 255, b * 255 ]; }, /** @@ -249,11 +249,11 @@ */ getColorBrightness: function ( currentColor, mod ) { var rgbArr = $.colorUtil.getRGB( currentColor ), - hslArr = $.colorUtil.rgbToHsl(rgbArr[0], rgbArr[1], rgbArr[2] ); - rgbArr = $.colorUtil.hslToRgb(hslArr[0], hslArr[1], hslArr[2] + mod); + hslArr = $.colorUtil.rgbToHsl( rgbArr[ 0 ], rgbArr[ 1 ], rgbArr[ 2 ] ); + rgbArr = $.colorUtil.hslToRgb( hslArr[ 0 ], hslArr[ 1 ], hslArr[ 2 ] + mod ); return 'rgb(' + - [parseInt( rgbArr[0], 10), parseInt( rgbArr[1], 10 ), parseInt( rgbArr[2], 10 )].join( ',' ) + + [ parseInt( rgbArr[ 0 ], 10 ), parseInt( rgbArr[ 1 ], 10 ), parseInt( rgbArr[ 2 ], 10 ) ].join( ',' ) + ')'; } diff --git a/resources/src/jquery/jquery.expandableField.js b/resources/src/jquery/jquery.expandableField.js index 48341bc5..221e6bbe 100644 --- a/resources/src/jquery/jquery.expandableField.js +++ b/resources/src/jquery/jquery.expandableField.js @@ -23,7 +23,7 @@ expandField: function ( e, context ) { context.config.beforeExpand.call( context.data.$field, context ); context.data.$field - .animate( { 'width': context.data.expandedWidth }, 'fast', function () { + .animate( { width: context.data.expandedWidth }, 'fast', function () { context.config.afterExpand.call( this, context ); } ); }, @@ -33,18 +33,19 @@ condenseField: function ( e, context ) { context.config.beforeCondense.call( context.data.$field, context ); context.data.$field - .animate( { 'width': context.data.condensedWidth }, 'fast', function () { + .animate( { width: context.data.condensedWidth }, 'fast', function () { context.config.afterCondense.call( this, context ); } ); }, /** * Sets the value of a property, and updates the widget accordingly - * @param property String Name of property - * @param value Mixed Value to set property with + * + * @param {String} property Name of property + * @param {Mixed} value Value to set property with */ configure: function ( context, property, value ) { // TODO: Validate creation using fallback values - context.config[property] = value; + context.config[ property ] = value; } }; @@ -87,20 +88,20 @@ /* API */ // Handle various calling styles if ( args.length > 0 ) { - if ( typeof args[0] === 'object' ) { + if ( typeof args[ 0 ] === 'object' ) { // Apply set of properties - for ( key in args[0] ) { - $.expandableField.configure( context, key, args[0][key] ); + for ( key in args[ 0 ] ) { + $.expandableField.configure( context, key, args[ 0 ][ key ] ); } - } else if ( typeof args[0] === 'string' ) { + } else if ( typeof args[ 0 ] === 'string' ) { if ( args.length > 1 ) { // Set property values - $.expandableField.configure( context, args[0], args[1] ); + $.expandableField.configure( context, args[ 0 ], args[ 1 ] ); // TODO: Do we need to check both null and undefined? } else if ( returnValue === null || returnValue === undefined ) { // Get property values, but don't give access to internal data - returns only the first - returnValue = ( args[0] in context.config ? undefined : context.config[args[0]] ); + returnValue = ( args[ 0 ] in context.config ? undefined : context.config[ args[ 0 ] ] ); } } } diff --git a/resources/src/jquery/jquery.farbtastic.js b/resources/src/jquery/jquery.farbtastic.js index d7024cc8..f70913f9 100644 --- a/resources/src/jquery/jquery.farbtastic.js +++ b/resources/src/jquery/jquery.farbtastic.js @@ -52,10 +52,10 @@ jQuery._farbtastic = function (container, callback) { if (this.currentStyle.backgroundImage != 'none') { var image = this.currentStyle.backgroundImage; image = this.currentStyle.backgroundImage.slice(5, image.length - 2); - $(this).css({ - 'backgroundImage': 'none', - 'filter': "progid:DXImageTransform.Microsoft.AlphaImageLoader(enabled=true, sizingMethod=crop, src='" + image + "')" - }); + $(this).css( { + backgroundImage: 'none', + filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader(enabled=true, sizingMethod=crop, src='" + image + "')" + } ); } }); } diff --git a/resources/src/jquery/jquery.getAttrs.js b/resources/src/jquery/jquery.getAttrs.js index 64827fb7..3064b423 100644 --- a/resources/src/jquery/jquery.getAttrs.js +++ b/resources/src/jquery/jquery.getAttrs.js @@ -8,7 +8,7 @@ function serializeControls( controls ) { len = controls.length; for ( i = 0; i < len; i++ ) { - data[ controls[i].name ] = controls[i].value; + data[ controls[ i ].name ] = controls[ i ].value; } return data; @@ -23,7 +23,7 @@ function serializeControls( controls ) { * @return {Object} */ jQuery.fn.getAttrs = function () { - return serializeControls( this[0].attributes ); + return serializeControls( this[ 0 ].attributes ); }; /** diff --git a/resources/src/jquery/jquery.hidpi.js b/resources/src/jquery/jquery.hidpi.js index 8fca0567..aa6590bf 100644 --- a/resources/src/jquery/jquery.hidpi.js +++ b/resources/src/jquery/jquery.hidpi.js @@ -27,14 +27,15 @@ $.devicePixelRatio = function () { if ( window.devicePixelRatio !== undefined ) { // Most web browsers: - // * WebKit (Safari, Chrome, Android browser, etc) + // * WebKit/Blink (Safari, Chrome, Android browser, etc) // * Opera // * Firefox 18+ + // * Microsoft Edge (Windows 10) return window.devicePixelRatio; } else if ( window.msMatchMedia !== undefined ) { // Windows 8 desktops / tablets, probably Windows Phone 8 // - // IE 10 doesn't report pixel ratio directly, but we can get the + // IE 10/11 doesn't report pixel ratio directly, but we can get the // screen DPI and divide by 96. We'll bracket to [1, 1.5, 2.0] for // simplicity, but you may get different values depending on zoom // factor, size of screen and orientation in Metro IE. @@ -53,6 +54,52 @@ $.devicePixelRatio = function () { }; /** + * Bracket a given device pixel ratio to one of [1, 1.5, 2]. + * + * This is useful for grabbing images on the fly with sizes based on the display + * density, without causing slowdown and extra thumbnail renderings on devices + * that are slightly different from the most common sizes. + * + * The bracketed ratios match the default 'srcset' output on MediaWiki thumbnails, + * so will be consistent with default renderings. + * + * @static + * @inheritable + * @return {number} Device pixel ratio + */ +$.bracketDevicePixelRatio = function ( baseRatio ) { + if ( baseRatio > 1.5 ) { + return 2; + } else if ( baseRatio > 1 ) { + return 1.5; + } else { + return 1; + } +}; + +/** + * Get reported or approximate device pixel ratio, bracketed to [1, 1.5, 2]. + * + * This is useful for grabbing images on the fly with sizes based on the display + * density, without causing slowdown and extra thumbnail renderings on devices + * that are slightly different from the most common sizes. + * + * The bracketed ratios match the default 'srcset' output on MediaWiki thumbnails, + * so will be consistent with default renderings. + * + * - 1.0 means 1 CSS pixel is 1 hardware pixel + * - 1.5 means 1 CSS pixel is 1.5 hardware pixels + * - 2.0 means 1 CSS pixel is 2 hardware pixels + * + * @static + * @inheritable + * @return {number} Device pixel ratio + */ +$.bracketedDevicePixelRatio = function () { + return $.bracketDevicePixelRatio( $.devicePixelRatio() ); +}; + +/** * Implement responsive images based on srcset attributes, if browser has no * native srcset support. * @@ -106,11 +153,11 @@ $.matchSrcSet = function ( devicePixelRatio, srcset ) { selectedSrc = null; candidates = srcset.split( / *, */ ); for ( i = 0; i < candidates.length; i++ ) { - candidate = candidates[i]; + candidate = candidates[ i ]; bits = candidate.split( / +/ ); - src = bits[0]; - if ( bits.length > 1 && bits[1].charAt( bits[1].length - 1 ) === 'x' ) { - ratioStr = bits[1].slice( 0, -1 ); + src = bits[ 0 ]; + if ( bits.length > 1 && bits[ 1 ].charAt( bits[ 1 ].length - 1 ) === 'x' ) { + ratioStr = bits[ 1 ].slice( 0, -1 ); ratio = parseFloat( ratioStr ); if ( ratio <= devicePixelRatio && ratio > selectedRatio ) { selectedRatio = ratio; diff --git a/resources/src/jquery/jquery.highlightText.js b/resources/src/jquery/jquery.highlightText.js index 13382182..e37f19b0 100644 --- a/resources/src/jquery/jquery.highlightText.js +++ b/resources/src/jquery/jquery.highlightText.js @@ -3,7 +3,7 @@ * TODO: Add a function for restoring the previous text. * TODO: Accept mappings for converting shortcuts like WP: to Wikipedia:. */ -( function ( $ ) { +( function ( $, mw ) { $.highlightText = { @@ -12,10 +12,10 @@ var i, patArray = pat.split( ' ' ); for ( i = 0; i < patArray.length; i++ ) { - if ( patArray[i].length === 0 ) { + if ( patArray[ i ].length === 0 ) { continue; } - $.highlightText.innerHighlight( node, patArray[i] ); + $.highlightText.innerHighlight( node, patArray[ i ] ); } return node; }, @@ -23,15 +23,14 @@ // scans a node looking for the pattern and wraps a span around each match innerHighlight: function ( node, pat ) { var i, match, pos, spannode, middlebit, middleclone; - // if this is a text node - if ( node.nodeType === 3 ) { + if ( node.nodeType === Node.TEXT_NODE ) { // TODO - need to be smarter about the character matching here. // non latin characters can make regex think a new word has begun: do not use \b // http://stackoverflow.com/questions/3787072/regex-wordwrap-with-utf8-characters-in-js // look for an occurrence of our pattern and store the starting position - match = node.data.match( new RegExp( '(^|\\s)' + $.escapeRE( pat ), 'i' ) ); + match = node.data.match( new RegExp( '(^|\\s)' + mw.RegExp.escape( pat ), 'i' ) ); if ( match ) { - pos = match.index + match[1].length; // include length of any matched spaces + pos = match.index + match[ 1 ].length; // include length of any matched spaces // create the span wrapper for the matched text spannode = document.createElement( 'span' ); spannode.className = 'highlight'; @@ -46,8 +45,8 @@ // replace the matched node, with our span-wrapped clone of the matched node middlebit.parentNode.replaceChild( spannode, middlebit ); } - // if this is an element with childnodes, and not a script, style or an element we created - } else if ( node.nodeType === 1 + } else if ( node.nodeType === Node.ELEMENT_NODE + // element with childnodes, and not a script, style or an element we created && node.childNodes && !/(script|style)/i.test( node.tagName ) && !( node.tagName.toLowerCase() === 'span' @@ -56,7 +55,7 @@ ) { for ( i = 0; i < node.childNodes.length; ++i ) { // call the highlight function for each child node - $.highlightText.innerHighlight( node.childNodes[i], pat ); + $.highlightText.innerHighlight( node.childNodes[ i ], pat ); } } } @@ -70,4 +69,4 @@ } ); }; -}( jQuery ) ); +}( jQuery, mediaWiki ) ); diff --git a/resources/src/jquery/jquery.localize.js b/resources/src/jquery/jquery.localize.js index 0b423545..f5932b24 100644 --- a/resources/src/jquery/jquery.localize.js +++ b/resources/src/jquery/jquery.localize.js @@ -5,16 +5,16 @@ /** * Gets a localized message, using parameters from options if present. - * @ignore * + * @ignore * @param {Object} options * @param {string} key * @return {string} Localized message */ function msg( options, key ) { - var args = options.params[key] || []; + var args = options.params[ key ] || []; // Format: mw.msg( key [, p1, p2, ...] ) - args.unshift( options.prefix + ( options.keys[key] || key ) ); + args.unshift( options.prefix + ( options.keys[ key ] || key ) ); return mw.msg.apply( mw, args ); } @@ -108,7 +108,7 @@ function msg( options, key ) { */ $.fn.localize = function ( options ) { var $target = this, - attributes = ['title', 'alt', 'placeholder']; + attributes = [ 'title', 'alt', 'placeholder' ]; // Extend options options = $.extend( { diff --git a/resources/src/jquery/jquery.makeCollapsible.js b/resources/src/jquery/jquery.makeCollapsible.js index f7c42177..19fdb263 100644 --- a/resources/src/jquery/jquery.makeCollapsible.js +++ b/resources/src/jquery/jquery.makeCollapsible.js @@ -159,8 +159,13 @@ } if ( e ) { - if ( e.type === 'click' && options.linksPassthru && $.nodeName( e.target, 'a' ) ) { - // Don't fire if a link was clicked, if requested (for premade togglers by default) + if ( + e.type === 'click' && + options.linksPassthru && + $.nodeName( e.target, 'a' ) && + $( e.target ).attr( 'href' ) !== '#' + ) { + // Don't fire if a link with href !== '#' was clicked, if requested (for premade togglers by default) return; } else if ( e.type === 'keypress' && e.which !== 13 && e.which !== 32 ) { // Only handle keypresses on the "Enter" or "Space" keys diff --git a/resources/src/jquery/jquery.mwExtension.js b/resources/src/jquery/jquery.mwExtension.js index e6e33ade..27ceb2bc 100644 --- a/resources/src/jquery/jquery.mwExtension.js +++ b/resources/src/jquery/jquery.mwExtension.js @@ -1,9 +1,11 @@ /* * JavaScript backwards-compatibility alternatives and other convenience functions + * + * @deprecated since 1.26 Dated collection of miscellaneous utilities. Methods are + * either trivially inline, obsolete, or have a better place elsewhere. */ -( function ( $ ) { - - $.extend( { +( function ( $, mw ) { + $.each( { trimLeft: function ( str ) { return str === null ? '' : str.toString().replace( /^\s+/, '' ); }, @@ -14,9 +16,6 @@ ucFirst: function ( str ) { return str.charAt( 0 ).toUpperCase() + str.slice( 1 ); }, - escapeRE: function ( str ) { - return str.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ); - }, isDomElement: function ( el ) { return !!el && !!el.nodeType; }, @@ -28,7 +27,7 @@ return true; } // the for-loop could potentially contain prototypes - // to avoid that we check it's length first + // to avoid that we check its length first if ( v.length === 0 ) { return true; } @@ -45,11 +44,11 @@ return false; } for ( var i = 0; i < arrThis.length; i++ ) { - if ( $.isArray( arrThis[i] ) ) { - if ( !$.compareArray( arrThis[i], arrAgainst[i] ) ) { + if ( $.isArray( arrThis[ i ] ) ) { + if ( !$.compareArray( arrThis[ i ], arrAgainst[ i ] ) ) { return false; } - } else if ( arrThis[i] !== arrAgainst[i] ) { + } else if ( arrThis[ i ] !== arrAgainst[ i ] ) { return false; } } @@ -72,24 +71,24 @@ // Check if this property is also present in the other object if ( prop in objectB ) { // Compare the types of the properties - type = typeof objectA[prop]; - if ( type === typeof objectB[prop] ) { + type = typeof objectA[ prop ]; + if ( type === typeof objectB[ prop ] ) { // Recursively check objects inside this one switch ( type ) { case 'object' : - if ( !$.compareObject( objectA[prop], objectB[prop] ) ) { + if ( !$.compareObject( objectA[ prop ], objectB[ prop ] ) ) { return false; } break; case 'function' : // Functions need to be strings to compare them properly - if ( objectA[prop].toString() !== objectB[prop].toString() ) { + if ( objectA[ prop ].toString() !== objectB[ prop ].toString() ) { return false; } break; default: // Strings, numbers - if ( objectA[prop] !== objectB[prop] ) { + if ( objectA[ prop ] !== objectB[ prop ] ) { return false; } break; @@ -117,6 +116,12 @@ } return true; } + }, function ( key, value ) { + mw.log.deprecate( $, key, value ); } ); -}( jQuery ) ); + mw.log.deprecate( $, 'escapeRE', function ( str ) { + return str.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ); + }, 'Use mediawiki.RegExp instead.' ); + +} )( jQuery, mediaWiki ); diff --git a/resources/src/jquery/jquery.placeholder.js b/resources/src/jquery/jquery.placeholder.js index d50422e2..9c18a919 100644 --- a/resources/src/jquery/jquery.placeholder.js +++ b/resources/src/jquery/jquery.placeholder.js @@ -13,23 +13,115 @@ * @version 2.1.0 * @license MIT */ -( function ($) { +( function ( $ ) { - var isInputSupported = 'placeholder' in document.createElement('input'), - isTextareaSupported = 'placeholder' in document.createElement('textarea'), + var isInputSupported = 'placeholder' in document.createElement( 'input' ), + isTextareaSupported = 'placeholder' in document.createElement( 'textarea' ), prototype = $.fn, valHooks = $.valHooks, propHooks = $.propHooks, hooks, placeholder; - if (isInputSupported && isTextareaSupported) { + function safeActiveElement() { + // Avoid IE9 `document.activeElement` of death + // https://github.com/mathiasbynens/jquery-placeholder/pull/99 + try { + return document.activeElement; + } catch ( err ) {} + } + + function args( elem ) { + // Return an object of element attributes + var newAttrs = {}, + rinlinejQuery = /^jQuery\d+$/; + $.each( elem.attributes, function ( i, attr ) { + if ( attr.specified && !rinlinejQuery.test( attr.name ) ) { + newAttrs[ attr.name ] = attr.value; + } + } ); + return newAttrs; + } + + function clearPlaceholder( event, value ) { + var input = this, + $input = $( input ); + if ( input.value === $input.attr( 'placeholder' ) && $input.hasClass( 'placeholder' ) ) { + if ( $input.data( 'placeholder-password' ) ) { + $input = $input.hide().next().show().attr( 'id', $input.removeAttr( 'id' ).data( 'placeholder-id' ) ); + // If `clearPlaceholder` was called from `$.valHooks.input.set` + if ( event === true ) { + $input[ 0 ].value = value; + return value; + } + $input.focus(); + } else { + input.value = ''; + $input.removeClass( 'placeholder' ); + if ( input === safeActiveElement() ) { + input.select(); + } + } + } + } - placeholder = prototype.placeholder = function (text) { + function setPlaceholder() { + var $replacement, + input = this, + $input = $( input ), + id = this.id; + if ( !input.value ) { + if ( input.type === 'password' ) { + if ( !$input.data( 'placeholder-textinput' ) ) { + try { + $replacement = $input.clone().attr( { type: 'text' } ); + } catch ( e ) { + $replacement = $( '<input>' ).attr( $.extend( args( this ), { type: 'text' } ) ); + } + $replacement + .removeAttr( 'name' ) + .data( { + 'placeholder-password': $input, + 'placeholder-id': id + } ) + .bind( 'focus.placeholder drop.placeholder', clearPlaceholder ); + $input + .data( { + 'placeholder-textinput': $replacement, + 'placeholder-id': id + } ) + .before( $replacement ); + } + $input = $input.removeAttr( 'id' ).hide().prev().attr( 'id', id ).show(); + // Note: `$input[0] != input` now! + } + $input.addClass( 'placeholder' ); + $input[ 0 ].value = $input.attr( 'placeholder' ); + } else { + $input.removeClass( 'placeholder' ); + } + } + + function changePlaceholder( text ) { + var hasArgs = arguments.length, + $input = this; + if ( hasArgs ) { + if ( $input.attr( 'placeholder' ) !== text ) { + $input.prop( 'placeholder', text ); + if ( $input.hasClass( 'placeholder' ) ) { + $input[ 0 ].value = text; + } + } + } + } + + if ( isInputSupported && isTextareaSupported ) { + + placeholder = prototype.placeholder = function ( text ) { var hasArgs = arguments.length; - if (hasArgs) { - changePlaceholder.call(this, text); + if ( hasArgs ) { + changePlaceholder.call( this, text ); } return this; @@ -39,25 +131,25 @@ } else { - placeholder = prototype.placeholder = function (text) { + placeholder = prototype.placeholder = function ( text ) { var $this = this, hasArgs = arguments.length; - if (hasArgs) { - changePlaceholder.call(this, text); + if ( hasArgs ) { + changePlaceholder.call( this, text ); } $this - .filter((isInputSupported ? 'textarea' : ':input') + '[placeholder]') + .filter( ( isInputSupported ? 'textarea' : ':input' ) + '[placeholder]' ) .filter( function () { - return !$(this).data('placeholder-enabled'); - }) - .bind({ + return !$( this ).data( 'placeholder-enabled' ); + } ) + .bind( { 'focus.placeholder drop.placeholder': clearPlaceholder, 'blur.placeholder': setPlaceholder - }) - .data('placeholder-enabled', true) - .trigger('blur.placeholder'); + } ) + .data( 'placeholder-enabled', true ) + .trigger( 'blur.placeholder' ); return $this; }; @@ -65,36 +157,36 @@ placeholder.textarea = isTextareaSupported; hooks = { - 'get': function (element) { - var $element = $(element), - $passwordInput = $element.data('placeholder-password'); - if ($passwordInput) { - return $passwordInput[0].value; + get: function ( element ) { + var $element = $( element ), + $passwordInput = $element.data( 'placeholder-password' ); + if ( $passwordInput ) { + return $passwordInput[ 0 ].value; } - return $element.data('placeholder-enabled') && $element.hasClass('placeholder') ? '' : element.value; + return $element.data( 'placeholder-enabled' ) && $element.hasClass( 'placeholder' ) ? '' : element.value; }, - 'set': function (element, value) { - var $element = $(element), - $passwordInput = $element.data('placeholder-password'); - if ($passwordInput) { - $passwordInput[0].value = value; + set: function ( element, value ) { + var $element = $( element ), + $passwordInput = $element.data( 'placeholder-password' ); + if ( $passwordInput ) { + $passwordInput[ 0 ].value = value; return value; } - if (!$element.data('placeholder-enabled')) { + if ( !$element.data( 'placeholder-enabled' ) ) { element.value = value; return value; } - if (!value) { + if ( !value ) { element.value = value; // Issue #56: Setting the placeholder causes problems if the element continues to have focus. - if (element !== safeActiveElement()) { + if ( element !== safeActiveElement() ) { // We can't use `triggerHandler` here because of dummy text/password inputs :( - setPlaceholder.call(element); + setPlaceholder.call( element ); } - } else if ($element.hasClass('placeholder')) { - if (!clearPlaceholder.call(element, true, value)) { + } else if ( $element.hasClass( 'placeholder' ) ) { + if ( !clearPlaceholder.call( element, true, value ) ) { element.value = value; } } else { @@ -105,125 +197,32 @@ } }; - if (!isInputSupported) { + if ( !isInputSupported ) { valHooks.input = hooks; propHooks.value = hooks; } - if (!isTextareaSupported) { + if ( !isTextareaSupported ) { valHooks.textarea = hooks; propHooks.value = hooks; } $( function () { // Look for forms - $(document).delegate('form', 'submit.placeholder', function () { + $( document ).delegate( 'form', 'submit.placeholder', function () { // Clear the placeholder values so they don't get submitted - var $inputs = $('.placeholder', this).each(clearPlaceholder); + var $inputs = $( '.placeholder', this ).each( clearPlaceholder ); setTimeout( function () { - $inputs.each(setPlaceholder); - }, 10); - }); - }); + $inputs.each( setPlaceholder ); + }, 10 ); + } ); + } ); // Clear placeholder values upon page reload - $(window).bind('beforeunload.placeholder', function () { - $('.placeholder').each( function () { + $( window ).bind( 'beforeunload.placeholder', function () { + $( '.placeholder' ).each( function () { this.value = ''; - }); - }); + } ); + } ); } - - function args(elem) { - // Return an object of element attributes - var newAttrs = {}, - rinlinejQuery = /^jQuery\d+$/; - $.each(elem.attributes, function (i, attr) { - if (attr.specified && !rinlinejQuery.test(attr.name)) { - newAttrs[attr.name] = attr.value; - } - }); - return newAttrs; - } - - function clearPlaceholder(event, value) { - var input = this, - $input = $(input); - if (input.value === $input.attr('placeholder') && $input.hasClass('placeholder')) { - if ($input.data('placeholder-password')) { - $input = $input.hide().next().show().attr('id', $input.removeAttr('id').data('placeholder-id')); - // If `clearPlaceholder` was called from `$.valHooks.input.set` - if (event === true) { - $input[0].value = value; - return value; - } - $input.focus(); - } else { - input.value = ''; - $input.removeClass('placeholder'); - if (input === safeActiveElement()) { - input.select(); - } - } - } - } - - function setPlaceholder() { - var $replacement, - input = this, - $input = $(input), - id = this.id; - if (!input.value) { - if (input.type === 'password') { - if (!$input.data('placeholder-textinput')) { - try { - $replacement = $input.clone().attr({ 'type': 'text' }); - } catch (e) { - $replacement = $('<input>').attr($.extend(args(this), { 'type': 'text' })); - } - $replacement - .removeAttr('name') - .data({ - 'placeholder-password': $input, - 'placeholder-id': id - }) - .bind('focus.placeholder drop.placeholder', clearPlaceholder); - $input - .data({ - 'placeholder-textinput': $replacement, - 'placeholder-id': id - }) - .before($replacement); - } - $input = $input.removeAttr('id').hide().prev().attr('id', id).show(); - // Note: `$input[0] != input` now! - } - $input.addClass('placeholder'); - $input[0].value = $input.attr('placeholder'); - } else { - $input.removeClass('placeholder'); - } - } - - function safeActiveElement() { - // Avoid IE9 `document.activeElement` of death - // https://github.com/mathiasbynens/jquery-placeholder/pull/99 - try { - return document.activeElement; - } catch (err) {} - } - - function changePlaceholder(text) { - var hasArgs = arguments.length, - $input = this; - if (hasArgs) { - if ($input.attr('placeholder') !== text) { - $input.prop('placeholder', text); - if ($input.hasClass('placeholder')) { - $input[0].value = text; - } - } - } - } - -}(jQuery)); +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.qunit.completenessTest.js b/resources/src/jquery/jquery.qunit.completenessTest.js index 556bf8c7..785b2738 100644 --- a/resources/src/jquery/jquery.qunit.completenessTest.js +++ b/resources/src/jquery/jquery.qunit.completenessTest.js @@ -51,14 +51,14 @@ /** * CompletenessTest - * @constructor * + * @constructor * @example * var myTester = new CompletenessTest( myLib ); - * @param masterVariable {Object} The root variable that contains all object + * @param {Object} masterVariable The root variable that contains all object * members. CompletenessTest will recursively traverse objects and keep track * of all methods. - * @param ignoreFn {Function} Optionally pass a function to filter out certain + * @param {Function} [ignoreFn] Optionally pass a function to filter out certain * methods. Example: You may want to filter out instances of jQuery or some * other constructor. Otherwise "missingTests" will include all methods that * were not called from that instance. @@ -132,7 +132,7 @@ elOutputWrapper.appendChild( elContainer ); util.each( style, function ( key, value ) { - elOutputWrapper.style[key] = value; + elOutputWrapper.style[ key ] = value; } ); return elOutputWrapper; } @@ -186,12 +186,12 @@ * Depending on the action it either injects our listener into the methods, or * reads from our tracker and records which methods have not been called by the test suite. * - * @param currName {String|Null} Name of the given object member (Initially this is null). - * @param currVar {mixed} The variable to check (initially an object, + * @param {String|Null} currName Name of the given object member (Initially this is null). + * @param {mixed} currVar The variable to check (initially an object, * further down it could be anything). - * @param masterVariable {Object} Throughout our interation, always keep track of the master/root. + * @param {Object} masterVariable Throughout our interation, always keep track of the master/root. * Initially this is the same as currVar. - * @param parentPathArray {Array} Array of names that indicate our breadcrumb path starting at + * @param {Array} parentPathArray Array of names that indicate our breadcrumb path starting at * masterVariable. Not including currName. */ walkTheObject: function ( currObj, currName, masterVariable, parentPathArray ) { @@ -201,7 +201,7 @@ if ( currName ) { currPathArray.push( currName ); - currVal = currObj[currName]; + currVal = currObj[ currName ]; } else { currName = '(root)'; currVal = currObj; @@ -258,12 +258,12 @@ * was called during the test suite (as far as the tracker knows). * If not it adds it to missingTests. * - * @param fnName {String} + * @param {String} fnName * @return {Boolean} */ hasTest: function ( fnName ) { if ( !( fnName in this.methodCallTracker ) ) { - this.missingTests[fnName] = true; + this.missingTests[ fnName ] = true; return false; } return true; @@ -275,9 +275,9 @@ * Injects a function (such as a spy that updates methodCallTracker when * it's called) inside another function. * - * @param masterVariable {Object} - * @param objectPathArray {Array} - * @param injectFn {Function} + * @param {Object} masterVariable + * @param {Array} objectPathArray + * @param {Function} injectFn */ injectCheck: function ( obj, key, injectFn ) { var spy, @@ -291,8 +291,11 @@ // Make the spy inherit from the original so that its static methods are also // visible in the spy (e.g. when we inject a check into mw.log, mw.log.warn // must remain accessible). + // XXX: https://github.com/jshint/jshint/issues/2656 + /*jshint ignore:start */ /*jshint proto:true */ spy.__proto__ = val; + /*jshint ignore:end */ // Objects are by reference, members (unless objects) are not. obj[ key ] = spy; diff --git a/resources/src/jquery/jquery.spinner.js b/resources/src/jquery/jquery.spinner.js index 361d3e08..41c555b7 100644 --- a/resources/src/jquery/jquery.spinner.js +++ b/resources/src/jquery/jquery.spinner.js @@ -67,7 +67,7 @@ opts = $.extend( {}, defaults, opts ); - var $spinner = $( '<div>', { 'class': 'mw-spinner', 'title': '...' } ); + var $spinner = $( '<div>', { 'class': 'mw-spinner', title: '...' } ); if ( opts.id !== undefined ) { $spinner.attr( 'id', 'mw-spinner-' + opts.id ); } diff --git a/resources/src/jquery/jquery.suggestions.js b/resources/src/jquery/jquery.suggestions.js index 813c37ce..dc1c7794 100644 --- a/resources/src/jquery/jquery.suggestions.js +++ b/resources/src/jquery/jquery.suggestions.js @@ -53,6 +53,12 @@ * @param {Function} options.result.select Called in context of the suggestions-result-current element. * @param {jQuery} options.result.select.$textbox * + * @param {Object} [options.update] Set of callbacks for listening to a change in the text input. + * + * @param {Function} options.update.before Called right after the user changes the textbox text. + * @param {Function} options.update.after Called after results are updated either from the cache or + * the API as a result of the user input. + * * @param {jQuery} [options.$region=this] The element to place the suggestions below and match width of. * * @param {string[]} [options.suggestions] Array of suggestions to display. @@ -83,7 +89,7 @@ * @param {boolean} [options.positionFromLeft] Sets `expandFrom=left`, for backwards * compatibility. * - * @param {boolean} [options.highlightInput=false] Whether to hightlight matched portions of the + * @param {boolean} [options.highlightInput=false] Whether to highlight matched portions of the * input or not. */ ( function ( $ ) { @@ -136,6 +142,7 @@ $.suggestions = { * call to this function still pending will be canceled. If the value in the * textbox is empty or hasn't changed since the last time suggestions were fetched, * this function does nothing. + * * @param {boolean} delayed Whether or not to delay this by the currently configured amount of time */ update: function ( context, delayed ) { @@ -144,6 +151,10 @@ $.suggestions = { cache = context.data.cache, cacheHit; + if ( typeof context.config.update.before === 'function' ) { + context.config.update.before.call( context.data.$textbox ); + } + // Only fetch if the value in the textbox changed and is not empty, or if the results were hidden // if the textbox is empty then clear the result div, but leave other settings intouched if ( val.length === 0 ) { @@ -158,6 +169,9 @@ $.suggestions = { if ( context.config.cache && hasOwn.call( cache, val ) ) { if ( +new Date() - cache[ val ].timestamp < context.config.cacheMaxAge ) { context.data.$textbox.suggestions( 'suggestions', cache[ val ].suggestions ); + if ( typeof context.config.update.after === 'function' ) { + context.config.update.after.call( context.data.$textbox ); + } cacheHit = true; } else { // Cache expired @@ -171,6 +185,9 @@ $.suggestions = { function ( suggestions ) { suggestions = suggestions.slice( 0, context.config.maxRows ); context.data.$textbox.suggestions( 'suggestions', suggestions ); + if ( typeof context.config.update.after === 'function' ) { + context.config.update.after.call( context.data.$textbox ); + } if ( context.config.cache ) { cache[ val ] = { suggestions: suggestions, @@ -213,6 +230,7 @@ $.suggestions = { /** * Sets the value of a property, and updates the widget accordingly + * * @param {string} property Name of property * @param {Mixed} value Value to set property with */ @@ -227,12 +245,13 @@ $.suggestions = { case 'cancel': case 'special': case 'result': + case 'update': case '$region': case 'expandFrom': - context.config[property] = value; + context.config[ property ] = value; break; case 'suggestions': - context.config[property] = value; + context.config[ property ] = value; // Update suggestions if ( context.data !== undefined ) { if ( context.data.$textbox.val().length === 0 ) { @@ -260,7 +279,7 @@ $.suggestions = { expandFrom = 'left'; // Catch invalid values, default to 'auto' - } else if ( $.inArray( expandFrom, ['left', 'right', 'start', 'end', 'auto'] ) === -1 ) { + } else if ( $.inArray( expandFrom, [ 'left', 'right', 'start', 'end', 'auto' ] ) === -1 ) { expandFrom = 'auto'; } @@ -319,11 +338,11 @@ $.suggestions = { expWidth = -1; for ( i = 0; i < context.config.suggestions.length; i++ ) { /*jshint loopfunc:true */ - text = context.config.suggestions[i]; + text = context.config.suggestions[ i ]; $result = $( '<div>' ) .addClass( 'suggestions-result' ) .attr( 'rel', i ) - .data( 'text', context.config.suggestions[i] ) + .data( 'text', context.config.suggestions[ i ] ) .mousemove( function () { context.data.selectedWithMouse = true; $.suggestions.highlight( @@ -335,7 +354,7 @@ $.suggestions = { .appendTo( $results ); // Allow custom rendering if ( typeof context.config.result.render === 'function' ) { - context.config.result.render.call( $result, context.config.suggestions[i], context ); + context.config.result.render.call( $result, context.config.suggestions[ i ], context ); } else { $result.text( text ); } @@ -376,28 +395,29 @@ $.suggestions = { } break; case 'maxRows': - context.config[property] = Math.max( 1, Math.min( 100, value ) ); + context.config[ property ] = Math.max( 1, Math.min( 100, value ) ); break; case 'delay': - context.config[property] = Math.max( 0, Math.min( 1200, value ) ); + context.config[ property ] = Math.max( 0, Math.min( 1200, value ) ); break; case 'cacheMaxAge': - context.config[property] = Math.max( 1, value ); + context.config[ property ] = Math.max( 1, value ); break; case 'maxExpandFactor': - context.config[property] = Math.max( 1, value ); + context.config[ property ] = Math.max( 1, value ); break; case 'cache': case 'submitOnClick': case 'positionFromLeft': case 'highlightInput': - context.config[property] = !!value; + context.config[ property ] = !!value; break; } }, /** * Highlight a result in the results table + * * @param {jQuery|string} result `<tr>` to highlight, or 'prev' or 'next' * @param {boolean} updateTextbox If true, put the suggestion in the textbox */ @@ -467,6 +487,7 @@ $.suggestions = { /** * Respond to keypress event + * * @param {number} key Code of key pressed */ keypress: function ( e, context, key ) { @@ -559,6 +580,7 @@ $.fn.suggestions = function () { cancel: function () {}, special: {}, result: {}, + update: {}, $region: $( this ), suggestions: [], maxRows: 10, @@ -577,18 +599,18 @@ $.fn.suggestions = function () { // Handle various calling styles if ( args.length > 0 ) { - if ( typeof args[0] === 'object' ) { + if ( typeof args[ 0 ] === 'object' ) { // Apply set of properties - for ( key in args[0] ) { - $.suggestions.configure( context, key, args[0][key] ); + for ( key in args[ 0 ] ) { + $.suggestions.configure( context, key, args[ 0 ][ key ] ); } - } else if ( typeof args[0] === 'string' ) { + } else if ( typeof args[ 0 ] === 'string' ) { if ( args.length > 1 ) { // Set property values - $.suggestions.configure( context, args[0], args[1] ); + $.suggestions.configure( context, args[ 0 ], args[ 1 ] ); } else if ( returnValue === null || returnValue === undefined ) { // Get property values, but don't give access to internal data - returns only the first - returnValue = ( args[0] in context.config ? undefined : context.config[args[0]] ); + returnValue = ( args[ 0 ] in context.config ? undefined : context.config[ args[ 0 ] ] ); } } } diff --git a/resources/src/jquery/jquery.tablesorter.js b/resources/src/jquery/jquery.tablesorter.js index ff5ff0a9..eaa138b9 100644 --- a/resources/src/jquery/jquery.tablesorter.js +++ b/resources/src/jquery/jquery.tablesorter.js @@ -8,7 +8,7 @@ * http://www.opensource.org/licenses/mit-license.php * http://www.gnu.org/licenses/gpl.html * - * Depends on mw.config (wgDigitTransformTable, wgDefaultDateFormat, wgContentLanguage) + * Depends on mw.config (wgDigitTransformTable, wgDefaultDateFormat, wgPageContentLanguage) * and mw.language.months. * * Uses 'tableSorterCollation' in mw.config (if available) @@ -70,8 +70,8 @@ var i, len = parsers.length; for ( i = 0; i < len; i++ ) { - if ( parsers[i].id.toLowerCase() === name.toLowerCase() ) { - return parsers[i]; + if ( parsers[ i ].id.toLowerCase() === name.toLowerCase() ) { + return parsers[ i ]; } } return false; @@ -95,8 +95,7 @@ return $node.attr( 'alt' ) || ''; // handle undefined alt } else { return $.map( $.makeArray( node.childNodes ), function ( elem ) { - // 1 is for document.ELEMENT_NODE (the constant is undefined on old browsers) - if ( elem.nodeType === 1 ) { + if ( elem.nodeType === Node.ELEMENT_NODE ) { return getElementSortKey( elem ); } else { return $.text( elem ); @@ -106,69 +105,83 @@ } } - function detectParserForColumn( table, rows, cellIndex ) { + function detectParserForColumn( table, rows, column ) { var l = parsers.length, + cellIndex, nodeValue, // Start with 1 because 0 is the fallback parser i = 1, + lastRowIndex = -1, rowIndex = 0, concurrent = 0, + empty = 0, needed = ( rows.length > 4 ) ? 5 : rows.length; while ( i < l ) { - if ( rows[rowIndex] && rows[rowIndex].cells[cellIndex] ) { - nodeValue = $.trim( getElementSortKey( rows[rowIndex].cells[cellIndex] ) ); + if ( rows[ rowIndex ] ) { + if ( rowIndex !== lastRowIndex ) { + lastRowIndex = rowIndex; + cellIndex = $( rows[ rowIndex ] ).data( 'columnToCell' )[ column ]; + nodeValue = $.trim( getElementSortKey( rows[ rowIndex ].cells[ cellIndex ] ) ); + } } else { nodeValue = ''; } if ( nodeValue !== '' ) { - if ( parsers[i].is( nodeValue, table ) ) { + if ( parsers[ i ].is( nodeValue, table ) ) { concurrent++; rowIndex++; if ( concurrent >= needed ) { // Confirmed the parser for multiple cells, let's return it - return parsers[i]; + return parsers[ i ]; } } else { // Check next parser, reset rows i++; rowIndex = 0; concurrent = 0; + empty = 0; } } else { // Empty cell + empty++; rowIndex++; - if ( rowIndex > rows.length ) { - rowIndex = 0; + if ( rowIndex >= rows.length ) { + if ( concurrent >= rows.length - empty ) { + // Confirmed the parser for all filled cells + return parsers[ i ]; + } + // Check next parser, reset rows i++; + rowIndex = 0; + concurrent = 0; + empty = 0; } } } // 0 is always the generic parser (text) - return parsers[0]; + return parsers[ 0 ]; } function buildParserCache( table, $headers ) { - var sortType, cells, len, i, parser, - rows = table.tBodies[0].rows, + var sortType, len, j, parser, + rows = table.tBodies[ 0 ].rows, + config = $( table ).data( 'tablesorter' ).config, parsers = []; - if ( rows[0] ) { - - cells = rows[0].cells; - len = cells.length; - - for ( i = 0; i < len; i++ ) { + if ( rows[ 0 ] ) { + len = config.columns; + for ( j = 0; j < len; j++ ) { parser = false; - sortType = $headers.eq( i ).data( 'sortType' ); + sortType = $headers.eq( config.columnToHeader[ j ] ).data( 'sortType' ); if ( sortType !== undefined ) { parser = getParserById( sortType ); } if ( parser === false ) { - parser = detectParserForColumn( table, rows, i ); + parser = detectParserForColumn( table, rows, j ); } parsers.push( parser ); @@ -181,33 +194,35 @@ function buildCache( table ) { var i, j, $row, cols, - totalRows = ( table.tBodies[0] && table.tBodies[0].rows.length ) || 0, - totalCells = ( table.tBodies[0].rows[0] && table.tBodies[0].rows[0].cells.length ) || 0, + totalRows = ( table.tBodies[ 0 ] && table.tBodies[ 0 ].rows.length ) || 0, config = $( table ).data( 'tablesorter' ).config, parsers = config.parsers, + len = parsers.length, + cellIndex, cache = { row: [], normalized: [] }; - for ( i = 0; i < totalRows; ++i ) { + for ( i = 0; i < totalRows; i++ ) { // Add the table data to main data array - $row = $( table.tBodies[0].rows[i] ); + $row = $( table.tBodies[ 0 ].rows[ i ] ); cols = []; // if this is a child row, add it to the last row's children and // continue to the next row if ( $row.hasClass( config.cssChildRow ) ) { - cache.row[cache.row.length - 1] = cache.row[cache.row.length - 1].add( $row ); + cache.row[ cache.row.length - 1 ] = cache.row[ cache.row.length - 1 ].add( $row ); // go to the next for loop continue; } cache.row.push( $row ); - for ( j = 0; j < totalCells; ++j ) { - cols.push( parsers[j].format( getElementSortKey( $row[0].cells[j] ), table, $row[0].cells[j] ) ); + for ( j = 0; j < len; j++ ) { + cellIndex = $row.data( 'columnToCell' )[ j ]; + cols.push( parsers[ j ].format( getElementSortKey( $row[ 0 ].cells[ cellIndex ] ) ) ); } cols.push( cache.normalized.length ); // add position for rowCache @@ -223,20 +238,20 @@ row = cache.row, normalized = cache.normalized, totalRows = normalized.length, - checkCell = ( normalized[0].length - 1 ), + checkCell = ( normalized[ 0 ].length - 1 ), fragment = document.createDocumentFragment(); for ( i = 0; i < totalRows; i++ ) { - pos = normalized[i][checkCell]; + pos = normalized[ i ][ checkCell ]; - l = row[pos].length; + l = row[ pos ].length; for ( j = 0; j < l; j++ ) { - fragment.appendChild( row[pos][j] ); + fragment.appendChild( row[ pos ][ j ] ); } } - table.tBodies[0].appendChild( fragment ); + table.tBodies[ 0 ].appendChild( fragment ); $( table ).trigger( 'sortEnd.tablesorter' ); } @@ -249,7 +264,8 @@ * * After this, it will look at all rows at the bottom for footer rows * And place these in a tfoot using similar rules. - * @param $table jQuery object for a <table> + * + * @param {jQuery} $table object for a <table> */ function emulateTHeadAndFoot( $table ) { var $thead, $tfoot, i, len, @@ -270,26 +286,37 @@ $tfoot = $( '<tfoot>' ); len = $rows.length; for ( i = len - 1; i >= 0; i-- ) { - if ( $( $rows[i] ).children( 'td' ).length ) { + if ( $( $rows[ i ] ).children( 'td' ).length ) { break; } - $tfoot.prepend( $( $rows[i] ) ); + $tfoot.prepend( $( $rows[ i ] ) ); } $table.append( $tfoot ); } } + function uniqueElements( array ) { + var uniques = []; + $.each( array, function ( index, elem ) { + if ( elem !== undefined && $.inArray( elem, uniques ) === -1 ) { + uniques.push( elem ); + } + } ); + return uniques; + } + function buildHeaders( table, msg ) { var config = $( table ).data( 'tablesorter' ).config, maxSeen = 0, colspanOffset = 0, columns, - i, + k, $cell, rowspan, colspan, headerCount, longestTR, + headerIndex, exploded, $tableHeaders = $( [] ), $tableRows = $( 'thead:eq(0) > tr', table ); @@ -308,7 +335,7 @@ colspan = Number( cell.colSpan ); // Skip the spots in the exploded matrix that are already filled - while ( exploded[rowIndex] && exploded[rowIndex][columnIndex] !== undefined ) { + while ( exploded[ rowIndex ] && exploded[ rowIndex ][ columnIndex ] !== undefined ) { ++columnIndex; } @@ -316,10 +343,10 @@ // in the exploded matrix rowspan times colspan times, with the proper offsets for ( matrixColumnIndex = columnIndex; matrixColumnIndex < columnIndex + colspan; ++matrixColumnIndex ) { for ( matrixRowIndex = rowIndex; matrixRowIndex < rowIndex + rowspan; ++matrixRowIndex ) { - if ( !exploded[matrixRowIndex] ) { - exploded[matrixRowIndex] = []; + if ( !exploded[ matrixRowIndex ] ) { + exploded[ matrixRowIndex ] = []; } - exploded[matrixRowIndex][matrixColumnIndex] = cell; + exploded[ matrixRowIndex ][ matrixColumnIndex ] = cell; } } } ); @@ -333,49 +360,65 @@ } } ); // We cannot use $.unique() here because it sorts into dom order, which is undesirable - $tableHeaders = $( uniqueElements( exploded[longestTR] ) ).filter( 'th' ); + $tableHeaders = $( uniqueElements( exploded[ longestTR ] ) ).filter( 'th' ); } // as each header can span over multiple columns (using colspan=N), // we have to bidirectionally map headers to their columns and columns to their headers - $tableHeaders.each( function ( headerIndex ) { + config.columnToHeader = []; + config.headerToColumns = []; + config.headerList = []; + headerIndex = 0; + $tableHeaders.each( function () { $cell = $( this ); columns = []; - for ( i = 0; i < this.colSpan; i++ ) { - config.columnToHeader[ colspanOffset + i ] = headerIndex; - columns.push( colspanOffset + i ); - } - - config.headerToColumns[ headerIndex ] = columns; - colspanOffset += this.colSpan; - - $cell.data( { - headerIndex: headerIndex, - order: 0, - count: 0 - } ); - - if ( $cell.hasClass( config.unsortableClass ) ) { - $cell.data( 'sortDisabled', true ); - } - - if ( !$cell.data( 'sortDisabled' ) ) { + if ( !$cell.hasClass( config.unsortableClass ) ) { $cell .addClass( config.cssHeader ) .prop( 'tabIndex', 0 ) .attr( { role: 'columnheader button', - title: msg[1] + title: msg[ 1 ] } ); + + for ( k = 0; k < this.colSpan; k++ ) { + config.columnToHeader[ colspanOffset + k ] = headerIndex; + columns.push( colspanOffset + k ); + } + + config.headerToColumns[ headerIndex ] = columns; + + $cell.data( { + headerIndex: headerIndex, + order: 0, + count: 0 + } ); + + // add only sortable cells to headerList + config.headerList[ headerIndex ] = this; + headerIndex++; } - // add cell to headerList - config.headerList[headerIndex] = this; + colspanOffset += this.colSpan; } ); - return $tableHeaders; + // number of columns with extended colspan, inclusive unsortable + // parsers[j], cache[][j], columnToHeader[j], columnToCell[j] have so many elements + config.columns = colspanOffset; + + return $tableHeaders.not( '.' + config.unsortableClass ); + } + function isValueInArray( v, a ) { + var i, + len = a.length; + for ( i = 0; i < len; i++ ) { + if ( a[ i ][ 0 ] === v ) { + return true; + } + } + return false; } /** @@ -391,7 +434,7 @@ $.each( headerToColumns, function ( headerIndex, columns ) { $.each( columns, function ( i, columnIndex ) { - var header = $headers[headerIndex], + var header = $headers[ headerIndex ], $header = $( header ); if ( !isValueInArray( columnIndex, sortList ) ) { @@ -403,10 +446,10 @@ } else { // Column shall be sorted: Apply designated count and order. $.each( sortList, function ( j, sortColumn ) { - if ( sortColumn[0] === i ) { + if ( sortColumn[ 0 ] === i ) { $header.data( { - order: sortColumn[1], - count: sortColumn[1] + 1 + order: sortColumn[ 1 ], + count: sortColumn[ 1 ] + 1 } ); return false; } @@ -417,35 +460,14 @@ } ); } - function isValueInArray( v, a ) { - var i, - len = a.length; - for ( i = 0; i < len; i++ ) { - if ( a[i][0] === v ) { - return true; - } - } - return false; - } - - function uniqueElements( array ) { - var uniques = []; - $.each( array, function ( index, elem ) { - if ( elem !== undefined && $.inArray( elem, uniques ) === -1 ) { - uniques.push( elem ); - } - } ); - return uniques; - } - function setHeadersCss( table, $headers, list, css, msg, columnToHeader ) { // Remove all header information and reset titles to default message - $headers.removeClass( css[0] ).removeClass( css[1] ).attr( 'title', msg[1] ); + $headers.removeClass( css[ 0 ] ).removeClass( css[ 1 ] ).attr( 'title', msg[ 1 ] ); for ( var i = 0; i < list.length; i++ ) { - $headers.eq( columnToHeader[ list[i][0] ] ) - .addClass( css[ list[i][1] ] ) - .attr( 'title', msg[ list[i][1] ] ); + $headers.eq( columnToHeader[ list[ i ][ 0 ] ] ) + .addClass( css[ list[ i ][ 1 ] ] ) + .attr( 'title', msg[ list[ i ][ 1 ] ] ); } } @@ -462,19 +484,19 @@ sortFn = [], len = sortList.length; for ( i = 0; i < len; i++ ) { - sortFn[i] = ( sortList[i][1] ) ? sortTextDesc : sortText; + sortFn[ i ] = ( sortList[ i ][ 1 ] ) ? sortTextDesc : sortText; } cache.normalized.sort( function ( array1, array2 ) { var i, col, ret; for ( i = 0; i < len; i++ ) { - col = sortList[i][0]; - ret = sortFn[i].call( this, array1[col], array2[col] ); + col = sortList[ i ][ 0 ]; + ret = sortFn[ i ].call( this, array1[ col ], array2[ col ] ); if ( ret !== 0 ) { return ret; } } // Fall back to index number column to ensure stable sort - return sortText.call( this, array1[array1.length - 1], array2[array2.length - 1] ); + return sortText.call( this, array1[ array1.length - 1 ], array2[ array2.length - 1 ] ); } ); return cache; } @@ -485,19 +507,19 @@ separatorTransformTable = mw.config.get( 'wgSeparatorTransformTable' ), digitTransformTable = mw.config.get( 'wgDigitTransformTable' ); - if ( separatorTransformTable === null || ( separatorTransformTable[0] === '' && digitTransformTable[2] === '' ) ) { + if ( separatorTransformTable === null || ( separatorTransformTable[ 0 ] === '' && digitTransformTable[ 2 ] === '' ) ) { ts.transformTable = false; } else { ts.transformTable = {}; // Unpack the transform table - ascii = separatorTransformTable[0].split( '\t' ).concat( digitTransformTable[0].split( '\t' ) ); - localised = separatorTransformTable[1].split( '\t' ).concat( digitTransformTable[1].split( '\t' ) ); + ascii = separatorTransformTable[ 0 ].split( '\t' ).concat( digitTransformTable[ 0 ].split( '\t' ) ); + localised = separatorTransformTable[ 1 ].split( '\t' ).concat( digitTransformTable[ 1 ].split( '\t' ) ); // Construct regex for number identification for ( i = 0; i < ascii.length; i++ ) { - ts.transformTable[localised[i]] = ascii[i]; - digits.push( $.escapeRE( localised[i] ) ); + ts.transformTable[ localised[ i ] ] = ascii[ i ]; + digits.push( mw.RegExp.escape( localised[ i ] ) ); } } digitClass = '[' + digits.join( '', digits ) + ']'; @@ -516,15 +538,15 @@ ts.monthNames = {}; for ( i = 0; i < 12; i++ ) { - name = mw.language.months.names[i].toLowerCase(); - ts.monthNames[name] = i + 1; - regex.push( $.escapeRE( name ) ); - name = mw.language.months.genitive[i].toLowerCase(); - ts.monthNames[name] = i + 1; - regex.push( $.escapeRE( name ) ); - name = mw.language.months.abbrev[i].toLowerCase().replace( '.', '' ); - ts.monthNames[name] = i + 1; - regex.push( $.escapeRE( name ) ); + name = mw.language.months.names[ i ].toLowerCase(); + ts.monthNames[ name ] = i + 1; + regex.push( mw.RegExp.escape( name ) ); + name = mw.language.months.genitive[ i ].toLowerCase(); + ts.monthNames[ name ] = i + 1; + regex.push( mw.RegExp.escape( name ) ); + name = mw.language.months.abbrev[ i ].toLowerCase().replace( '.', '' ); + ts.monthNames[ name ] = i + 1; + regex.push( mw.RegExp.escape( name ) ); } // Build piped string @@ -532,13 +554,13 @@ // Build RegEx // Any date formated with . , ' - or / - ts.dateRegex[0] = new RegExp( /^\s*(\d{1,2})[\,\.\-\/'\s]{1,2}(\d{1,2})[\,\.\-\/'\s]{1,2}(\d{2,4})\s*?/i ); + ts.dateRegex[ 0 ] = new RegExp( /^\s*(\d{1,2})[\,\.\-\/'\s]{1,2}(\d{1,2})[\,\.\-\/'\s]{1,2}(\d{2,4})\s*?/i ); // Written Month name, dmy - ts.dateRegex[1] = new RegExp( '^\\s*(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(' + regex + ')' + '[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$', 'i' ); + ts.dateRegex[ 1 ] = new RegExp( '^\\s*(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(' + regex + ')' + '[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$', 'i' ); // Written Month name, mdy - ts.dateRegex[2] = new RegExp( '^\\s*(' + regex + ')' + '[\\,\\.\\-\\/\'\\s]+(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$', 'i' ); + ts.dateRegex[ 2 ] = new RegExp( '^\\s*(' + regex + ')' + '[\\,\\.\\-\\/\'\\s]+(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$', 'i' ); } @@ -546,7 +568,7 @@ * Replace all rowspanned cells in the body with clones in each row, so sorting * need not worry about them. * - * @param $table jQuery object for a <table> + * @param {jQuery} $table jQuery object for a <table> */ function explodeRowspans( $table ) { var spanningRealCellIndex, rowSpan, colSpan, @@ -566,11 +588,11 @@ col = 0, l = this.cells.length; for ( i = 0; i < l; i++ ) { - $( this.cells[i] ).data( 'tablesorter', { + $( this.cells[ i ] ).data( 'tablesorter', { realCellIndex: col, realRowIndex: this.rowIndex } ); - col += this.cells[i].colSpan; + col += this.cells[ i ].colSpan; } } ); @@ -609,7 +631,7 @@ } while ( rowspanCells.length ) { - if ( $.data( rowspanCells[0], 'tablesorter' ).needResort ) { + if ( $.data( rowspanCells[ 0 ], 'tablesorter' ).needResort ) { resortCells(); } @@ -621,7 +643,7 @@ cell.rowSpan = 1; $nextRows = $( cell ).parent().nextAll(); for ( i = 0; i < rowSpan - 1; i++ ) { - $tds = $( $nextRows[i].cells ).filter( filterfunc ); + $tds = $( $nextRows[ i ].cells ).filter( filterfunc ); $clone = $( cell ).clone(); $clone.data( 'tablesorter', { realCellIndex: spanningRealCellIndex, @@ -638,6 +660,49 @@ } } + /** + * Build index to handle colspanned cells in the body. + * Set the cell index for each column in an array, + * so that colspaned cells set multiple in this array. + * columnToCell[collumnIndex] point at the real cell in this row. + * + * @param {jQuery} $table object for a <table> + */ + function manageColspans( $table ) { + var i, j, k, $row, + $rows = $table.find( '> tbody > tr' ), + totalRows = $rows.length || 0, + config = $table.data( 'tablesorter' ).config, + columns = config.columns, + columnToCell, cellsInRow, index; + + for ( i = 0; i < totalRows; i++ ) { + + $row = $rows.eq( i ); + // if this is a child row, continue to the next row (as buildCache()) + if ( $row.hasClass( config.cssChildRow ) ) { + // go to the next for loop + continue; + } + + columnToCell = []; + cellsInRow = ( $row[ 0 ].cells.length ) || 0; // all cells in this row + index = 0; // real cell index in this row + for ( j = 0; j < columns; index++ ) { + if ( index === cellsInRow ) { + // Row with cells less than columns: add empty cell + $row.append( '<td>' ); + cellsInRow++; + } + for ( k = 0; k < $row[ 0 ].cells[ index ].colSpan; k++ ) { + columnToCell[ j++ ] = index; + } + } + // Store it in $row + $row.data( 'columnToCell', columnToCell ); + } + } + function buildCollationTable() { ts.collationTable = mw.config.get( 'tableSorterCollation' ); ts.collationRegex = null; @@ -699,7 +764,7 @@ $.each( sortObjects, function ( i, sortObject ) { $.each( sortObject, function ( columnIndex, order ) { var orderIndex = ( order === 'desc' ) ? 1 : 0; - sortList.push( [parseInt( columnIndex, 10 ), orderIndex] ); + sortList.push( [ parseInt( columnIndex, 10 ), orderIndex ] ); } ); } ); return sortList; @@ -708,7 +773,6 @@ /* Public scope */ $.tablesorter = { - defaultOptions: { cssHeader: 'headerSort', cssAsc: 'headerSortUp', @@ -716,20 +780,21 @@ cssChildRow: 'expand-child', sortMultiSortKey: 'shiftKey', unsortableClass: 'unsortable', - parsers: {}, + parsers: [], cancelSelection: true, sortList: [], headerList: [], headerToColumns: [], - columnToHeader: [] + columnToHeader: [], + columns: 0 }, dateRegex: [], monthNames: {}, /** - * @param $tables {jQuery} - * @param settings {Object} (optional) + * @param {jQuery} $tables + * @param {Object} [settings] */ construct: function ( $tables, settings ) { return $tables.each( function ( i, table ) { @@ -799,6 +864,7 @@ } explodeRowspans( $table ); + manageColspans( $table ); // Try to auto detect column type, and store in tables config config.parsers = buildParserCache( table, $headers ); @@ -806,7 +872,7 @@ // Apply event handling to headers // this is too big, perhaps break it out? - $headers.not( '.' + config.unsortableClass ).on( 'keypress click', function ( e ) { + $headers.on( 'keypress click', function ( e ) { var cell, $cell, columns, newSortList, i, totalRows, j, s, o; @@ -834,8 +900,8 @@ // cells get event .change() and bubbles up to the <table> here cache = buildCache( table ); - totalRows = ( $table[0].tBodies[0] && $table[0].tBodies[0].rows.length ) || 0; - if ( !table.sortDisabled && totalRows > 0 ) { + totalRows = ( $table[ 0 ].tBodies[ 0 ] && $table[ 0 ].tBodies[ 0 ].rows.length ) || 0; + if ( totalRows > 0 ) { cell = this; $cell = $( cell ); @@ -850,12 +916,12 @@ columns = config.headerToColumns[ $cell.data( 'headerIndex' ) ]; newSortList = $.map( columns, function ( c ) { // jQuery "helpfully" flattens the arrays... - return [[c, $cell.data( 'order' )]]; + return [ [ c, $cell.data( 'order' ) ] ]; } ); // Index of first column belonging to this header - i = columns[0]; + i = columns[ 0 ]; - if ( !e[config.sortMultiSortKey] ) { + if ( !e[ config.sortMultiSortKey ] ) { // User only wants to sort on one column set // Flush the sort list and add new columns config.sortList = newSortList; @@ -867,11 +933,11 @@ // The user has clicked on an already sorted column. // Reverse the sorting direction for all tables. for ( j = 0; j < config.sortList.length; j++ ) { - s = config.sortList[j]; - o = config.headerList[s[0]]; - if ( isValueInArray( s[0], newSortList ) ) { - $( o ).data( 'count', s[1] + 1 ); - s[1] = $( o ).data( 'count' ) % 2; + s = config.sortList[ j ]; + o = config.headerList[ config.columnToHeader[ s[ 0 ] ] ]; + if ( isValueInArray( s[ 0 ], newSortList ) ) { + $( o ).data( 'count', s[ 1 ] + 1 ); + s[ 1 ] = $( o ).data( 'count' ) % 2; } } } else { @@ -884,9 +950,9 @@ setHeadersOrder( $headers, config.sortList, config.headerToColumns ); // Set CSS for headers - setHeadersCss( $table[0], $headers, config.sortList, sortCSS, sortMsg, config.columnToHeader ); + setHeadersCss( $table[ 0 ], $headers, config.sortList, sortCSS, sortMsg, config.columnToHeader ); appendToTable( - $table[0], multisort( $table[0], config.sortList, cache ) + $table[ 0 ], multisort( $table[ 0 ], config.sortList, cache ) ); // Stop normal event by returning false @@ -909,7 +975,7 @@ * Passing an empty array will reset sorting (basically just reset the headers * making the table appear unsorted). * - * @param sortList {Array} (optional) List of sort objects. + * @param {Array} [sortList] List of sort objects. */ $table.data( 'tablesorter' ).sort = function ( sortList ) { @@ -939,7 +1005,6 @@ // sort initially if ( config.sortList.length > 0 ) { - setupForFirstSort(); config.sortList = convertSortList( config.sortList ); $table.data( 'tablesorter' ).sort(); } @@ -952,7 +1017,7 @@ len = parsers.length, a = true; for ( i = 0; i < len; i++ ) { - if ( parsers[i].id.toLowerCase() === parser.id.toLowerCase() ) { + if ( parsers[ i ].id.toLowerCase() === parser.id.toLowerCase() ) { a = false; } } @@ -968,7 +1033,7 @@ for ( p = 0; p < s.length; p++ ) { c = s.charAt( p ); if ( c in ts.transformTable ) { - out += ts.transformTable[c]; + out += ts.transformTable[ c ]; } else { out += c; } @@ -990,7 +1055,7 @@ }, clearTableBody: function ( table ) { - $( table.tBodies[0] ).empty(); + $( table.tBodies[ 0 ] ).empty(); }, getParser: function ( id ) { @@ -1000,6 +1065,10 @@ buildCollationTable(); return getParserById( id ); + }, + + getParsers: function () { // for table diagnosis + return parsers; } }; @@ -1022,7 +1091,7 @@ if ( ts.collationRegex ) { var tsc = ts.collationTable; s = s.replace( ts.collationRegex, function ( match ) { - var r = tsc[match] ? tsc[match] : tsc[match.toUpperCase()]; + var r = tsc[ match ] ? tsc[ match ] : tsc[ match.toUpperCase() ]; return r.toLowerCase(); } ); } @@ -1034,7 +1103,7 @@ ts.addParser( { id: 'IPAddress', is: function ( s ) { - return ts.rgx.IPAddress[0].test( s ); + return ts.rgx.IPAddress[ 0 ].test( s ); }, format: function ( s ) { var i, item, @@ -1042,7 +1111,7 @@ r = '', len = a.length; for ( i = 0; i < len; i++ ) { - item = a[i]; + item = a[ i ]; if ( item.length === 1 ) { r += '00' + item; } else if ( item.length === 2 ) { @@ -1059,10 +1128,10 @@ ts.addParser( { id: 'currency', is: function ( s ) { - return ts.rgx.currency[0].test( s ); + return ts.rgx.currency[ 0 ].test( s ); }, format: function ( s ) { - return $.tablesorter.formatDigit( s.replace( ts.rgx.currency[1], '' ) ); + return $.tablesorter.formatDigit( s.replace( ts.rgx.currency[ 1 ], '' ) ); }, type: 'numeric' } ); @@ -1070,10 +1139,10 @@ ts.addParser( { id: 'url', is: function ( s ) { - return ts.rgx.url[0].test( s ); + return ts.rgx.url[ 0 ].test( s ); }, format: function ( s ) { - return $.trim( s.replace( ts.rgx.url[1], '' ) ); + return $.trim( s.replace( ts.rgx.url[ 1 ], '' ) ); }, type: 'text' } ); @@ -1081,7 +1150,7 @@ ts.addParser( { id: 'isoDate', is: function ( s ) { - return ts.rgx.isoDate[0].test( s ); + return ts.rgx.isoDate[ 0 ].test( s ); }, format: function ( s ) { return $.tablesorter.formatFloat( ( s !== '' ) ? new Date( s.replace( @@ -1093,7 +1162,7 @@ ts.addParser( { id: 'usLongDate', is: function ( s ) { - return ts.rgx.usLongDate[0].test( s ); + return ts.rgx.usLongDate[ 0 ].test( s ); }, format: function ( s ) { return $.tablesorter.formatFloat( new Date( s ).getTime() ); @@ -1104,49 +1173,49 @@ ts.addParser( { id: 'date', is: function ( s ) { - return ( ts.dateRegex[0].test( s ) || ts.dateRegex[1].test( s ) || ts.dateRegex[2].test( s ) ); + return ( ts.dateRegex[ 0 ].test( s ) || ts.dateRegex[ 1 ].test( s ) || ts.dateRegex[ 2 ].test( s ) ); }, format: function ( s ) { var match, y; s = $.trim( s.toLowerCase() ); - if ( ( match = s.match( ts.dateRegex[0] ) ) !== null ) { - if ( mw.config.get( 'wgDefaultDateFormat' ) === 'mdy' || mw.config.get( 'wgContentLanguage' ) === 'en' ) { - s = [ match[3], match[1], match[2] ]; + if ( ( match = s.match( ts.dateRegex[ 0 ] ) ) !== null ) { + if ( mw.config.get( 'wgDefaultDateFormat' ) === 'mdy' || mw.config.get( 'wgPageContentLanguage' ) === 'en' ) { + s = [ match[ 3 ], match[ 1 ], match[ 2 ] ]; } else if ( mw.config.get( 'wgDefaultDateFormat' ) === 'dmy' ) { - s = [ match[3], match[2], match[1] ]; + s = [ match[ 3 ], match[ 2 ], match[ 1 ] ]; } else { // If we get here, we don't know which order the dd-dd-dddd // date is in. So return something not entirely invalid. return '99999999'; } - } else if ( ( match = s.match( ts.dateRegex[1] ) ) !== null ) { - s = [ match[3], String( ts.monthNames[match[2]] ), match[1] ]; - } else if ( ( match = s.match( ts.dateRegex[2] ) ) !== null ) { - s = [ match[3], String( ts.monthNames[match[1]] ), match[2] ]; + } else if ( ( match = s.match( ts.dateRegex[ 1 ] ) ) !== null ) { + s = [ match[ 3 ], String( ts.monthNames[ match[ 2 ] ] ), match[ 1 ] ]; + } else if ( ( match = s.match( ts.dateRegex[ 2 ] ) ) !== null ) { + s = [ match[ 3 ], String( ts.monthNames[ match[ 1 ] ] ), match[ 2 ] ]; } else { // Should never get here return '99999999'; } // Pad Month and Day - if ( s[1].length === 1 ) { - s[1] = '0' + s[1]; + if ( s[ 1 ].length === 1 ) { + s[ 1 ] = '0' + s[ 1 ]; } - if ( s[2].length === 1 ) { - s[2] = '0' + s[2]; + if ( s[ 2 ].length === 1 ) { + s[ 2 ] = '0' + s[ 2 ]; } - if ( ( y = parseInt( s[0], 10 ) ) < 100 ) { + if ( ( y = parseInt( s[ 0 ], 10 ) ) < 100 ) { // Guestimate years without centuries if ( y < 30 ) { - s[0] = 2000 + y; + s[ 0 ] = 2000 + y; } else { - s[0] = 1900 + y; + s[ 0 ] = 1900 + y; } } - while ( s[0].length < 4 ) { - s[0] = '0' + s[0]; + while ( s[ 0 ].length < 4 ) { + s[ 0 ] = '0' + s[ 0 ]; } return parseInt( s.join( '' ), 10 ); }, @@ -1156,7 +1225,7 @@ ts.addParser( { id: 'time', is: function ( s ) { - return ts.rgx.time[0].test( s ); + return ts.rgx.time[ 0 ].test( s ); }, format: function ( s ) { return $.tablesorter.formatFloat( new Date( '2000/01/01 ' + s ).getTime() ); diff --git a/resources/src/jquery/jquery.textSelection.js b/resources/src/jquery/jquery.textSelection.js index 51119305..b9016424 100644 --- a/resources/src/jquery/jquery.textSelection.js +++ b/resources/src/jquery/jquery.textSelection.js @@ -138,7 +138,7 @@ insertText = '', selTextArr = selText.split( '\n' ); for ( i = 0; i < selTextArr.length; i++ ) { - insertText += pre + selTextArr[i] + post; + insertText += pre + selTextArr[ i ] + post; if ( i !== selTextArr.length - 1 ) { insertText += '\n'; } @@ -160,7 +160,7 @@ context.fn.restoreCursorAndScrollTop(); } if ( options.selectionStart !== undefined ) { - $( this ).textSelection( 'setSelection', { 'start': options.selectionStart, 'end': options.selectionEnd } ); + $( this ).textSelection( 'setSelection', { start: options.selectionStart, end: options.selectionEnd } ); } selText = $( this ).textSelection( 'getSelection' ); @@ -203,7 +203,7 @@ $( this ).focus(); if ( options.selectionStart !== undefined ) { - $( this ).textSelection( 'setSelection', { 'start': options.selectionStart, 'end': options.selectionEnd } ); + $( this ).textSelection( 'setSelection', { start: options.selectionStart, end: options.selectionEnd } ); } selText = $( this ).textSelection( 'getSelection' ); @@ -411,7 +411,8 @@ * * Scroll a textarea to the current cursor position. You can set the cursor * position with setSelection() - * @param options boolean Whether to force a scroll even if the caret position + * + * @param {boolean} options Whether to force a scroll even if the caret position * is already visible. Defaults to false * * @fixme document the options parameters (function body suggests options.force is a boolean, not options itself) @@ -576,7 +577,7 @@ context.fn.restoreSelection(); needSave = true; } - retval = ( alternateFn && alternateFn[command] || fn[command] ).call( this, options ); + retval = ( alternateFn && alternateFn[ command ] || fn[ command ] ).call( this, options ); if ( hasWikiEditor && needSave ) { context.fn.saveSelection(); } diff --git a/resources/src/mediawiki.legacy/images/checker.png b/resources/src/mediawiki.action/images/checker.png Binary files differindex 3e9e3d09..3e9e3d09 100644 --- a/resources/src/mediawiki.legacy/images/checker.png +++ b/resources/src/mediawiki.action/images/checker.png diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js b/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js index 7ae51aba..011f9c55 100644 --- a/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js +++ b/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js @@ -1,27 +1,28 @@ -jQuery( document ).ready( function ( $ ) { - var collapsibleLists, i, handleOne; +( function ( mw ) { + var collapsibleLists, handleOne; // Collapsible lists of categories and templates collapsibleLists = [ { - $list: $( '.templatesUsed ul' ), - $toggler: $( '.mw-templatesUsedExplanation' ), + listSel: '.templatesUsed ul', + togglerSel: '.mw-templatesUsedExplanation', cookieName: 'templates-used-list' }, { - $list: $( '.hiddencats ul' ), - $toggler: $( '.mw-hiddenCategoriesExplanation' ), + listSel: '.hiddencats ul', + togglerSel: '.mw-hiddenCategoriesExplanation', cookieName: 'hidden-categories-list' }, { - $list: $( '.preview-limit-report-wrapper' ), - $toggler: $( '.mw-limitReportExplanation' ), + listSel: '.preview-limit-report-wrapper', + togglerSel: '.mw-limitReportExplanation', cookieName: 'preview-limit-report' } ]; handleOne = function ( $list, $toggler, cookieName ) { - var isCollapsed = $.cookie( cookieName ) !== 'expanded'; + // Collapsed by default + var isCollapsed = mw.cookie.get( cookieName ) !== 'expanded'; // Style the toggler with an arrow icon and add a tabIndex and a role for accessibility $toggler.addClass( 'mw-editfooter-toggler' ).prop( 'tabIndex', 0 ).attr( 'role', 'button' ); @@ -38,17 +39,24 @@ jQuery( document ).ready( function ( $ ) { $list.on( 'beforeExpand.mw-collapsible', function () { $toggler.removeClass( 'mw-icon-arrow-collapsed' ).addClass( 'mw-icon-arrow-expanded' ); - $.cookie( cookieName, 'expanded' ); + mw.cookie.set( cookieName, 'expanded' ); } ); $list.on( 'beforeCollapse.mw-collapsible', function () { $toggler.removeClass( 'mw-icon-arrow-expanded' ).addClass( 'mw-icon-arrow-collapsed' ); - $.cookie( cookieName, 'collapsed' ); + mw.cookie.set( cookieName, 'collapsed' ); } ); }; - for ( i = 0; i < collapsibleLists.length; i++ ) { - // Pass to a function for iteration-local variables - handleOne( collapsibleLists[i].$list, collapsibleLists[i].$toggler, collapsibleLists[i].cookieName ); - } -} ); + mw.hook( 'wikipage.editform' ).add( function ( $editForm ) { + var i; + for ( i = 0; i < collapsibleLists.length; i++ ) { + // Pass to a function for iteration-local variables + handleOne( + $editForm.find( collapsibleLists[ i ].listSel ), + $editForm.find( collapsibleLists[ i ].togglerSel ), + collapsibleLists[ i ].cookieName + ); + } + } ); +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.css b/resources/src/mediawiki.action/mediawiki.action.edit.css index 45ba5437..9b0c430c 100644 --- a/resources/src/mediawiki.action/mediawiki.action.edit.css +++ b/resources/src/mediawiki.action/mediawiki.action.edit.css @@ -7,9 +7,6 @@ height: 22px; cursor: pointer; vertical-align: middle; - /* Cross-browser inline-block */ - /* Firefox 2 */ - display: -moz-inline-block; /* Modern browsers */ display: inline-block; /* IE7 */ diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.editWarning.js b/resources/src/mediawiki.action/mediawiki.action.edit.editWarning.js index 6b330128..56dba703 100644 --- a/resources/src/mediawiki.action/mediawiki.action.edit.editWarning.js +++ b/resources/src/mediawiki.action/mediawiki.action.edit.editWarning.js @@ -35,7 +35,7 @@ // Add form submission handler $( '#editform' ).submit( function () { - allowCloseWindow(); + allowCloseWindow.release(); } ); } ); diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.js b/resources/src/mediawiki.action/mediawiki.action.edit.js index 01a25f3b..c9834f04 100644 --- a/resources/src/mediawiki.action/mediawiki.action.edit.js +++ b/resources/src/mediawiki.action/mediawiki.action.edit.js @@ -1,23 +1,40 @@ /*! * Scripts for action=edit at domready */ -jQuery( function ( $ ) { - var editBox, scrollTop, $editForm; +( function ( mw, $ ) { + 'use strict'; - // Make sure edit summary does not exceed byte limit - $( '#wpSummary' ).byteLimit( 255 ); + /** + * Fired when the editform is added to the edit page + * + * Similar to the {@link mw.hook#event-wikipage_content wikipage.content hook} + * $editForm can still be detached when this hook is fired. + * + * @event wikipage_editform + * @member mw.hook + * @param {jQuery} $editForm The most appropriate element containing the + * editform, usually #editform. + */ - // Restore the edit box scroll state following a preview operation, - // and set up a form submission handler to remember this state. - editBox = document.getElementById( 'wpTextbox1' ); - scrollTop = document.getElementById( 'wpScrolltop' ); - $editForm = $( '#editform' ); - if ( $editForm.length && editBox && scrollTop ) { - if ( scrollTop.value ) { - editBox.scrollTop = scrollTop.value; + $( function () { + var editBox, scrollTop, $editForm; + + // Make sure edit summary does not exceed byte limit + $( '#wpSummary' ).byteLimit( 255 ); + + // Restore the edit box scroll state following a preview operation, + // and set up a form submission handler to remember this state. + editBox = document.getElementById( 'wpTextbox1' ); + scrollTop = document.getElementById( 'wpScrolltop' ); + $editForm = $( '#editform' ); + mw.hook( 'wikipage.editform' ).fire( $editForm ); + if ( $editForm.length && editBox && scrollTop ) { + if ( scrollTop.value ) { + editBox.scrollTop = scrollTop.value; + } + $editForm.submit( function () { + scrollTop.value = editBox.scrollTop; + } ); } - $editForm.submit( function () { - scrollTop.value = editBox.scrollTop; - } ); - } -} ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.preview.js b/resources/src/mediawiki.action/mediawiki.action.edit.preview.js index f24703af..ab4535b6 100644 --- a/resources/src/mediawiki.action/mediawiki.action.edit.preview.js +++ b/resources/src/mediawiki.action/mediawiki.action.edit.preview.js @@ -17,6 +17,7 @@ $editform = $( '#editform' ); $textbox = $editform.find( '#wpTextbox1' ); $summary = $editform.find( '#wpSummary' ); + $spinner = $( '.mw-spinner-preview' ); $errorBox = $( '.errorbox' ); section = $editform.find( '[name="wpSection"]' ).val(); @@ -36,7 +37,7 @@ $wikiPreview.show(); // Jump to where the preview will appear - $wikiPreview[0].scrollIntoView(); + $wikiPreview[ 0 ].scrollIntoView(); copySelectors = [ // Main @@ -55,14 +56,17 @@ // Not shown during normal preview, to be removed if present $( '.mw-newarticletext' ).remove(); - $spinner = $.createSpinner( { - size: 'large', - type: 'block' - } ); - $wikiPreview.before( $spinner ); - $spinner.css( { - marginTop: $spinner.height() - } ); + if ( $spinner.length === 0 ) { + $spinner = $.createSpinner( { + size: 'large', + type: 'block' + } ) + .addClass( 'mw-spinner-preview' ) + .css( 'margin-top', '1em' ); + $wikiPreview.before( $spinner ); + } else { + $spinner.show(); + } // Can't use fadeTo because it calls show(), and we might want to keep some elements hidden // (e.g. empty #catlinks) @@ -98,7 +102,7 @@ indexpageids: '', prop: 'revisions', titles: mw.config.get( 'wgPageName' ), - rvdifftotext: response.parse.text['*'], + rvdifftotext: response.parse.text[ '*' ], rvprop: '' }; if ( section !== '' ) { @@ -106,8 +110,8 @@ } return api.post( postData ).done( function ( result2 ) { try { - var diffHtml = result2.query.pages[result2.query.pageids[0]] - .revisions[0].diff['*']; + var diffHtml = result2.query.pages[ result2.query.pageids[ 0 ] ] + .revisions[ 0 ].diff[ '*' ]; $wikiDiff.find( 'table.diff tbody' ).html( diffHtml ); } catch ( e ) { // "result.blah is undefined" error, ignore @@ -121,12 +125,15 @@ $.extend( postData, { pst: '', preview: '', - prop: 'text|displaytitle|modules|categorieshtml|templates|langlinks|limitreporthtml', + prop: 'text|displaytitle|modules|jsconfigvars|categorieshtml|templates|langlinks|limitreporthtml', disableeditsection: true } ); request = api.post( postData ); request.done( function ( response ) { var li, newList, $displaytitle, $content, $parent, $list; + if ( response.parse.jsconfigvars ) { + mw.config.set( response.parse.jsconfigvars ); + } if ( response.parse.modules ) { mw.loader.load( response.parse.modules.concat( response.parse.modulescripts, @@ -148,7 +155,7 @@ ); } if ( response.parse.categorieshtml ) { - $( '#catlinks' ).replaceWith( response.parse.categorieshtml['*'] ); + $( '#catlinks' ).replaceWith( response.parse.categorieshtml[ '*' ] ); } if ( response.parse.templates ) { newList = []; @@ -156,10 +163,10 @@ li = $( '<li>' ) .append( $( '<a>' ) .attr( { - 'href': mw.util.getUrl( template['*'] ), + href: mw.util.getUrl( template[ '*' ] ), 'class': ( template.exists !== undefined ? '' : 'new' ) } ) - .text( template['*'] ) + .text( template[ '*' ] ) ); newList.push( li ); } ); @@ -167,7 +174,7 @@ $editform.find( '.templatesUsed .mw-editfooter-list' ).detach().empty().append( newList ).appendTo( '.templatesUsed' ); } if ( response.parse.limitreporthtml ) { - $( '.limitreport' ).html( response.parse.limitreporthtml['*'] ); + $( '.limitreport' ).html( response.parse.limitreporthtml[ '*' ] ); } if ( response.parse.langlinks && mw.config.get( 'skin' ) === 'vector' ) { newList = []; @@ -176,10 +183,10 @@ .addClass( 'interlanguage-link interwiki-' + langlink.lang ) .append( $( '<a>' ) .attr( { - 'href': langlink.url, - 'title': langlink['*'] + ' - ' + langlink.langname, - 'lang': langlink.lang, - 'hreflang': langlink.lang + href: langlink.url, + title: langlink[ '*' ] + ' - ' + langlink.langname, + lang: langlink.lang, + hreflang: langlink.lang } ) .text( langlink.autonym ) ); @@ -190,11 +197,11 @@ $list.detach().empty().append( newList ).prependTo( $parent ); } - if ( response.parse.text['*'] ) { + if ( response.parse.text[ '*' ] ) { $content = $wikiPreview.children( '.mw-content-ltr,.mw-content-rtl' ); $content .detach() - .html( response.parse.text['*'] ); + .html( response.parse.text[ '*' ] ); mw.hook( 'wikipage.content' ).fire( $content ); @@ -208,23 +215,23 @@ } request.done( function ( response ) { var isSubject = ( section === 'new' ), - summaryMsg = isSubject ? 'subject-preview' : 'summary-preview'; - if ( response.parse.parsedsummary ) { - $editform.find( '.mw-summary-preview' ) - .empty() - .append( - mw.message( summaryMsg ).parse(), - ' ', - $( '<span>' ).addClass( 'comment' ).html( - // There is no equivalent to rawParams - mw.message( 'parentheses' ).escaped() - .replace( '$1', response.parse.parsedsummary['*'] ) - ) - ); + summaryMsg = isSubject ? 'subject-preview' : 'summary-preview', + $summaryPreview = $editform.find( '.mw-summary-preview' ).empty(); + if ( response.parse.parsedsummary && response.parse.parsedsummary[ '*' ] !== '' ) { + $summaryPreview.append( + mw.message( summaryMsg ).parse(), + ' ', + $( '<span>' ).addClass( 'comment' ).html( + // There is no equivalent to rawParams + mw.message( 'parentheses' ).escaped() + .replace( '$1', response.parse.parsedsummary[ '*' ] ) + ) + ); } + mw.hook( 'wikipage.editform' ).fire( $editform ); } ); request.always( function () { - $spinner.remove(); + $spinner.hide(); $copyElements.animate( { opacity: 1 }, 'fast' ); @@ -265,9 +272,9 @@ $( '.portal:last' ).after( $( '<div>' ).attr( { 'class': 'portal', - 'id': 'p-lang', - 'role': 'navigation', - 'title': mw.msg( 'tooltip-p-lang' ), + id: 'p-lang', + role: 'navigation', + title: mw.msg( 'tooltip-p-lang' ), 'aria-labelledby': 'p-lang-label' } ) .append( $( '<h3>' ).attr( 'id', 'p-lang-label' ).text( mw.msg( 'otherlanguages' ) ) ) diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.stash.js b/resources/src/mediawiki.action/mediawiki.action.edit.stash.js index 29c533d8..abe912de 100644 --- a/resources/src/mediawiki.action/mediawiki.action.edit.stash.js +++ b/resources/src/mediawiki.action/mediawiki.action.edit.stash.js @@ -3,7 +3,7 @@ */ ( function ( mw, $ ) { $( function () { - var idleTimeout = 4000, + var idleTimeout = 3000, api = new mw.Api(), pending = null, $form = $( '#editform' ), diff --git a/resources/src/mediawiki.action/mediawiki.action.history.js b/resources/src/mediawiki.action/mediawiki.action.history.js index 2ebfe921..077d5e3a 100644 --- a/resources/src/mediawiki.action/mediawiki.action.history.js +++ b/resources/src/mediawiki.action/mediawiki.action.history.js @@ -9,7 +9,7 @@ jQuery( function ( $ ) { /** * @ignore * @context {Element} input - * @param e {jQuery.Event} + * @param {jQuery.Event} e */ function updateDiffRadios() { var nextState = 'before', diff --git a/resources/src/mediawiki.action/mediawiki.action.view.filepage.css b/resources/src/mediawiki.action/mediawiki.action.view.filepage.css new file mode 100644 index 00000000..bfc201af --- /dev/null +++ b/resources/src/mediawiki.action/mediawiki.action.view.filepage.css @@ -0,0 +1,71 @@ +/*! + * File description page + */ + +div.mw-filepage-resolutioninfo { + font-size: smaller; +} + +/* + * File histories + */ +h2#filehistory { + clear: both; +} + +table.filehistory th, +table.filehistory td { + vertical-align: top; +} + +table.filehistory th { + text-align: left; +} + +table.filehistory td.mw-imagepage-filesize, +table.filehistory th.mw-imagepage-filesize { + white-space: nowrap; +} + +table.filehistory td.filehistory-selected { + font-weight: bold; +} + +/* + * Add a checkered background image on hover for file + * description pages. (bug 26470) + */ +.filehistory a img, +#file img:hover { + /* @embed */ + background: white url(images/checker.png) repeat; +} + +/* + * filetoc + */ +ul#filetoc { + text-align: center; + border: 1px solid #aaaaaa; + background-color: #f9f9f9; + padding: 5px; + font-size: 95%; + margin-bottom: 0.5em; + margin-left: 0; + margin-right: 0; +} + +#filetoc li { + display: inline; + list-style-type: none; + padding-right: 2em; +} + +/* + * Shared images hint + */ +#shared-image-dup, +#shared-image-conflict { + font-style: italic; +} + diff --git a/resources/src/mediawiki.action/mediawiki.action.view.filepage.print.css b/resources/src/mediawiki.action/mediawiki.action.view.filepage.print.css new file mode 100644 index 00000000..15b20f10 --- /dev/null +++ b/resources/src/mediawiki.action/mediawiki.action.view.filepage.print.css @@ -0,0 +1,8 @@ +/*! + * File description page - print style + */ + +span.mw-filepage-other-resolutions, +#filetoc { + display: none; +} diff --git a/resources/src/mediawiki.action/mediawiki.action.view.metadata.css b/resources/src/mediawiki.action/mediawiki.action.view.metadata.css index 9f786ecb..b07965ee 100644 --- a/resources/src/mediawiki.action/mediawiki.action.view.metadata.css +++ b/resources/src/mediawiki.action/mediawiki.action.view.metadata.css @@ -14,3 +14,9 @@ table.collapsed tr.collapsable { -ms-user-select: none; user-select: none; } + +@media print { + tr.mw-metadata-show-hide-extended { + display: none; + } +} diff --git a/resources/src/mediawiki.action/mediawiki.action.view.postEdit.js b/resources/src/mediawiki.action/mediawiki.action.view.postEdit.js index c008dfd8..168a1c18 100644 --- a/resources/src/mediawiki.action/mediawiki.action.view.postEdit.js +++ b/resources/src/mediawiki.action/mediawiki.action.view.postEdit.js @@ -24,6 +24,19 @@ cookieVal = mw.cookie.get( cookieKey ), $div, id; + function removeConfirmation() { + $div.remove(); + mw.hook( 'postEdit.afterRemoval' ).fire(); + } + + function fadeOutConfirmation() { + clearTimeout( id ); + $div.find( '.postedit' ).addClass( 'postedit postedit-faded' ); + setTimeout( removeConfirmation, 500 ); + + return false; + } + function showConfirmation( data ) { data = data || {}; if ( data.message === undefined ) { @@ -45,19 +58,6 @@ id = setTimeout( fadeOutConfirmation, 3000 ); } - function fadeOutConfirmation() { - clearTimeout( id ); - $div.find( '.postedit' ).addClass( 'postedit postedit-faded' ); - setTimeout( removeConfirmation, 500 ); - - return false; - } - - function removeConfirmation() { - $div.remove(); - mw.hook( 'postEdit.afterRemoval' ).fire(); - } - mw.hook( 'postEdit' ).add( showConfirmation ); if ( config.wgAction === 'view' && cookieVal ) { @@ -68,7 +68,7 @@ // postedit-confirmation-saved // postedit-confirmation-created // postedit-confirmation-restored - 'message': mw.msg( + message: mw.msg( 'postedit-confirmation-' + cookieVal, mw.user ) diff --git a/resources/src/mediawiki.api/mediawiki.ForeignApi.js b/resources/src/mediawiki.api/mediawiki.ForeignApi.js new file mode 100644 index 00000000..b8cc0598 --- /dev/null +++ b/resources/src/mediawiki.api/mediawiki.ForeignApi.js @@ -0,0 +1,109 @@ +( function ( mw, $ ) { + + /** + * Create an object like mw.Api, but automatically handling everything required to communicate + * with another MediaWiki wiki via cross-origin requests (CORS). + * + * The foreign wiki must be configured to accept requests from the current wiki. See + * <https://www.mediawiki.org/wiki/Manual:$wgCrossSiteAJAXdomains> for details. + * + * var api = new mw.ForeignApi( 'https://commons.wikimedia.org/w/api.php' ); + * api.get( { + * action: 'query', + * meta: 'userinfo' + * } ).done( function ( data ) { + * console.log( data ); + * } ); + * + * To ensure that the user at the foreign wiki is logged in, pass the `assert: 'user'` parameter + * to #get/#post (since MW 1.23): if they are not, the API request will fail. (Note that this + * doesn't guarantee that it's the same user.) + * + * Authentication-related MediaWiki extensions may extend this class to ensure that the user + * authenticated on the current wiki will be automatically authenticated on the foreign one. These + * extension modules should be registered using the ResourceLoaderForeignApiModules hook. See + * CentralAuth for a practical example. The general pattern to extend and override the name is: + * + * function MyForeignApi() {}; + * OO.inheritClass( MyForeignApi, mw.ForeignApi ); + * mw.ForeignApi = MyForeignApi; + * + * @class mw.ForeignApi + * @extends mw.Api + * @since 1.26 + * + * @constructor + * @param {string|mw.Uri} url URL pointing to another wiki's `api.php` endpoint. + * @param {Object} [options] See mw.Api. + * + * @author Bartosz Dziewoński + * @author Jon Robson + */ + function CoreForeignApi( url, options ) { + if ( !url || $.isPlainObject( url ) ) { + throw new Error( 'mw.ForeignApi() requires a `url` parameter' ); + } + + this.apiUrl = String( url ); + + options = $.extend( /*deep=*/ true, + { + ajax: { + url: this.apiUrl, + xhrFields: { + withCredentials: true + } + }, + parameters: { + // Add 'origin' query parameter to all requests. + origin: this.getOrigin() + } + }, + options + ); + + // Call parent constructor + CoreForeignApi.parent.call( this, options ); + } + + OO.inheritClass( CoreForeignApi, mw.Api ); + + /** + * Return the origin to use for API requests, in the required format (protocol, host and port, if + * any). + * + * @protected + * @return {string} + */ + CoreForeignApi.prototype.getOrigin = function () { + var origin = location.protocol + '//' + location.hostname; + if ( location.port ) { + origin += ':' + location.port; + } + return origin; + }; + + /** + * @inheritdoc + */ + CoreForeignApi.prototype.ajax = function ( parameters, ajaxOptions ) { + var url, origin, newAjaxOptions; + + // 'origin' query parameter must be part of the request URI, and not just POST request body + if ( ajaxOptions.type === 'POST' ) { + url = ( ajaxOptions && ajaxOptions.url ) || this.defaults.ajax.url; + origin = ( parameters && parameters.origin ) || this.defaults.parameters.origin; + url += ( url.indexOf( '?' ) !== -1 ? '&' : '?' ) + + 'origin=' + encodeURIComponent( origin ); + newAjaxOptions = $.extend( {}, ajaxOptions, { url: url } ); + } else { + newAjaxOptions = ajaxOptions; + } + + return CoreForeignApi.parent.prototype.ajax.call( this, parameters, newAjaxOptions ); + }; + + // Expose + mw.ForeignApi = CoreForeignApi; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/mediawiki.api.edit.js b/resources/src/mediawiki.api/mediawiki.api.edit.js index dbe45bf6..e43285ff 100644 --- a/resources/src/mediawiki.api/mediawiki.api.edit.js +++ b/resources/src/mediawiki.api/mediawiki.api.edit.js @@ -11,10 +11,11 @@ * cached token and start over. * * @param {Object} params API parameters + * @param {Object} [ajaxOptions] * @return {jQuery.Promise} See #post */ - postWithEditToken: function ( params ) { - return this.postWithToken( 'edit', params ); + postWithEditToken: function ( params, ajaxOptions ) { + return this.postWithToken( 'edit', params, ajaxOptions ); }, /** @@ -30,6 +31,7 @@ /** * Post a new section to the page. + * * @see #postWithEditToken * @param {mw.Title|String} title Target page * @param {string} header diff --git a/resources/src/mediawiki.api/mediawiki.api.js b/resources/src/mediawiki.api/mediawiki.api.js index 3a19e021..73f3c8c6 100644 --- a/resources/src/mediawiki.api/mediawiki.api.js +++ b/resources/src/mediawiki.api/mediawiki.api.js @@ -1,26 +1,28 @@ ( function ( mw, $ ) { - // We allow people to omit these default parameters from API requests - // there is very customizable error handling here, on a per-call basis - // wondering, would it be simpler to make it easy to clone the api object, - // change error handling, and use that instead? - var defaultOptions = { + /** + * @class mw.Api + */ - // Query parameters for API requests + /** + * @property {Object} defaultOptions Default options for #ajax calls. Can be overridden by passing + * `options` to mw.Api constructor. + * @property {Object} defaultOptions.parameters Default query parameters for API requests. + * @property {Object} defaultOptions.ajax Default options for jQuery#ajax. + * @private + */ + var defaultOptions = { parameters: { action: 'query', format: 'json' }, - - // Ajax options for jQuery.ajax() ajax: { url: mw.util.wikiScript( 'api' ), - timeout: 30 * 1000, // 30 seconds - dataType: 'json' } }, + // Keyed by ajax url and symbolic name for the individual request promises = {}; @@ -39,37 +41,33 @@ * Constructor to create an object to interact with the API of a particular MediaWiki server. * mw.Api objects represent the API of a particular MediaWiki server. * - * TODO: Share API objects with exact same config. - * * var api = new mw.Api(); * api.get( { * action: 'query', * meta: 'userinfo' - * } ).done ( function ( data ) { + * } ).done( function ( data ) { * console.log( data ); * } ); * - * Multiple values for a parameter can be specified using an array (since MW 1.25): + * Since MW 1.25, multiple values for a parameter can be specified using an array: * * var api = new mw.Api(); * api.get( { * action: 'query', * meta: [ 'userinfo', 'siteinfo' ] // same effect as 'userinfo|siteinfo' - * } ).done ( function ( data ) { + * } ).done( function ( data ) { * console.log( data ); * } ); * - * @class + * Since MW 1.26, boolean values for a parameter can be specified directly. If the value is + * `false` or `undefined`, the parameter will be omitted from the request, as required by the API. * * @constructor - * @param {Object} options See defaultOptions documentation above. Ajax options can also be - * overridden for each individual request to {@link jQuery#ajax} later on. + * @param {Object} [options] See #defaultOptions documentation above. Can also be overridden for + * each individual request by passing them to #get or #post (or directly #ajax) later on. */ mw.Api = function ( options ) { - - if ( options === undefined ) { - options = {}; - } + options = options || {}; // Force a string if we got a mw.Uri object if ( options.ajax && options.ajax.url !== undefined ) { @@ -80,9 +78,22 @@ options.ajax = $.extend( {}, defaultOptions.ajax, options.ajax ); this.defaults = options; + this.requests = []; }; mw.Api.prototype = { + /** + * Abort all unfinished requests issued by this Api object. + * + * @method + */ + abort: function () { + $.each( this.requests, function ( index, request ) { + if ( request ) { + request.abort(); + } + } ); + }, /** * Perform API get request @@ -113,6 +124,27 @@ }, /** + * Massage parameters from the nice format we accept into a format suitable for the API. + * + * @private + * @param {Object} parameters (modified in-place) + */ + preprocessParameters: function ( parameters ) { + var key; + // Handle common MediaWiki API idioms for passing parameters + for ( key in parameters ) { + // Multiple values are pipe-separated + if ( $.isArray( parameters[ key ] ) ) { + parameters[ key ] = parameters[ key ].join( '|' ); + } + // Boolean values are only false when not given at all + if ( parameters[ key ] === false || parameters[ key ] === undefined ) { + delete parameters[ key ]; + } + } + }, + + /** * Perform the API call. * * @param {Object} parameters @@ -121,7 +153,8 @@ * Fail: Error code */ ajax: function ( parameters, ajaxOptions ) { - var token, + var token, requestIndex, + api = this, apiDeferred = $.Deferred(), xhr, key, formData; @@ -134,11 +167,7 @@ delete parameters.token; } - for ( key in parameters ) { - if ( $.isArray( parameters[key] ) ) { - parameters[key] = parameters[key].join( '|' ); - } - } + this.preprocessParameters( parameters ); // If multipart/form-data has been requested and emulation is possible, emulate it if ( @@ -150,7 +179,7 @@ formData = new FormData(); for ( key in parameters ) { - formData.append( key, parameters[key] ); + formData.append( key, parameters[ key ] ); } // If we extracted a token parameter, add it back in. if ( token ) { @@ -206,6 +235,11 @@ } } ); + requestIndex = this.requests.length; + this.requests.push( xhr ); + xhr.always( function () { + api.requests[ requestIndex ] = null; + } ); // Return the Promise return apiDeferred.promise( { abort: xhr.abort } ).fail( function ( code, details ) { if ( !( code === 'http' && details && details.textStatus === 'abort' ) ) { @@ -242,11 +276,9 @@ // Error handler function ( code ) { if ( code === 'badtoken' ) { - // Clear from cache - promises[ api.defaults.ajax.url ][ tokenType + 'Token' ] = - params.token = undefined; - + api.badToken( tokenType ); // Try again, once + params.token = undefined; return api.getToken( tokenType, params.assert ).then( function ( token ) { params.token = token; return api.post( params, ajaxOptions ); @@ -281,17 +313,16 @@ d = apiPromise .then( function ( data ) { - // If token type is not available for this user, - // key '...token' is either missing or set to boolean false - if ( data.tokens && data.tokens[type + 'token'] ) { - return data.tokens[type + 'token']; + if ( data.tokens && data.tokens[ type + 'token' ] ) { + return data.tokens[ type + 'token' ]; } + // If token type is not available for this user, + // key '...token' is either missing or set to boolean false return $.Deferred().reject( 'token-missing', data ); }, function () { // Clear promise. Do not cache errors. delete promiseGroup[ type + 'Token' ]; - // Pass on to allow the caller to handle the error return this; } ) @@ -306,6 +337,23 @@ } return d; + }, + + /** + * Indicate that the cached token for a certain action of the API is bad. + * + * Call this if you get a 'badtoken' error when using the token returned by #getToken. + * You may also want to use #postWithToken instead, which invalidates bad cached tokens + * automatically. + * + * @param {string} type Token type + * @since 1.26 + */ + badToken: function ( type ) { + var promiseGroup = promises[ this.defaults.ajax.url ]; + if ( promiseGroup ) { + delete promiseGroup[ type + 'Token' ]; + } } }; diff --git a/resources/src/mediawiki.api/mediawiki.api.login.js b/resources/src/mediawiki.api/mediawiki.api.login.js index 25257927..2b709aae 100644 --- a/resources/src/mediawiki.api/mediawiki.api.login.js +++ b/resources/src/mediawiki.api/mediawiki.api.login.js @@ -1,5 +1,6 @@ /** * Make the two-step login easier. + * * @author Niklas Laxström * @class mw.Api.plugin.login * @since 1.22 diff --git a/resources/src/mediawiki.api/mediawiki.api.options.js b/resources/src/mediawiki.api/mediawiki.api.options.js index b839fbdc..399e6f43 100644 --- a/resources/src/mediawiki.api/mediawiki.api.options.js +++ b/resources/src/mediawiki.api/mediawiki.api.options.js @@ -14,7 +14,7 @@ */ saveOption: function ( name, value ) { var param = {}; - param[name] = value; + param[ name ] = value; return this.saveOptions( param ); }, @@ -38,7 +38,7 @@ deferreds = []; for ( name in options ) { - value = options[name] === null ? null : String( options[name] ); + value = options[ name ] === null ? null : String( options[ name ] ); // Can we bundle this option, or does it need a separate request? bundleable = diff --git a/resources/src/mediawiki.api/mediawiki.api.parse.js b/resources/src/mediawiki.api/mediawiki.api.parse.js index 2dcf8078..bc3d44f9 100644 --- a/resources/src/mediawiki.api/mediawiki.api.parse.js +++ b/resources/src/mediawiki.api/mediawiki.api.parse.js @@ -21,7 +21,7 @@ return apiPromise .then( function ( data ) { - return data.parse.text['*']; + return data.parse.text[ '*' ]; } ) .promise( { abort: apiPromise.abort } ); } diff --git a/resources/src/mediawiki.api/mediawiki.api.upload.js b/resources/src/mediawiki.api/mediawiki.api.upload.js new file mode 100644 index 00000000..6f3e4c3f --- /dev/null +++ b/resources/src/mediawiki.api/mediawiki.api.upload.js @@ -0,0 +1,391 @@ +/** + * Provides an interface for uploading files to MediaWiki. + * + * @class mw.Api.plugin.upload + * @singleton + */ +( function ( mw, $ ) { + var nonce = 0, + fieldsAllowed = { + stash: true, + filekey: true, + filename: true, + comment: true, + text: true, + watchlist: true, + ignorewarnings: true + }; + + /** + * @private + * Get nonce for iframe IDs on the page. + * + * @return {number} + */ + function getNonce() { + return nonce++; + } + + /** + * @private + * Given a non-empty object, return one of its keys. + * + * @param {Object} obj + * @return {string} + */ + function getFirstKey( obj ) { + for ( var key in obj ) { + if ( obj.hasOwnProperty( key ) ) { + return key; + } + } + } + + /** + * @private + * Get new iframe object for an upload. + * + * @return {HTMLIframeElement} + */ + function getNewIframe( id ) { + var frame = document.createElement( 'iframe' ); + frame.id = id; + frame.name = id; + return frame; + } + + /** + * @private + * Shortcut for getting hidden inputs + * + * @return {jQuery} + */ + function getHiddenInput( name, val ) { + return $( '<input type="hidden" />' ) + .attr( 'name', name ) + .val( val ); + } + + /** + * Process the result of the form submission, returned to an iframe. + * This is the iframe's onload event. + * + * @param {HTMLIframeElement} iframe Iframe to extract result from + * @return {Object} Response from the server. The return value may or may + * not be an XMLDocument, this code was copied from elsewhere, so if you + * see an unexpected return type, please file a bug. + */ + function processIframeResult( iframe ) { + var json, + doc = iframe.contentDocument || frames[ iframe.id ].document; + + if ( doc.XMLDocument ) { + // The response is a document property in IE + return doc.XMLDocument; + } + + if ( doc.body ) { + // Get the json string + // We're actually searching through an HTML doc here -- + // according to mdale we need to do this + // because IE does not load JSON properly in an iframe + json = $( doc.body ).find( 'pre' ).text(); + + return JSON.parse( json ); + } + + // Response is a xml document + return doc; + } + + function formDataAvailable() { + return window.FormData !== undefined && + window.File !== undefined && + window.File.prototype.slice !== undefined; + } + + $.extend( mw.Api.prototype, { + /** + * Upload a file to MediaWiki. + * + * The file will be uploaded using AJAX and FormData, if the browser supports it, or via an + * iframe if it doesn't. + * + * Caveats of iframe upload: + * - The returned jQuery.Promise will not receive `progress` notifications during the upload + * - It is incompatible with uploads to a foreign wiki using mw.ForeignApi + * - You must pass a HTMLInputElement and not a File for it to be possible + * + * @param {HTMLInputElement|File} file HTML input type=file element with a file already inside + * of it, or a File object. + * @param {Object} data Other upload options, see action=upload API docs for more + * @return {jQuery.Promise} + */ + upload: function ( file, data ) { + var isFileInput, canUseFormData; + + isFileInput = file && file.nodeType === Node.ELEMENT_NODE; + + if ( formDataAvailable() && isFileInput && file.files ) { + file = file.files[ 0 ]; + } + + if ( !file ) { + throw new Error( 'No file' ); + } + + canUseFormData = formDataAvailable() && file instanceof window.File; + + if ( !isFileInput && !canUseFormData ) { + throw new Error( 'Unsupported argument type passed to mw.Api.upload' ); + } + + if ( canUseFormData ) { + return this.uploadWithFormData( file, data ); + } + + return this.uploadWithIframe( file, data ); + }, + + /** + * Upload a file to MediaWiki with an iframe and a form. + * + * This method is necessary for browsers without the File/FormData + * APIs, and continues to work in browsers with those APIs. + * + * The rough sketch of how this method works is as follows: + * 1. An iframe is loaded with no content. + * 2. A form is submitted with the passed-in file input and some extras. + * 3. The MediaWiki API receives that form data, and sends back a response. + * 4. The response is sent to the iframe, because we set target=(iframe id) + * 5. The response is parsed out of the iframe's document, and passed back + * through the promise. + * + * @private + * @param {HTMLInputElement} file The file input with a file in it. + * @param {Object} data Other upload options, see action=upload API docs for more + * @return {jQuery.Promise} + */ + uploadWithIframe: function ( file, data ) { + var key, + tokenPromise = $.Deferred(), + api = this, + deferred = $.Deferred(), + nonce = getNonce(), + id = 'uploadframe-' + nonce, + $form = $( '<form>' ), + iframe = getNewIframe( id ), + $iframe = $( iframe ); + + for ( key in data ) { + if ( !fieldsAllowed[ key ] ) { + delete data[ key ]; + } + } + + data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data ); + $form.addClass( 'mw-api-upload-form' ); + + $form.css( 'display', 'none' ) + .attr( { + action: this.defaults.ajax.url, + method: 'POST', + target: id, + enctype: 'multipart/form-data' + } ); + + $iframe.one( 'load', function () { + $iframe.one( 'load', function () { + var result = processIframeResult( iframe ); + deferred.notify( 1 ); + + if ( !result ) { + deferred.reject( 'ok-but-empty', 'No response from API on upload attempt.' ); + } else if ( result.error ) { + if ( result.error.code === 'badtoken' ) { + api.badToken( 'edit' ); + } + + deferred.reject( result.error.code, result ); + } else if ( result.upload && result.upload.warnings ) { + deferred.reject( getFirstKey( result.upload.warnings ), result ); + } else { + deferred.resolve( result ); + } + } ); + tokenPromise.done( function () { + $form.submit(); + } ); + } ); + + $iframe.error( function ( error ) { + deferred.reject( 'http', error ); + } ); + + $iframe.prop( 'src', 'about:blank' ).hide(); + + file.name = 'file'; + + $.each( data, function ( key, val ) { + $form.append( getHiddenInput( key, val ) ); + } ); + + if ( !data.filename && !data.stash ) { + throw new Error( 'Filename not included in file data.' ); + } + + if ( this.needToken() ) { + this.getEditToken().then( function ( token ) { + $form.append( getHiddenInput( 'token', token ) ); + tokenPromise.resolve(); + }, tokenPromise.reject ); + } else { + tokenPromise.resolve(); + } + + $( 'body' ).append( $form, $iframe ); + + deferred.always( function () { + $form.remove(); + $iframe.remove(); + } ); + + return deferred.promise(); + }, + + /** + * Uploads a file using the FormData API. + * + * @private + * @param {File} file + * @param {Object} data Other upload options, see action=upload API docs for more + * @return {jQuery.Promise} + */ + uploadWithFormData: function ( file, data ) { + var key, + deferred = $.Deferred(); + + for ( key in data ) { + if ( !fieldsAllowed[ key ] ) { + delete data[ key ]; + } + } + + data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data ); + data.file = file; + + if ( !data.filename && !data.stash ) { + throw new Error( 'Filename not included in file data.' ); + } + + // Use this.postWithEditToken() or this.post() + this[ this.needToken() ? 'postWithEditToken' : 'post' ]( data, { + // Use FormData (if we got here, we know that it's available) + contentType: 'multipart/form-data', + // Provide upload progress notifications + xhr: function () { + var xhr = $.ajaxSettings.xhr(); + if ( xhr.upload ) { + // need to bind this event before we open the connection (see note at + // https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest/Using_XMLHttpRequest#Monitoring_progress) + xhr.upload.addEventListener( 'progress', function ( ev ) { + if ( ev.lengthComputable ) { + deferred.notify( ev.loaded / ev.total ); + } + } ); + } + return xhr; + } + } ) + .done( function ( result ) { + deferred.notify( 1 ); + if ( result.upload && result.upload.warnings ) { + deferred.reject( getFirstKey( result.upload.warnings ), result ); + } else { + deferred.resolve( result ); + } + } ) + .fail( function ( errorCode, result ) { + deferred.notify( 1 ); + deferred.reject( errorCode, result ); + } ); + + return deferred.promise(); + }, + + /** + * Upload a file to the stash. + * + * This function will return a promise, which when resolved, will pass back a function + * to finish the stash upload. You can call that function with an argument containing + * more, or conflicting, data to pass to the server. For example: + * + * // upload a file to the stash with a placeholder filename + * api.uploadToStash( file, { filename: 'testing.png' } ).done( function ( finish ) { + * // finish is now the function we can use to finalize the upload + * // pass it a new filename from user input to override the initial value + * finish( { filename: getFilenameFromUser() } ).done( function ( data ) { + * // the upload is complete, data holds the API response + * } ); + * } ); + * + * @param {File|HTMLInputElement} file + * @param {Object} [data] + * @return {jQuery.Promise} + * @return {Function} return.finishStashUpload Call this function to finish the upload. + * @return {Object} return.finishStashUpload.data Additional data for the upload. + * @return {jQuery.Promise} return.finishStashUpload.return API promise for the final upload + * @return {Object} return.finishStashUpload.return.data API return value for the final upload + */ + uploadToStash: function ( file, data ) { + var filekey, + api = this; + + if ( !data.filename ) { + throw new Error( 'Filename not included in file data.' ); + } + + function finishUpload( moreData ) { + data = $.extend( data, moreData ); + data.filekey = filekey; + data.action = 'upload'; + data.format = 'json'; + + if ( !data.filename ) { + throw new Error( 'Filename not included in file data.' ); + } + + return api.postWithEditToken( data ).then( function ( result ) { + if ( result.upload && result.upload.warnings ) { + return $.Deferred().reject( getFirstKey( result.upload.warnings ), result ).promise(); + } + return result; + } ); + } + + return this.upload( file, { stash: true, filename: data.filename } ).then( + function ( result ) { + filekey = result.upload.filekey; + return finishUpload; + }, + function ( errorCode, result ) { + if ( result && result.upload && result.upload.filekey ) { + // Ignore any warnings if 'filekey' was returned, that's all we care about + filekey = result.upload.filekey; + return $.Deferred().resolve( finishUpload ); + } + return $.Deferred().reject( errorCode, result ); + } + ); + }, + + needToken: function () { + return true; + } + } ); + + /** + * @class mw.Api + * @mixins mw.Api.plugin.upload + */ +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.api/mediawiki.api.watch.js b/resources/src/mediawiki.api/mediawiki.api.watch.js index 40ba136d..a2ff1292 100644 --- a/resources/src/mediawiki.api/mediawiki.api.watch.js +++ b/resources/src/mediawiki.api/mediawiki.api.watch.js @@ -37,7 +37,7 @@ return apiPromise .then( function ( data ) { // If a single page was given (not an array) respond with a single item as well. - return $.isArray( pages ) ? data.watch : data.watch[0]; + return $.isArray( pages ) ? data.watch : data.watch[ 0 ]; } ) .promise( { abort: apiPromise.abort } ); } diff --git a/resources/src/mediawiki.language/languages/bs.js b/resources/src/mediawiki.language/languages/bs.js index b56e4b29..cb9e19ed 100644 --- a/resources/src/mediawiki.language/languages/bs.js +++ b/resources/src/mediawiki.language/languages/bs.js @@ -4,8 +4,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms = mediaWiki.language.getData( 'bs', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'instrumental': // instrumental diff --git a/resources/src/mediawiki.language/languages/dsb.js b/resources/src/mediawiki.language/languages/dsb.js index 69c36cc0..dc4447ab 100644 --- a/resources/src/mediawiki.language/languages/dsb.js +++ b/resources/src/mediawiki.language/languages/dsb.js @@ -4,8 +4,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms = mediaWiki.language.getData( 'dsb', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'instrumental': // instrumental diff --git a/resources/src/mediawiki.language/languages/fi.js b/resources/src/mediawiki.language/languages/fi.js index d9c2b06d..2bbfc6b8 100644 --- a/resources/src/mediawiki.language/languages/fi.js +++ b/resources/src/mediawiki.language/languages/fi.js @@ -7,8 +7,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms, aou, origWord; grammarForms = mediaWiki.language.getData( 'fi', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } // vowel harmony flag diff --git a/resources/src/mediawiki.language/languages/ga.js b/resources/src/mediawiki.language/languages/ga.js index fb4e9396..a4c911a3 100644 --- a/resources/src/mediawiki.language/languages/ga.js +++ b/resources/src/mediawiki.language/languages/ga.js @@ -5,8 +5,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { /*jshint onecase:true */ var grammarForms = mediaWiki.language.getData( 'ga', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'ainmlae': diff --git a/resources/src/mediawiki.language/languages/he.js b/resources/src/mediawiki.language/languages/he.js index d1eba43b..945f02fe 100644 --- a/resources/src/mediawiki.language/languages/he.js +++ b/resources/src/mediawiki.language/languages/he.js @@ -4,8 +4,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms = mediaWiki.language.getData( 'he', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'prefixed': diff --git a/resources/src/mediawiki.language/languages/hsb.js b/resources/src/mediawiki.language/languages/hsb.js index 2c0abd3d..8e9b1296 100644 --- a/resources/src/mediawiki.language/languages/hsb.js +++ b/resources/src/mediawiki.language/languages/hsb.js @@ -4,8 +4,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms = mediaWiki.language.getData( 'hsb', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'instrumental': // instrumental diff --git a/resources/src/mediawiki.language/languages/hu.js b/resources/src/mediawiki.language/languages/hu.js index d72a1c05..4f8f74df 100644 --- a/resources/src/mediawiki.language/languages/hu.js +++ b/resources/src/mediawiki.language/languages/hu.js @@ -5,8 +5,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms = mediaWiki.language.getData( 'hu', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'rol': diff --git a/resources/src/mediawiki.language/languages/hy.js b/resources/src/mediawiki.language/languages/hy.js index c4a1cf73..935d466d 100644 --- a/resources/src/mediawiki.language/languages/hy.js +++ b/resources/src/mediawiki.language/languages/hy.js @@ -5,8 +5,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { /*jshint onecase:true */ var grammarForms = mediaWiki.language.getData( 'hy', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } // These rules are not perfect, but they are currently only used for site names so it doesn't diff --git a/resources/src/mediawiki.language/languages/la.js b/resources/src/mediawiki.language/languages/la.js index 52e8dd44..29e04a67 100644 --- a/resources/src/mediawiki.language/languages/la.js +++ b/resources/src/mediawiki.language/languages/la.js @@ -5,8 +5,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms = mediaWiki.language.getData( 'la', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'genitive': @@ -30,7 +30,7 @@ mediaWiki.language.convertGrammar = function ( word, form ) { word = word.replace( /nuntii$/i, 'nuntios' );// 2nd declension plural (partly) word = word.replace( /tio$/i, 'tionem' ); // 3rd declension singular (partly) word = word.replace( /ns$/i, 'ntem' ); - word = word.replace( /as$/i, 'atem'); + word = word.replace( /as$/i, 'atem' ); word = word.replace( /es$/i, 'em' ); // 5th declension singular break; case 'ablative': @@ -42,7 +42,7 @@ mediaWiki.language.convertGrammar = function ( word, form ) { word = word.replace( /nuntii$/i, 'nuntiis' ); // 2nd declension plural (partly) word = word.replace( /tio$/i, 'tione' ); // 3rd declension singular (partly) word = word.replace( /ns$/i, 'nte' ); - word = word.replace( /as$/i, 'ate'); + word = word.replace( /as$/i, 'ate' ); word = word.replace( /es$/i, 'e' ); // 5th declension singular break; } diff --git a/resources/src/mediawiki.language/languages/os.js b/resources/src/mediawiki.language/languages/os.js index 554e99d4..3e0f279d 100644 --- a/resources/src/mediawiki.language/languages/os.js +++ b/resources/src/mediawiki.language/languages/os.js @@ -14,8 +14,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { // Variable for ending ending = ''; - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } // Checking if the $word is in plural form if ( word.match( /тæ$/i ) ) { diff --git a/resources/src/mediawiki.language/languages/ru.js b/resources/src/mediawiki.language/languages/ru.js index 2077b6be..ee1d6ef2 100644 --- a/resources/src/mediawiki.language/languages/ru.js +++ b/resources/src/mediawiki.language/languages/ru.js @@ -10,8 +10,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { 'use strict'; var grammarForms = mediaWiki.language.getData( 'ru', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'genitive': // родительный падеж diff --git a/resources/src/mediawiki.language/languages/sl.js b/resources/src/mediawiki.language/languages/sl.js index d20d0b34..3d8bdfde 100644 --- a/resources/src/mediawiki.language/languages/sl.js +++ b/resources/src/mediawiki.language/languages/sl.js @@ -4,8 +4,8 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms = mediaWiki.language.getData( 'sl', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'mestnik': // locative diff --git a/resources/src/mediawiki.language/languages/uk.js b/resources/src/mediawiki.language/languages/uk.js index 550a388c..a22874b3 100644 --- a/resources/src/mediawiki.language/languages/uk.js +++ b/resources/src/mediawiki.language/languages/uk.js @@ -4,31 +4,31 @@ mediaWiki.language.convertGrammar = function ( word, form ) { var grammarForms = mediaWiki.language.getData( 'uk', 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word]; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ]; } switch ( form ) { case 'genitive': // родовий відмінок if ( word.slice( -4 ) !== 'вікі' && word.slice( -4 ) !== 'Вікі' ) { if ( word.slice( -1 ) === 'ь' ) { - word = word.slice(0, -1 ) + 'я'; + word = word.slice( 0, -1 ) + 'я'; } else if ( word.slice( -2 ) === 'ія' ) { - word = word.slice(0, -2 ) + 'ії'; + word = word.slice( 0, -2 ) + 'ії'; } else if ( word.slice( -2 ) === 'ка' ) { - word = word.slice(0, -2 ) + 'ки'; + word = word.slice( 0, -2 ) + 'ки'; } else if ( word.slice( -2 ) === 'ти' ) { - word = word.slice(0, -2 ) + 'тей'; + word = word.slice( 0, -2 ) + 'тей'; } else if ( word.slice( -2 ) === 'ды' ) { - word = word.slice(0, -2 ) + 'дов'; + word = word.slice( 0, -2 ) + 'дов'; } else if ( word.slice( -3 ) === 'ник' ) { - word = word.slice(0, -3 ) + 'ника'; + word = word.slice( 0, -3 ) + 'ника'; } } break; case 'accusative': // знахідний відмінок if ( word.slice( -4 ) !== 'вікі' && word.slice( -4 ) !== 'Вікі' ) { if ( word.slice( -2 ) === 'ія' ) { - word = word.slice(0, -2 ) + 'ію'; + word = word.slice( 0, -2 ) + 'ію'; } } break; diff --git a/resources/src/mediawiki.language/mediawiki.cldr.js b/resources/src/mediawiki.language/mediawiki.cldr.js index f6fb8f10..ca4b6fbe 100644 --- a/resources/src/mediawiki.language/mediawiki.cldr.js +++ b/resources/src/mediawiki.language/mediawiki.cldr.js @@ -21,7 +21,7 @@ getPluralForm: function ( number, pluralRules ) { var i; for ( i = 0; i < pluralRules.length; i++ ) { - if ( mw.libs.pluralRuleParser( pluralRules[i], number ) ) { + if ( mw.libs.pluralRuleParser( pluralRules[ i ], number ) ) { break; } } diff --git a/resources/src/mediawiki.language/mediawiki.language.init.js b/resources/src/mediawiki.language/mediawiki.language.init.js index b3765c85..808f6e5e 100644 --- a/resources/src/mediawiki.language/mediawiki.language.init.js +++ b/resources/src/mediawiki.language/mediawiki.language.init.js @@ -55,8 +55,8 @@ getData: function ( langCode, dataKey ) { var langData = mw.language.data; langCode = langCode.toLowerCase(); - if ( langData && langData[langCode] instanceof mw.Map ) { - return langData[langCode].get( dataKey ); + if ( langData && langData[ langCode ] instanceof mw.Map ) { + return langData[ langCode ].get( dataKey ); } return undefined; }, @@ -73,10 +73,10 @@ setData: function ( langCode, dataKey, value ) { var langData = mw.language.data; langCode = langCode.toLowerCase(); - if ( !( langData[langCode] instanceof mw.Map ) ) { - langData[langCode] = new mw.Map(); + if ( !( langData[ langCode ] instanceof mw.Map ) ) { + langData[ langCode ] = new mw.Map(); } - langData[langCode].set( dataKey, value ); + langData[ langCode ].set( dataKey, value ); } }; diff --git a/resources/src/mediawiki.language/mediawiki.language.js b/resources/src/mediawiki.language/mediawiki.language.js index 78e39191..0d324ed3 100644 --- a/resources/src/mediawiki.language/mediawiki.language.js +++ b/resources/src/mediawiki.language/mediawiki.language.js @@ -29,8 +29,8 @@ $.extend( mw.language, { return mw.language.convertPlural( parseInt( count, 10 ), template.parameters ); } // Could not process plural return first form or nothing - if ( template.parameters[0] ) { - return template.parameters[0]; + if ( template.parameters[ 0 ] ) { + return template.parameters[ 0 ]; } return ''; }, @@ -47,8 +47,8 @@ $.extend( mw.language, { var pluralRules, pluralFormIndex = 0; - if ( explicitPluralForms && explicitPluralForms[count] ) { - return explicitPluralForms[count]; + if ( explicitPluralForms && explicitPluralForms[ count ] ) { + return explicitPluralForms[ count ]; } if ( !forms || forms.length === 0 ) { @@ -58,11 +58,11 @@ $.extend( mw.language, { pluralRules = mw.language.getData( mw.config.get( 'wgUserLanguage' ), 'pluralRules' ); if ( !pluralRules ) { // default fallback. - return ( count === 1 ) ? forms[0] : forms[1]; + return ( count === 1 ) ? forms[ 0 ] : forms[ 1 ]; } pluralFormIndex = mw.cldr.getPluralForm( count, pluralRules ); pluralFormIndex = Math.min( pluralFormIndex, forms.length - 1 ); - return forms[pluralFormIndex]; + return forms[ pluralFormIndex ]; }, /** @@ -90,7 +90,7 @@ $.extend( mw.language, { * * @param {string} gender 'male', 'female', or anything else for neutral. * @param {Array} forms List of gender forms - * @return string + * @return {string} */ gender: function ( gender, forms ) { if ( !forms || forms.length === 0 ) { @@ -98,12 +98,12 @@ $.extend( mw.language, { } forms = mw.language.preConvertPlural( forms, 2 ); if ( gender === 'male' ) { - return forms[0]; + return forms[ 0 ]; } if ( gender === 'female' ) { - return forms[1]; + return forms[ 1 ]; } - return ( forms.length === 3 ) ? forms[2] : forms[0]; + return ( forms.length === 3 ) ? forms[ 2 ] : forms[ 0 ]; }, /** @@ -119,8 +119,8 @@ $.extend( mw.language, { */ convertGrammar: function ( word, form ) { var grammarForms = mw.language.getData( mw.config.get( 'wgUserLanguage' ), 'grammarForms' ); - if ( grammarForms && grammarForms[form] ) { - return grammarForms[form][word] || word; + if ( grammarForms && grammarForms[ form ] ) { + return grammarForms[ form ][ word ] || word; } return word; }, @@ -138,7 +138,7 @@ $.extend( mw.language, { i = 0; for ( ; i < list.length; i++ ) { - text += list[i]; + text += list[ i ]; if ( list.length - 2 === i ) { text += mw.msg( 'and' ) + mw.msg( 'word-separator' ); } else if ( list.length - 1 !== i ) { diff --git a/resources/src/mediawiki.language/mediawiki.language.numbers.js b/resources/src/mediawiki.language/mediawiki.language.numbers.js index 3c13055b..268985f8 100644 --- a/resources/src/mediawiki.language/mediawiki.language.numbers.js +++ b/resources/src/mediawiki.language/mediawiki.language.numbers.js @@ -7,6 +7,26 @@ */ /** + * Replicate a string 'n' times. + * + * @private + * @param {string} str The string to replicate + * @param {number} num Number of times to replicate the string + * @return {string} + */ + function replicate( str, num ) { + if ( num <= 0 || !str ) { + return ''; + } + + var buf = []; + while ( num-- ) { + buf.push( str ); + } + return buf.join( '' ); + } + + /** * Pad a string to guarantee that it is at least `size` length by * filling with the character `ch` at either the start or end of the * string. Pads at the start, by default. @@ -34,26 +54,6 @@ } /** - * Replicate a string 'n' times. - * - * @private - * @param {string} str The string to replicate - * @param {number} num Number of times to replicate the string - * @return {string} - */ - function replicate( str, num ) { - if ( num <= 0 || !str ) { - return ''; - } - - var buf = []; - while ( num-- ) { - buf.push( str ); - } - return buf.join( '' ); - } - - /** * Apply numeric pattern to absolute value using options. Gives no * consideration to local customs. * @@ -74,7 +74,7 @@ decimal: '.' }; - if ( isNaN( value) ) { + if ( isNaN( value ) ) { return value; } @@ -85,62 +85,62 @@ off, remainder, patternParts = pattern.split( '.' ), - maxPlaces = ( patternParts[1] || [] ).length, + maxPlaces = ( patternParts[ 1 ] || [] ).length, valueParts = String( Math.abs( value ) ).split( '.' ), - fractional = valueParts[1] || '', + fractional = valueParts[ 1 ] || '', groupSize = 0, groupSize2 = 0, pieces = []; - if ( patternParts[1] ) { + if ( patternParts[ 1 ] ) { // Pad fractional with trailing zeros - padLength = ( patternParts[1] && patternParts[1].lastIndexOf( '0' ) + 1 ); + padLength = ( patternParts[ 1 ] && patternParts[ 1 ].lastIndexOf( '0' ) + 1 ); if ( padLength > fractional.length ) { - valueParts[1] = pad( fractional, padLength, '0', true ); + valueParts[ 1 ] = pad( fractional, padLength, '0', true ); } // Truncate fractional if ( maxPlaces < fractional.length ) { - valueParts[1] = fractional.slice( 0, maxPlaces ); + valueParts[ 1 ] = fractional.slice( 0, maxPlaces ); } } else { - if ( valueParts[1] ) { + if ( valueParts[ 1 ] ) { valueParts.pop(); } } // Pad whole with leading zeros - patternDigits = patternParts[0].replace( ',', '' ); + patternDigits = patternParts[ 0 ].replace( ',', '' ); padLength = patternDigits.indexOf( '0' ); if ( padLength !== -1 ) { padLength = patternDigits.length - padLength; - if ( padLength > valueParts[0].length ) { - valueParts[0] = pad( valueParts[0], padLength ); + if ( padLength > valueParts[ 0 ].length ) { + valueParts[ 0 ] = pad( valueParts[ 0 ], padLength ); } // Truncate whole if ( patternDigits.indexOf( '#' ) === -1 ) { - valueParts[0] = valueParts[0].slice( valueParts[0].length - padLength ); + valueParts[ 0 ] = valueParts[ 0 ].slice( valueParts[ 0 ].length - padLength ); } } // Add group separators - index = patternParts[0].lastIndexOf( ',' ); + index = patternParts[ 0 ].lastIndexOf( ',' ); if ( index !== -1 ) { - groupSize = patternParts[0].length - index - 1; - remainder = patternParts[0].slice( 0, index ); + groupSize = patternParts[ 0 ].length - index - 1; + remainder = patternParts[ 0 ].slice( 0, index ); index = remainder.lastIndexOf( ',' ); if ( index !== -1 ) { groupSize2 = remainder.length - index - 1; } } - for ( whole = valueParts[0]; whole; ) { + for ( whole = valueParts[ 0 ]; whole; ) { off = groupSize ? whole.length - groupSize : 0; pieces.push( ( off > 0 ) ? whole.slice( off ) : whole ); whole = ( off > 0 ) ? whole.slice( 0, off ) : ''; @@ -150,7 +150,7 @@ groupSize2 = null; } } - valueParts[0] = pieces.reverse().join( options.group ); + valueParts[ 0 ] = pieces.reverse().join( options.group ); return valueParts.join( options.decimal ); } @@ -198,17 +198,18 @@ convertedNumber = ''; for ( i = 0; i < numberString.length; i++ ) { - if ( transformTable[ numberString[i] ] ) { - convertedNumber += transformTable[numberString[i]]; + if ( transformTable[ numberString[ i ] ] ) { + convertedNumber += transformTable[ numberString[ i ] ]; } else { - convertedNumber += numberString[i]; + convertedNumber += numberString[ i ]; } } return integer ? parseInt( convertedNumber, 10 ) : convertedNumber; }, /** - * Get the digit transform table for current UI language. + * Get the digit transform table for current UI language. + * * @return {Object|Array} */ getDigitTransformTable: function () { @@ -217,7 +218,8 @@ }, /** - * Get the separator transform table for current UI language. + * Get the separator transform table for current UI language. + * * @return {Object|Array} */ getSeparatorTransformTable: function () { @@ -238,20 +240,20 @@ commafy: function ( value, pattern ) { var numberPattern, transformTable = mw.language.getSeparatorTransformTable(), - group = transformTable[','] || ',', + group = transformTable[ ',' ] || ',', numberPatternRE = /[#0,]*[#0](?:\.0*#*)?/, // not precise, but good enough - decimal = transformTable['.'] || '.', + decimal = transformTable[ '.' ] || '.', patternList = pattern.split( ';' ), - positivePattern = patternList[0]; + positivePattern = patternList[ 0 ]; - pattern = patternList[ ( value < 0 ) ? 1 : 0] || ( '-' + positivePattern ); + pattern = patternList[ ( value < 0 ) ? 1 : 0 ] || ( '-' + positivePattern ); numberPattern = positivePattern.match( numberPatternRE ); if ( !numberPattern ) { throw new Error( 'unable to find a number expression in pattern: ' + pattern ); } - return pattern.replace( numberPatternRE, commafyNumber( value, numberPattern[0], { + return pattern.replace( numberPatternRE, commafyNumber( value, numberPattern[ 0 ], { decimal: decimal, group: group } ) ); diff --git a/resources/src/mediawiki.language/specialcharacters.json b/resources/src/mediawiki.language/specialcharacters.json index bab92a1b..d4446ab8 100644 --- a/resources/src/mediawiki.language/specialcharacters.json +++ b/resources/src/mediawiki.language/specialcharacters.json @@ -1 +1,445 @@ -{"latin":["Á","á","À","à","Â","â","Ä","ä","Ã","ã","Ǎ","ǎ","Ā","ā","Ă","ă","Ą","ą","Å","å","Ć","ć","Ĉ","ĉ","Ç","ç","Č","č","Ċ","ċ","Đ","đ","Ď","ď","É","é","È","è","Ê","ê","Ë","ë","Ě","ě","Ē","ē","Ĕ","ĕ","Ė","ė","Ę","ę","Ĝ","ĝ","Ģ","ģ","Ğ","ğ","Ġ","ġ","Ĥ","ĥ","Ħ","ħ","Í","í","Ì","ì","Î","î","Ï","ï","Ĩ","ĩ","Ǐ","ǐ","Ī","ī","Ĭ","ĭ","İ","ı","Į","į","Ĵ","ĵ","Ķ","ķ","Ĺ","ĺ","Ļ","ļ","Ľ","ľ","Ł","ł","Ń","ń","Ñ","ñ","Ņ","ņ","Ň","ň","Ó","ó","Ò","ò","Ô","ô","Ö","ö","Õ","õ","Ǒ","ǒ","Ō","ō","Ŏ","ŏ","Ǫ","ǫ","Ő","ő","Ŕ","ŕ","Ŗ","ŗ","Ř","ř","Ś","ś","Ŝ","ŝ","Ş","ş","Š","š","Ș","ș","Ț","ț","Ť","ť","Ú","ú","Ù","ù","Û","û","Ü","ü","Ũ","ũ","Ů","ů","Ǔ","ǔ","Ū","ū","ǖ","ǘ","ǚ","ǜ","Ŭ","ŭ","Ų","ų","Ű","ű","Ŵ","ŵ","Ý","ý","Ŷ","ŷ","Ÿ","ÿ","Ȳ","ȳ","Ź","ź","Ž","ž","Ż","ż","Æ","æ","Ǣ","ǣ","Ø","ø","Œ","œ","ß","Ð","ð","Þ","þ","Ə","ə"],"latinextended":["Ḁ","ḁ","ẚ","Ạ","ạ","Ả","ả","Ấ","ấ","Ầ","ầ","Ẩ","ẩ","Ẫ","ẫ","Ậ","ậ","Ắ","ắ","Ằ","ằ","Ẳ","ẳ","Ẵ","ẵ","Ặ","ặ","Ḃ","ḃ","Ḅ","ḅ","Ḇ","ḇ","Ḉ","ḉ","Ḋ","ḋ","Ḍ","ḍ","Ḏ","ḏ","Ḑ","ḑ","Ḓ","ḓ","Ḕ","ḕ","Ḗ","ḗ","Ḙ","ḙ","Ḛ","ḛ","Ḝ","ḝ","Ẹ","ẹ","Ẻ","ẻ","Ẽ","ẽ","Ế","ế","Ề","ề","Ể","ể","Ễ","ễ","Ệ","ệ","Ḟ","ḟ","Ḡ","ḡ","Ḣ","ḣ","Ḥ","ḥ","Ḧ","ḧ","Ḩ","ḩ","Ḫ","ḫ","ẖ","Ḭ","ḭ","Ḯ","ḯ","Ỉ","ỉ","Ị","ị","Ḱ","ḱ","Ḳ","ḳ","Ḵ","ḵ","Ḷ","ḷ","Ḹ","ḹ","Ḻ","ḻ","Ḽ","ḽ","Ỻ","ỻ","Ḿ","ḿ","Ṁ","ṁ","Ṃ","ṃ","Ṅ","ṅ","Ṇ","ṇ","Ṉ","ṉ","Ṋ","ṋ","Ṍ","ṍ","Ṏ","ṏ","Ṑ","ṑ","Ṓ","ṓ","Ọ","ọ","Ỏ","ỏ","Ố","ố","Ồ","ồ","Ổ","ổ","Ỗ","ỗ","Ộ","ộ","Ớ","ớ","Ờ","ờ","Ở","ở","Ỡ","ỡ","Ợ","ợ","Ǿ","ǿ","Ơ","ơ","Ṕ","ṕ","Ṗ","ṗ","Ṙ","ṙ","Ṛ","ṛ","Ṝ","ṝ","Ṟ","ṟ","Ṡ","ṡ","ẛ","Ṣ","ṣ","Ṥ","ṥ","Ṧ","ṧ","Ṩ","ṩ","ẜ","ẝ","Ṫ","ṫ","Ṭ","ṭ","Ṯ","ṯ","Ṱ","ṱ","ẗ","Ṳ","ṳ","Ṵ","ṵ","Ṷ","ṷ","Ṹ","ṹ","Ṻ","ṻ","Ụ","ụ","Ủ","ủ","Ứ","ứ","Ừ","ừ","Ử","ử","Ữ","ữ","Ự","ự","Ư","ư","Ǖ","Ǘ","Ǚ","Ǜ","Ṽ","ṽ","Ṿ","ṿ","Ỽ","ỽ","Ẁ","ẁ","Ẃ","ẃ","Ẅ","ẅ","Ẇ","ẇ","Ẉ","ẉ","ẘ","Ẋ","ẋ","Ẍ","ẍ","Ẏ","ẏ","ẙ","Ỳ","ỳ","Ỵ","ỵ","Ỷ","ỷ","Ỹ","ỹ","Ỿ","ỿ","Ẑ","ẑ","Ẓ","ẓ","Ẕ","ẕ","Ǽ","ǽ","ẞ","ẟ"],"ipa":["p","t̪","t","ʈ","c","k","q","ʡ","ʔ","b","d̪","d","ɖ","ɟ","ɡ","ɢ","ɓ","ɗ","ʄ","ɠ","ʛ","t͡s","t͡ʃ","t͡ɕ","d͡z","d͡ʒ","d͡ʑ","ɸ","f","θ","s","ʃ","ʅ","ʆ","ʂ","ɕ","ç","ɧ","x","χ","ħ","ʜ","h","β","v","ʍ","ð","z","ʒ","ʓ","ʐ","ʑ","ʝ","ɣ","ʁ","ʕ","ʖ","ʢ","ɦ","ɬ","ɮ","m","m̩","ɱ","ɱ̩","ɱ̍","n̪","n̪̍","n","n̩","ɳ","ɳ̩","ɲ","ɲ̩","ŋ","ŋ̍","ŋ̩","ɴ","ɴ̩","ʙ","ʙ̩","r","r̩","ʀ","ʀ̩","ɾ","ɽ","ɿ","ɺ","l̪","l̪̩","l","l̩","ɫ","ɫ̩","ɭ","ɭ̩","ʎ","ʎ̩","ʟ","ʟ̩","w","ɥ","ʋ","ɹ","ɻ","j","ɰ","ʘ","ǂ","ǀ","!","ǁ","ʰ","ʱ","ʷ","ʸ","ʲ","ʳ","ⁿ","ˡ","ʴ","ʵ","ˢ","ˣ","ˠ","ʶ","ˤ","ˁ","ˀ","ʼ","i","i̯","ĩ","y","y̯","ỹ","ɪ","ɪ̯","ɪ̃","ʏ","ʏ̯","ʏ̃","ɨ","ɨ̯","ɨ̃","ʉ","ʉ̯","ʉ̃","ɯ","ɯ̯","ɯ̃","u","u̯","ũ","ʊ","ʊ̯","ʊ̃","e","e̯","ẽ","ø","ø̯","ø̃","ɘ","ɘ̯","ɘ̃","ɵ","ɵ̯","ɵ̃","ɤ","ɤ̯","ɤ̃","o","o̯","õ","ɛ","ɛ̯","ɛ̃","œ","œ̯","œ̃","ɜ","ɜ̯","ɜ̃","ə","ə̯","ə̃","ɞ","ɞ̯","ɞ̃","ʌ","ʌ̯","ʌ̃","ɔ","ɔ̯","ɔ̃","æ","æ̯","æ̃","ɶ","ɶ̯","ɶ̃","a","a̯","ã","ɐ","ɐ̯","ɐ̃","ɑ","ɑ̯","ɑ̃","ɒ","ɒ̯","ɒ̃","ˈ","ˌ","ː","ˑ","˘",".","‿","|","‖","ɚ","ɝ"],"symbols":["~","|","¡","¿","†","‡","↔","↑","↓","•","¶","#","½","⅓","⅔","¼","¾","⅛","⅜","⅝","⅞","∞","‘","’",{"label":"“”","action":{"type":"encapsulate","options":{"pre":"“","post":"”"}}},{"label":"„“","action":{"type":"encapsulate","options":{"pre":"„","post":"“"}}},{"label":"„”","action":{"type":"encapsulate","options":{"pre":"„","post":"”"}}},{"label":"«»","action":{"type":"encapsulate","options":{"pre":"«","post":"»"}}},"¤","₳","฿","₵","¢","₡","₢","$","₫","₯","€","₠","₣","ƒ","₴","₭","₤","ℳ","₥","₦","№","₧","₰","£","៛","₨","₪","৳","₮","₩","¥","♠","♣","♥","♦","m²","m³",{"label":"–","titleMsg":"special-characters-title-endash","action":{"type":"replace","options":{"peri":"–","selectPeri":false}}},{"label":"—","titleMsg":"special-characters-title-emdash","action":{"type":"replace","options":{"peri":"—","selectPeri":false}}},"…","‘","’","“","”","°","′","″","≈","≠","≤","≥","±",{"label":"−","titleMsg":"special-characters-title-minus","action":{"type":"replace","options":{"peri":"−","selectPeri":false}}},"×","÷","←","→","·","§","‽"],"greek":["Α","Ά","α","ά","Β","β","Γ","γ","Δ","δ","Ε","Έ","ε","έ","Ζ","ζ","Η","Ή","η","ή","Θ","θ","Ι","Ί","ι","ί","Κ","κ","Λ","λ","Μ","μ","Ν","ν","Ξ","ξ","Ο","Ό","ο","ό","Π","π","Ρ","ρ","Σ","σ","ς","Τ","τ","Υ","Ύ","υ","ύ","Φ","φ","Χ","χ","Ψ","ψ","Ω","Ώ","ω","ώ"],"cyrillic":["А","а","Ӑ","ӑ","Ӓ","ӓ","Ә","ә","Ӛ","ӛ","Б","б","В","в","Г","г","Ґ","ґ","Ӷ","ӷ","Ѓ","ѓ","Ӻ","ӻ","Ғ","ғ","Ҕ","ҕ","Д","д","Ԁ","ԁ","Ԃ","ԃ","Ђ","ђ","Е","е","Ѐ","ѐ","Є","є","Ё","ё","Ӗ","ӗ","Ҽ","ҽ","Ҿ","ҿ","Ж","ж","Җ","җ","Ӂ","ӂ","Ӝ","ӝ","З","з","Ҙ","ҙ","Ӟ","ӟ","Ԑ","ԑ","Ӡ","ӡ","Ѕ","ѕ","Ԅ","ԅ","Ԇ","ԇ","И","и","І","і","Ї","ї",["◌Ӏ","Ӏ"],["◌ӏ","ӏ"],"Й","й","Ӣ","ӣ","Ѝ","ѝ","Ҋ","ҋ","Ӥ","ӥ","Ј","ј","К","к","Ќ","ќ","Қ","қ","Ҝ","ҝ","Ҟ","ҟ","Ҡ","ҡ","Ӄ","ӄ","Ԛ","ԛ","Л","л","Љ","љ","Ԉ","ԉ","Ԓ","ԓ","Ӆ","ӆ","М","м","Ӎ","ӎ","Н","н","Њ","њ","Ң","ң","Ҥ","ҥ","Ӈ","ӈ","Ԋ","ԋ","Ӊ","ӊ","О","о","Ҩ","ҩ","Ӧ","ӧ","Ө","ө","Ӫ","ӫ","П","п","Ԥ","ԥ","Ҧ","ҧ","Р","р","Ҏ","ҏ","С","с","Ҫ","ҫ","Т","т","Ћ","ћ","Ԍ","ԍ","Ҭ","ҭ","Ԏ","ԏ","У","у","Ў","ў","Ӯ","ӯ","Ӱ","ӱ","Ӳ","ӳ","Ү","ү","Ұ","ұ","Ф","ф","Х","х","Ҳ","ҳ","Ӽ","ӽ","Ӿ","ӿ","Һ","һ","Ц","ц","Ч","ч","Ҵ","ҵ","Ҷ","ҷ","Ҹ","ҹ","Ӌ","ӌ","Ӵ","ӵ","Џ","џ","Ш","ш","Щ","щ","Ъ","ъ","Ы","ы","Ӹ","ӹ","Ь","ь","Ҍ","ҍ","Э","э","Ӭ","ӭ","Ю","ю","Я","я","Ԝ","ԝ","Ѡ","ѡ","Ѣ","ѣ","Ѥ","ѥ","Ѧ","ѧ","Ѩ","ѩ","Ѫ","ѫ","Ѭ","ѭ","Ѯ","ѯ","Ѱ","ѱ","Ѳ","ѳ","Ѵ","ѵ","Ѷ","ѷ","Ѹ","ѹ","Ѻ","ѻ","Ѽ","ѽ","Ѿ","ѿ","Ҁ","ҁ"],"arabic":["ا","ب","ت","ث","ج","ح","خ","د","ذ","ر","ز","س","ش","ص","ض","ط","ظ","ع","غ","ف","ق","ك","ل","م","ن","ه","و","ي","ء","آ","أ","إ","ٱ","ؤ","ئ","ى","ة","َ","ُ","ِ","ً","ٌ","ٍ","ّ","ْ","ٰ","،","؛","؟","ـ","٠","١","٢","٣","٤","٥","٦","٧","٨","٩","٪","٫","٬","٭",["ZWNJ",""],["ZWJ",""]],"arabicextended":["ٲ","ٳ","ٴ","ٵ","ݳ","ݴ","ٮ","ٻ","پ","ڀ","ݐ","ݑ","ݒ","ݓ","ݔ","ݕ","ݖ","ٹ","ٺ","ټ","ٽ","ٿ","ځ","ڂ","ڃ","ڄ","څ","چ","ڇ","ڿ","ݗ","ݘ","ݮ","ݯ","ݲ","ݼ","ڈ","ډ","ڊ","ڋ","ڌ","ڍ","ڎ","ڏ","ڐ","ۮ","ݙ","ݚ","ڑ","ڒ","ړ","ڔ","ڕ","ږ","ڗ","ژ","ڙ","ۯ","ݛ","ݫ","ݬ","ݱ","ښ","ڛ","ڜ","ݽ","ۺ","ݜ","ݭ","ݰ","ݾ","ڝ","ڞ","ۻ","ڟ","ڠ","ݝ","ݞ","ݟ","ۼ","ڡ","ڢ","ڣ","ڤ","ڥ","ڦ","ݠ","ݡ","ٯ","ڧ","ڨ","ػ","ؼ","ک","ڪ","ګ","ڬ","ڭ","ڮ","گ","ڰ","ڱ","ڲ","ڳ","ڴ","ݢ","ݣ","ݤ","ݿ","ڵ","ڶ","ڷ","ڸ","ݪ","ݥ","ݦ","ڹ","ں","ڻ","ڼ","ڽ","ݧ","ݨ","ݩ","ھ","ۀ","ہ","ۂ","ۃ","ە","ۿ","ٶ","ٷ","ۄ","ۅ","ۆ","ۇ","ۈ","ۉ","ۊ","ۋ","ۏ","ݸ","ݹ","ؠ","ؽ","ؾ","ؿ","ٸ","ی","ۍ","ێ","ې","ۑ","ے","ۓ","ݵ","ݶ","ݷ","ݺ","ݻ","ٖ","ٗ","٘","ٙ","ٚ","ٛ","ٜ","ٝ","ٞ","ٟ","۔","۽","۾","۰","۱","۲","۳","۴","۵","۶","۷","۸","۹"],"hebrew":["א","ב","ג","ד","ה","ו","ז","ח","ט","י","כ","ך","ל","מ","ם","נ","ן","ס","ע","פ","ף","צ","ץ","ק","ר","ש","ת","װ","ױ","ײ","׳","״","־","–",{"label":"„”","action":{"type":"encapsulate","options":{"pre":"„","post":"”"}}},{"label":"‚’","action":{"type":"encapsulate","options":{"pre":"‚","post":"’"}}},["◌ְ","ְ"],["◌ֱ","ֱ"],["◌ֲ","ֲ"],["◌ֳ","ֳ"],["◌ִ","ִ"],["◌ֵ","ֵ"],["◌ֶ","ֶ"],["◌ַ","ַ"],["◌ָ","ָ"],["◌ֹ","ֹ"],["◌ֻ","ֻ"],["◌ּ","ּ"],["◌ׁ","ׁ"],["◌ׂ","ׂ"],["◌ׇ","ׇ"],["◌֑","֑"],["◌֒","֒"],["◌֓","֓"],["◌֔","֔"],["◌֕","֕"],["◌֖","֖"],["◌֗","֗"],["◌֘","֘"],["◌֙","֙"],["◌֚","֚"],["◌֛","֛"],["◌֜","֜"],["◌֝","֝"],["◌֞","֞"],["◌֟","֟"],["◌֠","֠"],["◌֡","֡"],["◌֢","֢"],["◌֣","֣"],["◌֤","֤"],["◌֥","֥"],["◌֦","֦"],["◌֧","֧"],["◌֨","֨"],["◌֩","֩"],["◌֪","֪"],["◌֫","֫"],["◌֬","֬"],["◌֭","֭"],["◌֮","֮"],["◌֯","֯"],["◌ֿ","ֿ"],["◌׀","׀"],["◌׃","׃"]],"bangla":["অ","আ","ই","ঈ","উ","ঊ","ঋ","এ","ঐ","ও","ঔ","া","ি","ী","ু","ূ","ৃ","ে","ৈ","ো","ৌ","ক","খ","গ","ঘ","ঙ","চ","ছ","জ","ঝ","ঞ","ট","ঠ","ড","ঢ","ণ","ত","থ","দ","ধ","ন","প","ফ","ব","ভ","ম","য","র","ল","শ","ষ","স","হ","ড়","ঢ়","য়","ৎ","ং","ঃ","ঁ","্","১","২","৩","৪","৫","৬","৭","৮","৯","০"],"tamil":["௦","௧","௨","௩","௪","௫","௬","௭","௮","௯","௰","௱","௲","௳","௴","௵","௶","௷","௸","௹","௺","ௐ"],"telugu":["ఁ","ం","ః","అ","ఆ","ఇ","ఈ","ఉ","ఊ","ఋ","ౠ","ఌ","ౡ","ఎ","ఏ","ఐ","ఒ","ఓ","ఔ","క","ఖ","గ","ఘ","ఙ","చ","ఛ","జ","ఝ","ఞ","ట","ఠ","డ","ఢ","ణ","త","థ","ద","ధ","న","ప","ఫ","బ","భ","మ","య","ర","ఱ","ల","ళ","వ","శ","ష","స","హ","ా","ి","ీ","ు","ూ","ృ","ౄ","ె","ే","ై","ొ","ో","ౌ","్","ౢ","ౣ","ౘ","ౙ","౦","౧","౨","౩","౪","౫","౬","౭","౮","౯","ఽ","౸","౹","౺","౻","౼","౽","౾","౿"],"sinhala":["අ","ආ","ඇ","ඈ","ඉ","ඊ","උ","ඌ","ඍ","ඎ","ඏ","ඐ","එ","ඒ","ඓ","ඔ","ඕ","ඖ","ක","ඛ","ග","ඝ","ඞ","ඟ","ච","ඡ","ජ","ඣ","ඤ","ඥ","ඦ","ට","ඨ","ඩ","ඪ","ණ","ඬ","ත","ථ","ද","ධ","න","ඳ","ප","ඵ","බ","භ","ම","ඹ","ය","ර","ල","ව","ශ","ෂ","ස","හ","ළ","ෆ",["◌ා","ා"],["◌ැ","ැ"],["◌ෑ","ෑ"],["◌ි","ි"],["◌ී","ී"],["◌ු","ු"],["◌ූ","ූ"],["◌ෘ","ෘ"],["◌ෲ","ෲ"],["◌ෟ","ෟ"],["◌ෳ","ෳ"],["◌ෙ","ෙ"],["◌ේ","ේ"],["◌ො","ො"],["◌ෝ","ෝ"],["◌ෞ","ෞ"],["◌්","්"]],"devanagari":["ऀ","ँ","ं","ः","ऄ","अ","आ","इ","ई","उ","ऊ","ऋ","ऌ","ऍ","ऎ","ए","ऐ","ऑ","ऒ","ओ","औ","क","ख","ग","घ","ङ","च","छ","ज","झ","ञ","ट","ठ","ड","ढ","ण","त","थ","द","ध","न","ऩ","प","फ","ब","भ","म","य","र","ऱ","ल","ळ","ऴ","व","श","ष","स","ह","ऺ","ऻ","़","ऽ","ा","ि","ी","ु","ू","ृ","ॄ","ॅ","ॆ","े","ै","ॉ","ॊ","ो","ौ","्","ॎ","ॏ","ॐ","॑","॒","॓","॔","ॕ","ॖ","ॗ","क़","ख़","ग़","ज़","ड़","ढ़","फ़","य़","ॠ","ॡ","ॢ","ॣ","।","॥","०","१","२","३","४","५","६","७","८","९","॰","ॱ","ॲ","ॳ","ॴ","ॵ","ॶ","ॷ","ॹ","ॺ","ॻ","ॼ","ॽ","ॾ","ॿ"],"gujarati":["ૐ","ઁ","ં","ઃ","અ","આ","ઇ","ઈ","ઉ","ઊ","એ","ઐ","ઓ","ઔ","અં","ઋ","ઍ","ઑ","ઌ","ૠ","ૡ","ક","ખ","ગ","ઘ","ઙ","ચ","છ","જ","ઝ","ઞ","ટ","ઠ","ડ","ઢ","ણ","ત","થ","દ","ધ","ન","પ","ફ","બ","ભ","મ","ય","ર","લ","ળ","વ","શ","ષ","સ","હ","ક્ષ","જ્ઞ","ઽ","ા","િ","ી","ી","ુ","ૂ","ૃ","ૄ","ૅ","ે","ૈ","ૉ","ો","ૌ","ૢ","ૣ","્","૦","૧","૨","૩","૪","૫","૬","૭","૮","૯","૱"],"thai":["ก","ข","ฃ","ค","ฅ","ฆ","ง","จ","ฉ","ช","ซ","ฌ","ญ","ฎ","ฏ","ฐ","ฑ","ฒ","ณ","ด","ต","ถ","ท","ธ","น","บ","ป","ผ","ฝ","พ","ฟ","ภ","ม","ย","ร","ฤ","ล","ฦ","ว","ศ","ษ","ส","ห","ฬ","อ","ฮ","ะ","ั","า","ๅ","ำ","ิ","ี","ึ","ื","ุ","ู","เ","แ","โ","ใ","ไ","็","่","้","๊","๋","์","ํ","ฺ","๎","๐","๑","๒","๓","๔","๕","๖","๗","๘","๙","฿","ๆ","ฯ","๚","๏","๛"],"lao":["ກ","ຂ","ຄ","ງ","ຈ","ສ","ຊ","ຍ","ດ","ຕ","ຖ","ທ","ນ","ບ","ປ","ຜ","ຝ","ພ","ຟ","ມ","ຢ","ລ","ວ","ຫ","ອ","ຮ","ຣ","ໜ","ໝ","ຼ","ຽ","ະ","ັ","າ","ຳ","ິ","ີ","ຶ","ື","ຸ","ູ","ົ","ເ","ແ","ໂ","ໃ","ໄ","່","້","໊","໋","໌","ໍ","໐","໑","໒","໓","໔","໕","໖","໗","໘","໙","₭","ໆ","ຯ"],"khmer":["ក","ខ","គ","ឃ","ង","ច","ឆ","ជ","ឈ","ញ","ដ","ឋ","ឌ","ឍ","ណ","ត","ថ","ទ","ធ","ន","ប","ផ","ព","ភ","ម","យ","រ","ល","វ","ស","ហ","ឡ","អ","ឣ","ឤ","ឥ","ឦ","ឧ","ឨ","ឩ","ឪ","ឫ","ឬ","ឭ","ឮ","ឯ","ឰ","ឱ","ឲ","ឳ","្","឴","឵","ា","ិ","ី","ឹ","ឺ","ុ","ូ","ួ","ើ","ឿ","ៀ","េ","ែ","ៃ","ោ","ៅ","ំ","ះ","ៈ","៉","៊","់","៌","៍","៎","៏","័","៑","៓","៝","ៜ","០","១","២","៣","៤","៥","៦","៧","៨","៩","៛","។","៕","៖","ៗ","៘","៙","៚","៰","៱","៲","៳","៴","៵","៶","៷","៸","៹","᧠","᧡","᧢","᧣","᧤","᧥","᧦","᧧","᧨","᧩","᧪","᧫","᧬","᧭","᧮","᧯","᧰","᧱","᧲","᧳","᧴","᧵","᧶","᧷","᧸","᧹","᧺","᧻","᧼","᧽","᧾","᧿"]}
\ No newline at end of file +{ + "latin": [ + "Á", "á", "À", "à", "Â", "â", "Ä", "ä", "Ã", "ã", "Ǎ", "ǎ", "Ā", "ā", "Ă", "ă", "Ą", "ą", "Å", "å", "Ć", "ć", "Ĉ", "ĉ", "Ç", "ç", "Č", "č", "Ċ", "ċ", "Đ", "đ", "Ď", "ď", "É", "é", "È", "è", "Ê", "ê", "Ë", "ë", "Ě", "ě", "Ē", "ē", "Ĕ", "ĕ", "Ė", "ė", "Ę", "ę", "Ĝ", "ĝ", "Ģ", "ģ", "Ğ", "ğ", "Ġ", "ġ", "Ĥ", "ĥ", "Ħ", "ħ", "Í", "í", "Ì", "ì", "Î", "î", "Ï", "ï", "Ĩ", "ĩ", "Ǐ", "ǐ", "Ī", "ī", "Ĭ", "ĭ", "İ", "ı", "Į", "į", "Ĵ", "ĵ", "Ķ", "ķ", "Ĺ", "ĺ", "Ļ", "ļ", "Ľ", "ľ", "Ł", "ł", "Ń", "ń", "Ñ", "ñ", "Ņ", "ņ", "Ň", "ň", "Ó", "ó", "Ò", "ò", "Ô", "ô", "Ö", "ö", "Õ", "õ", "Ǒ", "ǒ", "Ō", "ō", "Ŏ", "ŏ", "Ǫ", "ǫ", "Ő", "ő", "Ŕ", "ŕ", "Ŗ", "ŗ", "Ř", "ř", "Ś", "ś", "Ŝ", "ŝ", "Ş", "ş", "Š", "š", "Ș", "ș", "Ț", "ț", "Ť", "ť", "Ú", "ú", "Ù", "ù", "Û", "û", "Ü", "ü", "Ũ", "ũ", "Ů", "ů", "Ǔ", "ǔ", "Ū", "ū", "ǖ", "ǘ", "ǚ", "ǜ", "Ŭ", "ŭ", "Ų", "ų", "Ű", "ű", "Ŵ", "ŵ", "Ý", "ý", "Ŷ", "ŷ", "Ÿ", "ÿ", "Ȳ", "ȳ", "Ź", "ź", "Ž", "ž", "Ż", "ż", "Æ", "æ", "Ǣ", "ǣ", "Ø", "ø", "Œ", "œ", "ß", "Ð", "ð", "Þ", "þ", "Ə", "ə" + ], + "latinextended": [ + "Ḁ", "ḁ", "ẚ", "Ạ", "ạ", "Ả", "ả", "Ấ", "ấ", "Ầ", "ầ", "Ẩ", "ẩ", "Ẫ", "ẫ", "Ậ", "ậ", "Ắ", "ắ", "Ằ", "ằ", "Ẳ", "ẳ", "Ẵ", "ẵ", "Ặ", "ặ", "Ḃ", "ḃ", "Ḅ", "ḅ", "Ḇ", "ḇ", "Ḉ", "ḉ", "Ḋ", "ḋ", "Ḍ", "ḍ", "Ḏ", "ḏ", "Ḑ", "ḑ", "Ḓ", "ḓ", "Ḕ", "ḕ", "Ḗ", "ḗ", "Ḙ", "ḙ", "Ḛ", "ḛ", "Ḝ", "ḝ", "Ẹ", "ẹ", "Ẻ", "ẻ", "Ẽ", "ẽ", "Ế", "ế", "Ề", "ề", "Ể", "ể", "Ễ", "ễ", "Ệ", "ệ", "Ḟ", "ḟ", "Ḡ", "ḡ", "Ḣ", "ḣ", "Ḥ", "ḥ", "Ḧ", "ḧ", "Ḩ", "ḩ", "Ḫ", "ḫ", "ẖ", "Ḭ", "ḭ", "Ḯ", "ḯ", "Ỉ", "ỉ", "Ị", "ị", "Ḱ", "ḱ", "Ḳ", "ḳ", "Ḵ", "ḵ", "Ḷ", "ḷ", "Ḹ", "ḹ", "Ḻ", "ḻ", "Ḽ", "ḽ", "Ỻ", "ỻ", "Ḿ", "ḿ", "Ṁ", "ṁ", "Ṃ", "ṃ", "Ṅ", "ṅ", "Ṇ", "ṇ", "Ṉ", "ṉ", "Ṋ", "ṋ", "Ṍ", "ṍ", "Ṏ", "ṏ", "Ṑ", "ṑ", "Ṓ", "ṓ", "Ọ", "ọ", "Ỏ", "ỏ", "Ố", "ố", "Ồ", "ồ", "Ổ", "ổ", "Ỗ", "ỗ", "Ộ", "ộ", "Ớ", "ớ", "Ờ", "ờ", "Ở", "ở", "Ỡ", "ỡ", "Ợ", "ợ", "Ǿ", "ǿ", "Ơ", "ơ", "Ṕ", "ṕ", "Ṗ", "ṗ", "Ṙ", "ṙ", "Ṛ", "ṛ", "Ṝ", "ṝ", "Ṟ", "ṟ", "Ṡ", "ṡ", "ẛ", "Ṣ", "ṣ", "Ṥ", "ṥ", "Ṧ", "ṧ", "Ṩ", "ṩ", "ẜ", "ẝ", "Ṫ", "ṫ", "Ṭ", "ṭ", "Ṯ", "ṯ", "Ṱ", "ṱ", "ẗ", "Ṳ", "ṳ", "Ṵ", "ṵ", "Ṷ", "ṷ", "Ṹ", "ṹ", "Ṻ", "ṻ", "Ụ", "ụ", "Ủ", "ủ", "Ứ", "ứ", "Ừ", "ừ", "Ử", "ử", "Ữ", "ữ", "Ự", "ự", "Ư", "ư", "Ǖ", "Ǘ", "Ǚ", "Ǜ", "Ṽ", "ṽ", "Ṿ", "ṿ", "Ỽ", "ỽ", "Ẁ", "ẁ", "Ẃ", "ẃ", "Ẅ", "ẅ", "Ẇ", "ẇ", "Ẉ", "ẉ", "ẘ", "Ẋ", "ẋ", "Ẍ", "ẍ", "Ẏ", "ẏ", "ẙ", "Ỳ", "ỳ", "Ỵ", "ỵ", "Ỷ", "ỷ", "Ỹ", "ỹ", "Ỿ", "ỿ", "Ẑ", "ẑ", "Ẓ", "ẓ", "Ẕ", "ẕ", "Ǽ", "ǽ", "ẞ", "ẟ" + ], + "ipa": [ + "p", "t̪", "t", "ʈ", "c", "k", "q", "ʡ", "ʔ", "b", "d̪", "d", "ɖ", "ɟ", "ɡ", "ɢ", "ɓ", "ɗ", "ʄ", "ɠ", "ʛ", "t͡s", "t͡ʃ", "t͡ɕ", "d͡z", "d͡ʒ", "d͡ʑ", "ɸ", "f", "θ", "s", "ʃ", "ʅ", "ʆ", "ʂ", "ɕ", "ç", "ɧ", "x", "χ", "ħ", "ʜ", "h", "β", "v", "ʍ", "ð", "z", "ʒ", "ʓ", "ʐ", "ʑ", "ʝ", "ɣ", "ʁ", "ʕ", "ʖ", "ʢ", "ɦ", "ɬ", "ɮ", "m", "m̩", "ɱ", "ɱ̩", "ɱ̍", "n̪", "n̪̍", "n", "n̩", "ɳ", "ɳ̩", "ɲ", "ɲ̩", "ŋ", "ŋ̍", "ŋ̩", "ɴ", "ɴ̩", "ʙ", "ʙ̩", "r", "r̩", "ʀ", "ʀ̩", "ɾ", "ɽ", "ɿ", "ɺ", "l̪", "l̪̩", "l", "l̩", "ɫ", "ɫ̩", "ɭ", "ɭ̩", "ʎ", "ʎ̩", "ʟ", "ʟ̩", "w", "ɥ", "ʋ", "ɹ", "ɻ", "j", "ɰ", "ʘ", "ǂ", "ǀ", "!", "ǁ", "ʰ", "ʱ", "ʷ", "ʸ", "ʲ", "ʳ", "ⁿ", "ˡ", "ʴ", "ʵ", "ˢ", "ˣ", "ˠ", "ʶ", "ˤ", "ˁ", "ˀ", "ʼ", "i", "i̯", "ĩ", "y", "y̯", "ỹ", "ɪ", "ɪ̯", "ɪ̃", "ʏ", "ʏ̯", "ʏ̃", "ɨ", "ɨ̯", "ɨ̃", "ʉ", "ʉ̯", "ʉ̃", "ɯ", "ɯ̯", "ɯ̃", "u", "u̯", "ũ", "ʊ", "ʊ̯", "ʊ̃", "e", "e̯", "ẽ", "ø", "ø̯", "ø̃", "ɘ", "ɘ̯", "ɘ̃", "ɵ", "ɵ̯", "ɵ̃", "ɤ", "ɤ̯", "ɤ̃", "o", "o̯", "õ", "ɛ", "ɛ̯", "ɛ̃", "œ", "œ̯", "œ̃", "ɜ", "ɜ̯", "ɜ̃", "ə", "ə̯", "ə̃", "ɞ", "ɞ̯", "ɞ̃", "ʌ", "ʌ̯", "ʌ̃", "ɔ", "ɔ̯", "ɔ̃", "æ", "æ̯", "æ̃", "ɶ", "ɶ̯", "ɶ̃", "a", "a̯", "ã", "ɐ", "ɐ̯", "ɐ̃", "ɑ", "ɑ̯", "ɑ̃", "ɒ", "ɒ̯", "ɒ̃", "ˈ", "ˌ", "ː", "ˑ", "˘", ".", "‿", "|", "‖", "ɚ", "ɝ" + ], + "symbols": [ + "~", "|", "¡", "¿", "†", "‡", "↔", "↑", "↓", "•", "¶", "#", "½", "⅓", "⅔", "¼", "¾", "⅛", "⅜", "⅝", "⅞", "∞", "‘", "’", + { + "label": "“”", + "action": { + "type": "encapsulate", + "options": { + "pre": "“", + "post": "”" + } + } + }, + { + "label": "„“", + "action": { + "type": "encapsulate", + "options": { + "pre": "„", + "post": "“" + } + } + }, + { + "label": "„”", + "action": { + "type": "encapsulate", + "options": { + "pre": "„", + "post": "”" + } + } + }, + { + "label": "«»", + "action": { + "type": "encapsulate", + "options": { + "pre": "«", + "post": "»" + } + } + }, + "¤", "₳", "฿", "₵", "¢", "₡", "₢", "$", "₫", "₯", "€", "₠", "₣", "ƒ", "₴", "₭", "₤", "ℳ", "₥", "₦", "№", "₧", "₰", "£", "៛", "₨", "₪", "৳", "₮", "₩", "¥", "♠", "♣", "♥", "♦", "m²", "m³", + { + "label": "–", + "titleMsg": "special-characters-title-endash", + "action": { + "type": "replace", + "options": { + "peri": "–", + "selectPeri": false + } + } + }, + { + "label": "—", + "titleMsg": "special-characters-title-emdash", + "action": { + "type": "replace", + "options": { + "peri": "—", + "selectPeri": false + } + } + }, + "…", "‘", "’", "“", "”", "°", "′", "″", "≈", "≠", "≤", "≥", "±", + { + "label": "−", + "titleMsg": "special-characters-title-minus", + "action": { + "type": "replace", + "options": { + "peri": "−", + "selectPeri": false + } + } + }, + "×", "÷", "←", "→", "·", "§", "‽" + ], + "greek": [ + "Α", "Ά", "α", "ά", "Β", "β", "Γ", "γ", "Δ", "δ", "Ε", "Έ", "ε", "έ", "Ζ", "ζ", "Η", "Ή", "η", "ή", "Θ", "θ", "Ι", "Ί", "ι", "ί", "Κ", "κ", "Λ", "λ", "Μ", "μ", "Ν", "ν", "Ξ", "ξ", "Ο", "Ό", "ο", "ό", "Π", "π", "Ρ", "ρ", "Σ", "σ", "ς", "Τ", "τ", "Υ", "Ύ", "υ", "ύ", "Φ", "φ", "Χ", "χ", "Ψ", "ψ", "Ω", "Ώ", "ω", "ώ" + ], + "cyrillic": [ + "А", "а", "Ӑ", "ӑ", "Ӓ", "ӓ", "Ә", "ә", "Ӛ", "ӛ", "Б", "б", "В", "в", "Г", "г", "Ґ", "ґ", "Ӷ", "ӷ", "Ѓ", "ѓ", "Ӻ", "ӻ", "Ғ", "ғ", "Ҕ", "ҕ", "Д", "д", "Ԁ", "ԁ", "Ԃ", "ԃ", "Ђ", "ђ", "Е", "е", "Ѐ", "ѐ", "Є", "є", "Ё", "ё", "Ӗ", "ӗ", "Ҽ", "ҽ", "Ҿ", "ҿ", "Ж", "ж", "Җ", "җ", "Ӂ", "ӂ", "Ӝ", "ӝ", "З", "з", "Ҙ", "ҙ", "Ӟ", "ӟ", "Ԑ", "ԑ", "Ӡ", "ӡ", "Ѕ", "ѕ", "Ԅ", "ԅ", "Ԇ", "ԇ", "И", "и", "І", "і", "Ї", "ї", + [ + "◌Ӏ", + "Ӏ" + ], + [ + "◌ӏ", + "ӏ" + ], + "Й", "й", "Ӣ", "ӣ", "Ѝ", "ѝ", "Ҋ", "ҋ", "Ӥ", "ӥ", "Ј", "ј", "К", "к", "Ќ", "ќ", "Қ", "қ", "Ҝ", "ҝ", "Ҟ", "ҟ", "Ҡ", "ҡ", "Ӄ", "ӄ", "Ԛ", "ԛ", "Л", "л", "Љ", "љ", "Ԉ", "ԉ", "Ԓ", "ԓ", "Ӆ", "ӆ", "М", "м", "Ӎ", "ӎ", "Н", "н", "Њ", "њ", "Ң", "ң", "Ҥ", "ҥ", "Ӈ", "ӈ", "Ԋ", "ԋ", "Ӊ", "ӊ", "О", "о", "Ҩ", "ҩ", "Ӧ", "ӧ", "Ө", "ө", "Ӫ", "ӫ", "П", "п", "Ԥ", "ԥ", "Ҧ", "ҧ", "Р", "р", "Ҏ", "ҏ", "С", "с", "Ҫ", "ҫ", "Т", "т", "Ћ", "ћ", "Ԍ", "ԍ", "Ҭ", "ҭ", "Ԏ", "ԏ", "У", "у", "Ў", "ў", "Ӯ", "ӯ", "Ӱ", "ӱ", "Ӳ", "ӳ", "Ү", "ү", "Ұ", "ұ", "Ф", "ф", "Х", "х", "Ҳ", "ҳ", "Ӽ", "ӽ", "Ӿ", "ӿ", "Һ", "һ", "Ц", "ц", "Ч", "ч", "Ҵ", "ҵ", "Ҷ", "ҷ", "Ҹ", "ҹ", "Ӌ", "ӌ", "Ӵ", "ӵ", "Џ", "џ", "Ш", "ш", "Щ", "щ", "Ъ", "ъ", "Ы", "ы", "Ӹ", "ӹ", "Ь", "ь", "Ҍ", "ҍ", "Э", "э", "Ӭ", "ӭ", "Ю", "ю", "Я", "я", "Ԝ", "ԝ", "Ѡ", "ѡ", "Ѣ", "ѣ", "Ѥ", "ѥ", "Ѧ", "ѧ", "Ѩ", "ѩ", "Ѫ", "ѫ", "Ѭ", "ѭ", "Ѯ", "ѯ", "Ѱ", "ѱ", "Ѳ", "ѳ", "Ѵ", "ѵ", "Ѷ", "ѷ", "Ѹ", "ѹ", "Ѻ", "ѻ", "Ѽ", "ѽ", "Ѿ", "ѿ", "Ҁ", "ҁ" + ], + "arabic": [ + "ا", "ب", "ت", "ث", "ج", "ح", "خ", "د", "ذ", "ر", "ز", "س", "ش", "ص", "ض", "ط", "ظ", "ع", "غ", "ف", "ق", "ك", "ل", "م", "ن", "ه", "و", "ي", "ء", "آ", "أ", "إ", "ٱ", "ؤ", "ئ", "ى", "ة", "َ", "ُ", "ِ", "ً", "ٌ", "ٍ", "ّ", "ْ", "ٰ", "،", "؛", "؟", "ـ", "٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩", "٪", "٫", "٬", "٭", + [ + "zwnj", + "" + ], + [ + "zwj", + "" + ] + ], + "arabicextended": [ + "ٲ", "ٳ", "ٴ", "ٵ", "ݳ", "ݴ", "ٮ", "ٻ", "پ", "ڀ", "ݐ", "ݑ", "ݒ", "ݓ", "ݔ", "ݕ", "ݖ", "ٹ", "ٺ", "ټ", "ٽ", "ٿ", "ځ", "ڂ", "ڃ", "ڄ", "څ", "چ", "ڇ", "ڿ", "ݗ", "ݘ", "ݮ", "ݯ", "ݲ", "ݼ", "ڈ", "ډ", "ڊ", "ڋ", "ڌ", "ڍ", "ڎ", "ڏ", "ڐ", "ۮ", "ݙ", "ݚ", "ڑ", "ڒ", "ړ", "ڔ", "ڕ", "ږ", "ڗ", "ژ", "ڙ", "ۯ", "ݛ", "ݫ", "ݬ", "ݱ", "ښ", "ڛ", "ڜ", "ݽ", "ۺ", "ݜ", "ݭ", "ݰ", "ݾ", "ڝ", "ڞ", "ۻ", "ڟ", "ڠ", "ݝ", "ݞ", "ݟ", "ۼ", "ڡ", "ڢ", "ڣ", "ڤ", "ڥ", "ڦ", "ݠ", "ݡ", "ٯ", "ڧ", "ڨ", "ػ", "ؼ", "ک", "ڪ", "ګ", "ڬ", "ڭ", "ڮ", "گ", "ڰ", "ڱ", "ڲ", "ڳ", "ڴ", "ݢ", "ݣ", "ݤ", "ݿ", "ڵ", "ڶ", "ڷ", "ڸ", "ݪ", "ݥ", "ݦ", "ڹ", "ں", "ڻ", "ڼ", "ڽ", "ݧ", "ݨ", "ݩ", "ھ", "ۀ", "ہ", "ۂ", "ۃ", "ە", "ۿ", "ٶ", "ٷ", "ۄ", "ۅ", "ۆ", "ۇ", "ۈ", "ۉ", "ۊ", "ۋ", "ۏ", "ݸ", "ݹ", "ؠ", "ؽ", "ؾ", "ؿ", "ٸ", "ی", "ۍ", "ێ", "ې", "ۑ", "ے", "ۓ", "ݵ", "ݶ", "ݷ", "ݺ", "ݻ", "ٖ", "ٗ", "٘", "ٙ", "ٚ", "ٛ", "ٜ", "ٝ", "ٞ", "ٟ", "۔", "۽", "۾", "۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹" + ], + "hebrew": [ + "א", "ב", "ג", "ד", "ה", "ו", "ז", "ח", "ט", "י", "כ", "ך", "ל", "מ", "ם", "נ", "ן", "ס", "ע", "פ", "ף", "צ", "ץ", "ק", "ר", "ש", "ת", "װ", "ױ", "ײ", "׳", "״", "־", "–", + { + "label": "„”", + "action": { + "type": "encapsulate", + "options": { + "pre": "„", + "post": "”" + } + } + }, + { + "label": "‚’", + "action": { + "type": "encapsulate", + "options": { + "pre": "‚", + "post": "’" + } + } + }, + [ + "◌ְ", + "ְ" + ], + [ + "◌ֱ", + "ֱ" + ], + [ + "◌ֲ", + "ֲ" + ], + [ + "◌ֳ", + "ֳ" + ], + [ + "◌ִ", + "ִ" + ], + [ + "◌ֵ", + "ֵ" + ], + [ + "◌ֶ", + "ֶ" + ], + [ + "◌ַ", + "ַ" + ], + [ + "◌ָ", + "ָ" + ], + [ + "◌ֹ", + "ֹ" + ], + [ + "◌ֻ", + "ֻ" + ], + [ + "◌ּ", + "ּ" + ], + [ + "◌ׁ", + "ׁ" + ], + [ + "◌ׂ", + "ׂ" + ], + [ + "◌ׇ", + "ׇ" + ], + [ + "◌֑", + "֑" + ], + [ + "◌֒", + "֒" + ], + [ + "◌֓", + "֓" + ], + [ + "◌֔", + "֔" + ], + [ + "◌֕", + "֕" + ], + [ + "◌֖", + "֖" + ], + [ + "◌֗", + "֗" + ], + [ + "◌֘", + "֘" + ], + [ + "◌֙", + "֙" + ], + [ + "◌֚", + "֚" + ], + [ + "◌֛", + "֛" + ], + [ + "◌֜", + "֜" + ], + [ + "◌֝", + "֝" + ], + [ + "◌֞", + "֞" + ], + [ + "◌֟", + "֟" + ], + [ + "◌֠", + "֠" + ], + [ + "◌֡", + "֡" + ], + [ + "◌֢", + "֢" + ], + [ + "◌֣", + "֣" + ], + [ + "◌֤", + "֤" + ], + [ + "◌֥", + "֥" + ], + [ + "◌֦", + "֦" + ], + [ + "◌֧", + "֧" + ], + [ + "◌֨", + "֨" + ], + [ + "◌֩", + "֩" + ], + [ + "◌֪", + "֪" + ], + [ + "◌֫", + "֫" + ], + [ + "◌֬", + "֬" + ], + [ + "◌֭", + "֭" + ], + [ + "◌֮", + "֮" + ], + [ + "◌֯", + "֯" + ], + [ + "◌ֿ", + "ֿ" + ], + [ + "◌׀", + "׀" + ], + [ + "◌׃", + "׃" + ] + ], + "bangla": [ + "ঀ", "অ", "আ", "ই", "ঈ", "উ", "ঊ", "ঋ", "ঌ", "এ", "ঐ", "ও", "ঔ", "া", "ি", "ী", "ু", "ূ", "ৃ", "ে", "ৈ", "ো", "ৌ", "্য", "্র", "ক", "খ", "গ", "ঘ", "ঙ", "চ", "ছ", "জ", "ঝ", "ঞ", "ট", "ঠ", "ড", "ঢ", "ণ", "ত", "থ", "দ", "ধ", "ন", "প", "ফ", "ব", "ভ", "ম", "য", "র", "ল", "শ", "ষ", "স", "হ", "ড়", "ঢ়", "য়", "ৎ", "ং", "ঃ", "ঁ", "্", "৷", "॥", "১", "২", "৩", "৪", "৫", "৬", "৭", "৮", "৯", "০", "ঽ", "ৗ", "়", "ৰ", "ৱ", "৲", "৻", "৳", "৴", "৵", "৶", "৷", "৸", "৹", "৺", "ৠ", "ৡ", "ৄ", "ৢ", "ৣ", "‘", "’", "“", "”", + [ + "zws", + "" + ], + [ + "zwnj", + "" + ], + [ + "zwj", + "" + ] + ], + "tamil": [ + "௦", "௧", "௨", "௩", "௪", "௫", "௬", "௭", "௮", "௯", "௰", "௱", "௲", "௳", "௴", "௵", "௶", "௷", "௸", "௹", "௺", "ௐ" + ], + "telugu": [ + "ఁ", "ం", "ః", "అ", "ఆ", "ఇ", "ఈ", "ఉ", "ఊ", "ఋ", "ౠ", "ఌ", "ౡ", "ఎ", "ఏ", "ఐ", "ఒ", "ఓ", "ఔ", "క", "ఖ", "గ", "ఘ", "ఙ", "చ", "ఛ", "జ", "ఝ", "ఞ", "ట", "ఠ", "డ", "ఢ", "ణ", "త", "థ", "ద", "ధ", "న", "ప", "ఫ", "బ", "భ", "మ", "య", "ర", "ఱ", "ల", "ళ", "వ", "శ", "ష", "స", "హ", "ా", "ి", "ీ", "ు", "ూ", "ృ", "ౄ", "ె", "ే", "ై", "ొ", "ో", "ౌ", "్", "ౢ", "ౣ", "ౘ", "ౙ", "౦", "౧", "౨", "౩", "౪", "౫", "౬", "౭", "౮", "౯", "ఽ", "౸", "౹", "౺", "౻", "౼", "౽", "౾", "౿" + ], + "sinhala": [ + "අ", "ආ", "ඇ", "ඈ", "ඉ", "ඊ", "උ", "ඌ", "ඍ", "ඎ", "ඏ", "ඐ", "එ", "ඒ", "ඓ", "ඔ", "ඕ", "ඖ", "ක", "ඛ", "ග", "ඝ", "ඞ", "ඟ", "ච", "ඡ", "ජ", "ඣ", "ඤ", "ඥ", "ඦ", "ට", "ඨ", "ඩ", "ඪ", "ණ", "ඬ", "ත", "ථ", "ද", "ධ", "න", "ඳ", "ප", "ඵ", "බ", "භ", "ම", "ඹ", "ය", "ර", "ල", "ව", "ශ", "ෂ", "ස", "හ", "ළ", "ෆ", + [ + "◌ා", + "ා" + ], + [ + "◌ැ", + "ැ" + ], + [ + "◌ෑ", + "ෑ" + ], + [ + "◌ි", + "ි" + ], + [ + "◌ී", + "ී" + ], + [ + "◌ු", + "ු" + ], + [ + "◌ූ", + "ූ" + ], + [ + "◌ෘ", + "ෘ" + ], + [ + "◌ෲ", + "ෲ" + ], + [ + "◌ෟ", + "ෟ" + ], + [ + "◌ෳ", + "ෳ" + ], + [ + "◌ෙ", + "ෙ" + ], + [ + "◌ේ", + "ේ" + ], + [ + "◌ො", + "ො" + ], + [ + "◌ෝ", + "ෝ" + ], + [ + "◌ෞ", + "ෞ" + ], + [ + "◌්", + "්" + ] + ], + "devanagari": [ + "ऀ", "ँ", "ं", "ः", "ऄ", "अ", "आ", "इ", "ई", "उ", "ऊ", "ऋ", "ऌ", "ऍ", "ऎ", "ए", "ऐ", "ऑ", "ऒ", "ओ", "औ", "क", "ख", "ग", "घ", "ङ", "च", "छ", "ज", "झ", "ञ", "ट", "ठ", "ड", "ढ", "ण", "त", "थ", "द", "ध", "न", "ऩ", "प", "फ", "ब", "भ", "म", "य", "र", "ऱ", "ल", "ळ", "ऴ", "व", "श", "ष", "स", "ह", "ऺ", "ऻ", "़", "ऽ", "ा", "ि", "ी", "ु", "ू", "ृ", "ॄ", "ॅ", "ॆ", "े", "ै", "ॉ", "ॊ", "ो", "ौ", "्", "ॎ", "ॏ", "ॐ", "॑", "॒", "॓", "॔", "ॕ", "ॖ", "ॗ", "क़", "ख़", "ग़", "ज़", "ड़", "ढ़", "फ़", "य़", "ॠ", "ॡ", "ॢ", "ॣ", "।", "॥", "०", "१", "२", "३", "४", "५", "६", "७", "८", "९", "॰", "ॱ", "ॲ", "ॳ", "ॴ", "ॵ", "ॶ", "ॷ", "ॹ", "ॺ", "ॻ", "ॼ", "ॽ", "ॾ", "ॿ" + ], + "gujarati": [ + "ૐ", "ઁ", "ં", "ઃ", "અ", "આ", "ઇ", "ઈ", "ઉ", "ઊ", "એ", "ઐ", "ઓ", "ઔ", "અં", "ઋ", "ઍ", "ઑ", "ઌ", "ૠ", "ૡ", "ક", "ખ", "ગ", "ઘ", "ઙ", "ચ", "છ", "જ", "ઝ", "ઞ", "ટ", "ઠ", "ડ", "ઢ", "ણ", "ત", "થ", "દ", "ધ", "ન", "પ", "ફ", "બ", "ભ", "મ", "ય", "ર", "લ", "ળ", "વ", "શ", "ષ", "સ", "હ", "ક્ષ", "જ્ઞ", "ઽ", "ા", "િ", "ી", "ી", "ુ", "ૂ", "ૃ", "ૄ", "ૅ", "ે", "ૈ", "ૉ", "ો", "ૌ", "ૢ", "ૣ", "્", "૦", "૧", "૨", "૩", "૪", "૫", "૬", "૭", "૮", "૯", "૱" + ], + "thai": [ + "ก", "ข", "ฃ", "ค", "ฅ", "ฆ", "ง", "จ", "ฉ", "ช", "ซ", "ฌ", "ญ", "ฎ", "ฏ", "ฐ", "ฑ", "ฒ", "ณ", "ด", "ต", "ถ", "ท", "ธ", "น", "บ", "ป", "ผ", "ฝ", "พ", "ฟ", "ภ", "ม", "ย", "ร", "ฤ", "ล", "ฦ", "ว", "ศ", "ษ", "ส", "ห", "ฬ", "อ", "ฮ", "ะ", "ั", "า", "ๅ", "ำ", "ิ", "ี", "ึ", "ื", "ุ", "ู", "เ", "แ", "โ", "ใ", "ไ", "็", "่", "้", "๊", "๋", "์", "ํ", "ฺ", "๎", "๐", "๑", "๒", "๓", "๔", "๕", "๖", "๗", "๘", "๙", "฿", "ๆ", "ฯ", "๚", "๏", "๛" + ], + "lao": [ + "ກ", "ຂ", "ຄ", "ງ", "ຈ", "ສ", "ຊ", "ຍ", "ດ", "ຕ", "ຖ", "ທ", "ນ", "ບ", "ປ", "ຜ", "ຝ", "ພ", "ຟ", "ມ", "ຢ", "ລ", "ວ", "ຫ", "ອ", "ຮ", "ຣ", "ໜ", "ໝ", "ຼ", "ຽ", "ະ", "ັ", "າ", "ຳ", "ິ", "ີ", "ຶ", "ື", "ຸ", "ູ", "ົ", "ເ", "ແ", "ໂ", "ໃ", "ໄ", "່", "້", "໊", "໋", "໌", "ໍ", "໐", "໑", "໒", "໓", "໔", "໕", "໖", "໗", "໘", "໙", "₭", "ໆ", "ຯ" + ], + "khmer": [ + "ក", "ខ", "គ", "ឃ", "ង", "ច", "ឆ", "ជ", "ឈ", "ញ", "ដ", "ឋ", "ឌ", "ឍ", "ណ", "ត", "ថ", "ទ", "ធ", "ន", "ប", "ផ", "ព", "ភ", "ម", "យ", "រ", "ល", "វ", "ស", "ហ", "ឡ", "អ", "ឣ", "ឤ", "ឥ", "ឦ", "ឧ", "ឨ", "ឩ", "ឪ", "ឫ", "ឬ", "ឭ", "ឮ", "ឯ", "ឰ", "ឱ", "ឲ", "ឳ", "្", "឴", "឵", "ា", "ិ", "ី", "ឹ", "ឺ", "ុ", "ូ", "ួ", "ើ", "ឿ", "ៀ", "េ", "ែ", "ៃ", "ោ", "ៅ", "ំ", "ះ", "ៈ", "៉", "៊", "់", "៌", "៍", "៎", "៏", "័", "៑", "៓", "៝", "ៜ", "០", "១", "២", "៣", "៤", "៥", "៦", "៧", "៨", "៩", "៛", "។", "៕", "៖", "ៗ", "៘", "៙", "៚", "៰", "៱", "៲", "៳", "៴", "៵", "៶", "៷", "៸", "៹", "᧠", "᧡", "᧢", "᧣", "᧤", "᧥", "᧦", "᧧", "᧨", "᧩", "᧪", "᧫", "᧬", "᧭", "᧮", "᧯", "᧰", "᧱", "᧲", "᧳", "᧴", "᧵", "᧶", "᧷", "᧸", "᧹", "᧺", "᧻", "᧼", "᧽", "᧾", "᧿" + ] +} diff --git a/resources/src/mediawiki.legacy/ajax.js b/resources/src/mediawiki.legacy/ajax.js deleted file mode 100644 index 3660c205..00000000 --- a/resources/src/mediawiki.legacy/ajax.js +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Remote Scripting Library - * Copyright 2005 modernmethod, inc - * Under the open source BSD license - * http://www.modernmethod.com/sajax/ - */ - -/*jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ -/*global alert */ -( function ( mw ) { - - /** - * if sajax_debug_mode is true, this function outputs given the message into - * the element with id = sajax_debug; if no such element exists in the document, - * it is injected. - */ - function debug( text ) { - if ( !window.sajax_debug_mode ) { - return false; - } - - var b, m, - e = document.getElementById( 'sajax_debug' ); - - if ( !e ) { - e = document.createElement( 'p' ); - e.className = 'sajax_debug'; - e.id = 'sajax_debug'; - - b = document.getElementsByTagName( 'body' )[0]; - - if ( b.firstChild ) { - b.insertBefore( e, b.firstChild ); - } else { - b.appendChild( e ); - } - } - - m = document.createElement( 'div' ); - m.appendChild( document.createTextNode( text ) ); - - e.appendChild( m ); - - return true; - } - - /** - * Compatibility wrapper for creating a new XMLHttpRequest object. - */ - function createXhr() { - debug( 'sajax_init_object() called..' ); - var a; - try { - // Try the new style before ActiveX so we don't - // unnecessarily trigger warnings in IE 7 when - // set to prompt about ActiveX usage - a = new XMLHttpRequest(); - } catch ( xhrE ) { - try { - a = new window.ActiveXObject( 'Msxml2.XMLHTTP' ); - } catch ( msXmlE ) { - try { - a = new window.ActiveXObject( 'Microsoft.XMLHTTP' ); - } catch ( msXhrE ) { - a = null; - } - } - } - if ( !a ) { - debug( 'Could not create connection object.' ); - } - - return a; - } - - /** - * Perform an AJAX call to MediaWiki. Calls are handled by AjaxDispatcher.php - * func_name - the name of the function to call. Must be registered in $wgAjaxExportList - * args - an array of arguments to that function - * target - the target that will handle the result of the call. If this is a function, - * if will be called with the XMLHttpRequest as a parameter; if it's an input - * element, its value will be set to the resultText; if it's another type of - * element, its innerHTML will be set to the resultText. - * - * Example: - * sajax_do_call( 'doFoo', [1, 2, 3], document.getElementById( 'showFoo' ) ); - * - * This will call the doFoo function via MediaWiki's AjaxDispatcher, with - * (1, 2, 3) as the parameter list, and will show the result in the element - * with id = showFoo - */ - function doAjaxRequest( func_name, args, target ) { - var i, x, uri, post_data; - uri = mw.util.wikiScript() + '?action=ajax'; - if ( window.sajax_request_type === 'GET' ) { - if ( uri.indexOf( '?' ) === -1 ) { - uri = uri + '?rs=' + encodeURIComponent( func_name ); - } else { - uri = uri + '&rs=' + encodeURIComponent( func_name ); - } - for ( i = 0; i < args.length; i++ ) { - uri = uri + '&rsargs[]=' + encodeURIComponent( args[i] ); - } - // uri = uri + '&rsrnd=' + new Date().getTime(); - post_data = null; - } else { - post_data = 'rs=' + encodeURIComponent( func_name ); - for ( i = 0; i < args.length; i++ ) { - post_data = post_data + '&rsargs[]=' + encodeURIComponent( args[i] ); - } - } - x = createXhr(); - if ( !x ) { - alert( 'AJAX not supported' ); - return false; - } - - try { - x.open( window.sajax_request_type, uri, true ); - } catch ( e ) { - if ( location.hostname === 'localhost' ) { - alert( 'Your browser blocks XMLHttpRequest to "localhost", try using a real hostname for development/testing.' ); - } - throw e; - } - if ( window.sajax_request_type === 'POST' ) { - x.setRequestHeader( 'Method', 'POST ' + uri + ' HTTP/1.1' ); - x.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' ); - } - x.setRequestHeader( 'Pragma', 'cache=yes' ); - x.setRequestHeader( 'Cache-Control', 'no-transform' ); - x.onreadystatechange = function () { - if ( x.readyState !== 4 ) { - return; - } - - debug( 'received (' + x.status + ' ' + x.statusText + ') ' + x.responseText ); - - // if ( x.status != 200 ) - // alert( 'Error: ' + x.status + ' ' + x.statusText + ': ' + x.responseText ); - // else - - if ( typeof target === 'function' ) { - target( x ); - } else if ( typeof target === 'object' ) { - if ( target.tagName === 'INPUT' ) { - if ( x.status === 200 ) { - target.value = x.responseText; - } - // else alert( 'Error: ' + x.status + ' ' + x.statusText + ' (' + x.responseText + ')' ); - } else { - if ( x.status === 200 ) { - target.innerHTML = x.responseText; - } else { - target.innerHTML = '<div class="error">Error: ' + x.status + - ' ' + x.statusText + ' (' + x.responseText + ')</div>'; - } - } - } else { - alert( 'Bad target for sajax_do_call: not a function or object: ' + target ); - } - }; - - debug( func_name + ' uri = ' + uri + ' / post = ' + post_data ); - x.send( post_data ); - debug( func_name + ' waiting..' ); - - return true; - } - - /** - * @return {boolean} Whether the browser supports AJAX - */ - function wfSupportsAjax() { - var request = createXhr(), - supportsAjax = request ? true : false; - - request = undefined; - return supportsAjax; - } - - // Expose + Mark as deprecated - var deprecationNotice = 'Sajax is deprecated, use jQuery.ajax or mediawiki.api instead.'; - - // Variables - mw.log.deprecate( window, 'sajax_debug_mode', false, deprecationNotice ); - mw.log.deprecate( window, 'sajax_request_type', 'GET', deprecationNotice ); - // Methods - mw.log.deprecate( window, 'sajax_debug', debug, deprecationNotice ); - mw.log.deprecate( window, 'sajax_init_object', createXhr, deprecationNotice ); - mw.log.deprecate( window, 'sajax_do_call', doAjaxRequest, deprecationNotice ); - mw.log.deprecate( window, 'wfSupportsAjax', wfSupportsAjax, deprecationNotice ); - -}( mediaWiki ) ); diff --git a/resources/src/mediawiki.legacy/commonPrint.css b/resources/src/mediawiki.legacy/commonPrint.css index 9a8d3918..e1b31982 100644 --- a/resources/src/mediawiki.legacy/commonPrint.css +++ b/resources/src/mediawiki.legacy/commonPrint.css @@ -16,7 +16,6 @@ div#jump-to-nav, .mw-jump, div.top, div#column-one, -#colophon, .mw-editsection, .mw-editsection-like, .toctoggle, @@ -29,9 +28,6 @@ li#mobileview, li#privacy, #footer-places, .mw-hidden-catlinks, -tr.mw-metadata-show-hide-extended, -span.mw-filepage-other-resolutions, -#filetoc, .usermessage, .patrollink, .ns-0 .mw-redirectedfrom, @@ -123,7 +119,6 @@ pre, .mw-code { border: 1px solid #aaaaaa; background-color: #f9f9f9; padding: 5px; - display: -moz-inline-block; display: inline-block; display: table; /* IE7 and earlier */ @@ -286,45 +281,6 @@ img.thumbborder { } /** - * Galleries (see shared.css for more info) - */ -li.gallerybox { - vertical-align: top; - display: inline-block; -} - -ul.gallery, li.gallerybox { - zoom: 1; - *display: inline; -} - -ul.gallery { - margin: 2px; - padding: 2px; - display: block; -} - -li.gallerycaption { - font-weight: bold; - text-align: center; - display: block; - word-wrap: break-word; -} - -li.gallerybox div.thumb { - text-align: center; - border: 1px solid #ccc; - margin: 2px; -} - -div.gallerytext { - overflow: hidden; - font-size: 94%; - padding: 2px 4px; - word-wrap: break-word; -} - -/** * Table rendering * As on shared.css but with white background. */ diff --git a/resources/src/mediawiki.legacy/oldshared.css b/resources/src/mediawiki.legacy/oldshared.css index c2bd5a73..66161ed3 100644 --- a/resources/src/mediawiki.legacy/oldshared.css +++ b/resources/src/mediawiki.legacy/oldshared.css @@ -168,7 +168,6 @@ img { padding: 5px; font-size: 95%; text-align: center; - display: -moz-inline-block; display: inline-block; display: table; @@ -257,14 +256,6 @@ div.htmlform-tip { color: #666; } -fieldset.prefsection { - margin-top: 1em; -} - -fieldset.operaprefsection { - margin-left: 15em; -} - /* emulate center */ .center { width: 100%; @@ -321,10 +312,6 @@ span.comment { font-style: italic; } -span.changedby { - font-size: 95%; -} - .previewnote { text-align: center; color: #cc0000; diff --git a/resources/src/mediawiki.legacy/protect.js b/resources/src/mediawiki.legacy/protect.js index 3f4b263e..6226c90b 100644 --- a/resources/src/mediawiki.legacy/protect.js +++ b/resources/src/mediawiki.legacy/protect.js @@ -146,7 +146,7 @@ var ProtectionForm = window.ProtectionForm = { */ matchAttribute: function ( objects, attrName ) { return $.map( objects, function ( object ) { - return object[attrName]; + return object[ attrName ]; } ).filter( function ( item, index, a ) { return index === a.indexOf( item ); } ).length === 1; @@ -177,6 +177,7 @@ var ProtectionForm = window.ProtectionForm = { /** * Find the highest protection level in any selector + * * @return {number} */ getMaxLevel: function () { diff --git a/resources/src/mediawiki.legacy/shared.css b/resources/src/mediawiki.legacy/shared.css index 3657b127..961c02b2 100644 --- a/resources/src/mediawiki.legacy/shared.css +++ b/resources/src/mediawiki.legacy/shared.css @@ -2,6 +2,11 @@ * CSS in this file is used by *all* skins (that have any CSS at all). Be * careful what you put in here, since what looks good in one skin may not in * another, but don't ignore the poor pre-Monobook users either. + * + * NOTE: The images which are referenced in this file are no longer in use in + * essential interface components. They should NOT be embedded, because that + * optimizes for the uncommon case at the cost of bloating the size of render- + * blocking CSS common to all pages. */ /* GENERAL CLASSES FOR DIRECTIONALITY SUPPORT */ @@ -78,6 +83,14 @@ abbr[title], cursor: help; } +@supports (text-decoration: underline dotted) { + abbr[title], + .explain[title] { + border-bottom: none; + text-decoration: underline dotted; + } +} + /* Colored watchlist and recent changes numbers */ .mw-plusminus-pos { color: #006400; /* dark green */ @@ -113,15 +126,11 @@ abbr[title], font-style: italic; } -/* Comment and username portions of RC entries */ +/* Comment portions of RC entries */ span.comment { font-style: italic; } -span.changedby { - font-size: 95%; -} - /* Math */ .texvc { direction: ltr; @@ -152,49 +161,6 @@ span.texhtml { } /** - * File description page - */ - -div.mw-filepage-resolutioninfo { - font-size: smaller; -} - -/** - * File histories - */ -h2#filehistory { - clear: both; -} - -table.filehistory th, -table.filehistory td { - vertical-align: top; -} - -table.filehistory th { - text-align: left; -} - -table.filehistory td.mw-imagepage-filesize, -table.filehistory th.mw-imagepage-filesize { - white-space: nowrap; -} - -table.filehistory td.filehistory-selected { - font-weight: bold; -} - -/** - * Add a checkered background image on hover for file - * description pages. (bug 26470) - */ -.filehistory a img, -#file img:hover { - /* @embed */ - background: white url(images/checker.png) repeat; -} - -/** * rev_deleted stuff */ li span.deleted, @@ -237,73 +203,13 @@ td.mw-submit { } td.mw-label { - vertical-align: top; -} - -.prefsection td.mw-label { - width: 20%; -} - -.prefsection table { - width: 100%; -} - -.prefsection table.mw-htmlform-matrix { - width: auto; -} - -.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); + vertical-align: middle; } td.mw-submit { white-space: nowrap; } -table.mw-htmlform-nolabel td.mw-label { - width: 1px; -} - -tr.mw-htmlform-vertical-label td.mw-label { - text-align: left !important; -} - -.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; -} - -.mw-htmlform-matrix td { - padding-left: 0.5em; - padding-right: 0.5em; -} - input#wpSummary { width: 80%; margin-bottom: 1em; @@ -437,11 +343,6 @@ p.mw-upload-editlicenses { font-weight: bold; } -#shared-image-dup, -#shared-image-conflict { - font-style: italic; -} - /** * Recreating deleted page warning * Reupload file warning @@ -481,22 +382,6 @@ a.new { color: #BA0000; } -/* feed links */ -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; -} - /* Plainlinks - this can be used to switch * off special external link styling */ .plainlinks a.external { @@ -566,7 +451,6 @@ table.wikitable > caption { border: 1px solid; padding: .5em 1em; margin-bottom: 1em; - display: -moz-inline-block; display: inline-block; zoom: 1; *display: inline; @@ -663,24 +547,6 @@ table.wikitable > caption { background-color: #eeeeff; } -/* filetoc */ -ul#filetoc { - text-align: center; - border: 1px solid #aaaaaa; - background-color: #f9f9f9; - padding: 5px; - font-size: 95%; - margin-bottom: 0.5em; - margin-left: 0; - margin-right: 0; -} - -#filetoc li { - display: inline; - list-style-type: none; - padding-right: 2em; -} - /* Classes for Exif data display */ table.mw_metadata { font-size: 0.8em; @@ -773,110 +639,7 @@ table.mw_metadata ul.metadata-langlist { margin-left: 0; } -/* Galleries */ -/* These display attributes look nonsensical, but are needed to support IE and FF2 */ -/* Don't forget to update commonPrint.css */ -li.gallerybox { - vertical-align: top; - display: -moz-inline-box; - display: inline-block; -} - -ul.gallery, -li.gallerybox { - zoom: 1; - *display: inline; -} - -ul.gallery { - margin: 2px; - padding: 2px; - display: block; -} - -li.gallerycaption { - font-weight: bold; - text-align: center; - display: block; - word-wrap: break-word; -} - -li.gallerybox div.thumb { - text-align: center; - border: 1px solid #ccc; - background-color: #f9f9f9; - margin: 2px; -} - -li.gallerybox div.thumb img { - display: block; - margin: 0 auto; -} - -div.gallerytext { - overflow: hidden; - font-size: 94%; - padding: 2px 4px; - word-wrap: break-word; -} - -/* new gallery stuff */ -ul.mw-gallery-nolines li.gallerybox div.thumb { - background-color: transparent; - border: none; -} - -ul.mw-gallery-nolines li.gallerybox div.gallerytext { - text-align: center; -} - -/* height constrained gallery */ - -ul.mw-gallery-packed li.gallerybox div.thumb, -ul.mw-gallery-packed-overlay li.gallerybox div.thumb, -ul.mw-gallery-packed-hover li.gallerybox div.thumb { - background-color: transparent; - border: none; -} - -ul.mw-gallery-packed li.gallerybox div.thumb img, -ul.mw-gallery-packed-overlay li.gallerybox div.thumb img, -ul.mw-gallery-packed-hover li.gallerybox div.thumb img { - margin: 0 auto; -} - -ul.mw-gallery-packed-hover li.gallerybox, -ul.mw-gallery-packed-overlay li.gallerybox { - position: relative; -} - -ul.mw-gallery-packed-hover div.gallerytextwrapper { - overflow: hidden; - height: 0; -} - -ul.mw-gallery-packed-hover li.gallerybox:hover div.gallerytextwrapper, -ul.mw-gallery-packed-overlay li.gallerybox div.gallerytextwrapper, -ul.mw-gallery-packed-hover li.gallerybox.mw-gallery-focused div.gallerytextwrapper { - position: absolute; - background: white; - background: rgba(255, 255, 255, 0.8); - padding: 5px 10px; - bottom: 0; - left: 0; /* Needed for IE */ - height: auto; - font-weight: bold; - margin: 2px; /* correspond to style on div.thumb */ -} - -ul.mw-gallery-packed-hover, -ul.mw-gallery-packed-overlay, -ul.mw-gallery-packed { - text-align: center; -} - .mw-ajax-loader { - /* @embed */ background-image: url(images/ajax-loader.gif); background-position: center center; background-repeat: no-repeat; @@ -888,7 +651,6 @@ ul.mw-gallery-packed { .mw-small-spinner { padding: 10px !important; margin-right: 0.6em; - /* @embed */ background-image: url(images/spinner.gif); background-position: center center; background-repeat: no-repeat; @@ -945,6 +707,7 @@ h2:lang(te), h3:lang(te), h4:lang(te), h5:lang(te), h6:lang(te) { } /* Localised ordered list numbering for some languages */ +ol:lang(azb) li, ol:lang(bcc) li, ol:lang(bgn) li, ol:lang(bqi) li, @@ -952,13 +715,14 @@ ol:lang(fa) li, ol:lang(glk) li, ol:lang(kk-arab) li, ol:lang(lrc) li, -ol:lang(mzn) li, -ol:lang(sdh) li { +ol:lang(luz) li, +ol:lang(mzn) li { list-style-type: -moz-persian; list-style-type: persian; } -ol:lang(ckb) li { +ol:lang(ckb) li, +ol:lang(sdh) li { list-style-type: -moz-arabic-indic; list-style-type: arabic-indic; } @@ -1026,7 +790,6 @@ ol:lang(or) li { margin-left: 2px; margin-bottom: -8px; padding: 0 0 0 15px; - /* @embed */ background-image: url(images/help-question.gif); background-position: left center; background-repeat: no-repeat; @@ -1037,7 +800,6 @@ ol:lang(or) li { } .mw-help-field-hint:hover { - /* @embed */ background-image: url(images/help-question-hover.gif); } diff --git a/resources/src/mediawiki.legacy/wikibits.js b/resources/src/mediawiki.legacy/wikibits.js index 32cd79a5..7d1f6d73 100644 --- a/resources/src/mediawiki.legacy/wikibits.js +++ b/resources/src/mediawiki.legacy/wikibits.js @@ -85,7 +85,7 @@ // Execute the queued functions for ( i = 0; i < functs.length; i++ ) { - functs[i](); + functs[ i ](); } } ); @@ -164,33 +164,26 @@ * See https://www.mediawiki.org/wiki/ResourceLoader/Legacy_JavaScript#wikibits.js */ - function importScript( page ) { - var uri = mw.config.get( 'wgScript' ) + '?title=' + - mw.util.wikiUrlencode( page ) + - '&action=raw&ctype=text/javascript'; - return importScriptURI( uri ); - } - /** * @deprecated since 1.17 Use mw.loader instead. Warnings added in 1.25. */ function importScriptURI( url ) { - if ( loadedScripts[url] ) { + if ( loadedScripts[ url ] ) { return null; } - loadedScripts[url] = true; + loadedScripts[ url ] = true; var s = document.createElement( 'script' ); s.setAttribute( 'src', url ); s.setAttribute( 'type', 'text/javascript' ); - document.getElementsByTagName( 'head' )[0].appendChild( s ); + document.getElementsByTagName( 'head' )[ 0 ].appendChild( s ); return s; } - function importStylesheet( page ) { + function importScript( page ) { var uri = mw.config.get( 'wgScript' ) + '?title=' + mw.util.wikiUrlencode( page ) + - '&action=raw&ctype=text/css'; - return importStylesheetURI( uri ); + '&action=raw&ctype=text/javascript'; + return importScriptURI( uri ); } /** @@ -203,10 +196,17 @@ if ( media ) { l.media = media; } - document.getElementsByTagName( 'head' )[0].appendChild( l ); + document.getElementsByTagName( 'head' )[ 0 ].appendChild( l ); return l; } + function importStylesheet( page ) { + var uri = mw.config.get( 'wgScript' ) + '?title=' + + mw.util.wikiUrlencode( page ) + + '&action=raw&ctype=text/css'; + return importStylesheetURI( uri ); + } + msg = 'Use mw.loader instead.'; mw.log.deprecate( win, 'loadedScripts', loadedScripts, msg ); mw.log.deprecate( win, 'importScriptURI', importScriptURI, msg ); @@ -215,4 +215,12 @@ win.importScript = importScript; win.importStylesheet = importStylesheet; + // Replace document.write/writeln with basic html parsing that appends + // to the <body> to avoid blanking pages. Added JavaScript will not run. + $.each( [ 'write', 'writeln' ], function ( idx, method ) { + mw.log.deprecate( document, method, function () { + $( 'body' ).append( $.parseHTML( Array.prototype.join.call( arguments, '' ) ) ); + }, 'Use jQuery or mw.loader.load instead.' ); + } ); + }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.less/mediawiki.mixins.less b/resources/src/mediawiki.less/mediawiki.mixins.less index 3366f1e1..79549c33 100644 --- a/resources/src/mediawiki.less/mediawiki.mixins.less +++ b/resources/src/mediawiki.less/mediawiki.mixins.less @@ -52,7 +52,7 @@ .list-style-image-svg(@svg, @fallback) { list-style-image: e('/* @embed */') url(@svg); /* Fallback to PNG bullet for IE 8 and below using CSS hack */ - list-style-image: e('/* @embed */') url(@fallback)\9; + list-style-image: e('/* @embed */') url(@fallback) e('\9'); } .transition(@value) { @@ -85,7 +85,7 @@ column-width: @value;// IE 10+ } -.column-break-inside-avoid { +.column-break-inside-avoid() { -webkit-column-break-inside: avoid; // Chrome Any, Safari 3+, Opera 11.1+ page-break-inside: avoid; // Firefox 1.5+ break-inside: avoid-column; // IE 10+ diff --git a/resources/src/mediawiki.less/mediawiki.ui/mixins.less b/resources/src/mediawiki.less/mediawiki.ui/mixins.less index 2d684572..1b31956d 100644 --- a/resources/src/mediawiki.less/mediawiki.ui/mixins.less +++ b/resources/src/mediawiki.less/mediawiki.ui/mixins.less @@ -33,18 +33,17 @@ // Button styling // ---------------------------------------------------------------------------- -.button-colors(@bgColor) { +.button-colors(@bgColor, @highlightColor, @activeColor) { background: @bgColor; &:hover { // The inner bottom bevel should match the active background color. - box-shadow: 0 1px rgba(0, 0, 0, 10%), inset 0 -3px rgba(0, 0, 0, 20%); - border-bottom-color: mix(#000, @bgColor, 20%); + background-color: @highlightColor; } &:focus { - border-color: rgba(0,0,0,0.2); - box-shadow: inset 0 0 0 1px rgba(0,0,0,0.2); + border-color: @colorWhite; + box-shadow: 0 0 0 1px @highlightColor; outline: none; // remove outline in Firefox @@ -55,15 +54,12 @@ &:active, &.mw-ui-checked { - // lessphp doesn't implement shade (https://github.com/leafo/lessphp/issues/528); - // it passes it through, then ResourceLoader drops it. - // background: shade(@bgColor, 20%); - background: mix(#000, @bgColor, 20%); + background: @activeColor; box-shadow: none; } } -.button-colors(@bgColor) when (lightness(@bgColor) >= 70%) { +.button-colors(@bgColor, @highlightColor, @activeColor) when (lightness(@bgColor) >= 70%) { color: @colorButtonText; border: 1px solid @colorGray12; @@ -74,6 +70,10 @@ color: @colorButtonText; } + &:focus { + background-color: @highlightColor; + } + &:disabled { color: @colorDisabledText; @@ -86,7 +86,7 @@ } } -.button-colors(@bgColor) when (lightness(@bgColor) < 70%) { +.button-colors(@bgColor, @highlightColor, @activeColor) when (lightness(@bgColor) < 70%) { color: #fff; // border of the same color as background so that light background and // dark background buttons are the same height and width @@ -106,21 +106,20 @@ } } -.button-colors-quiet(@textColor) { +.button-colors-quiet(@textColor, @highlightColor, @activeColor) { // Quiet buttons all start gray, and reveal // constructive/progressive/destructive color on hover and active. color: @colorButtonText; &:hover, &:focus { + background: transparent; color: @textColor; } &:active, &.mw-ui-checked { - // lessphp doesn't implement shade, see above - // color: shade(@textColor, 20%); - color: mix(#000, @textColor, 20%); + color: @activeColor; } &:disabled { diff --git a/resources/src/mediawiki.less/mediawiki.ui/variables.less b/resources/src/mediawiki.less/mediawiki.ui/variables.less index e91302be..4b6bb48b 100644 --- a/resources/src/mediawiki.less/mediawiki.ui/variables.less +++ b/resources/src/mediawiki.less/mediawiki.ui/variables.less @@ -21,12 +21,18 @@ // Semantic background colors // Blue; for contextual use of a continuing action @colorProgressive: #347bff; +@colorProgressiveHighlight: #2962CC; +@colorProgressiveActive: #2962CC; // Green; for contextual use of a positive finalizing action @colorConstructive: #00af89; +@colorConstructiveHighlight: #008C6D; +@colorConstructiveActive: #008C6D; // Orange; for contextual use of returning to a past action @colorRegressive: #FF5D00; // Red; for contextual use of a negative action of high severity @colorDestructive: #d11d13; +@colorDestructiveHighlight: #A7170F; +@colorDestructiveActive: #A7170F; // Orange; for contextual use of a potentially negative action of medium severity @colorMediumSevere: #FF5D00; // Yellow; for contextual use of a potentially negative action of low severity @@ -41,6 +47,8 @@ @colorText: @colorGray2; @colorTextLight: @colorGray6; @colorButtonText: @colorGray5; +@colorButtonTextHighlight: @colorGray7; +@colorButtonTextActive: @colorGray7; @colorDisabledText: @colorGray12; @colorErrorText: #CC0000; @@ -60,3 +68,8 @@ // Global border radius to be used to buttons and inputs @borderRadius: 2px; + + +// Icon related variables +@iconSize: 1.5em; +@iconGutterWidth: 1em; diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js index 9d280800..6f9aa025 100644 --- a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js +++ b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js @@ -24,11 +24,11 @@ * @param {Function} messagePosterConstructor Constructor for MessagePoster */ MwMessagePosterFactory.prototype.register = function ( contentModel, messagePosterConstructor ) { - if ( this.contentModelToClass[contentModel] !== undefined ) { + if ( this.contentModelToClass[ contentModel ] !== undefined ) { throw new Error( 'The content model \'' + contentModel + '\' is already registered.' ); } - this.contentModelToClass[contentModel] = messagePosterConstructor; + this.contentModelToClass[ contentModel ] = messagePosterConstructor; }; /** @@ -38,7 +38,7 @@ * @param {string} contentModel Content model to unregister */ MwMessagePosterFactory.prototype.unregister = function ( contentModel ) { - delete this.contentModelToClass[contentModel]; + delete this.contentModelToClass[ contentModel ]; }; /** @@ -67,9 +67,9 @@ indexpageids: 1, titles: title.getPrefixedDb() } ).then( function ( result ) { - if ( result.query.pageids.length > 0 ) { - pageId = result.query.pageids[0]; - page = result.query.pages[pageId]; + if ( result.query.pageids && result.query.pageids.length > 0 ) { + pageId = result.query.pageids[ 0 ]; + page = result.query.pages[ pageId ]; contentModel = page.contentmodel; moduleName = 'mediawiki.messagePoster.' + contentModel; @@ -100,7 +100,7 @@ * */ MwMessagePosterFactory.prototype.createForContentModel = function ( contentModel, title ) { - return new this.contentModelToClass[contentModel]( title ); + return new this.contentModelToClass[ contentModel ]( title ); }; mw.messagePoster = { diff --git a/resources/src/mediawiki.page/mediawiki.page.gallery.css b/resources/src/mediawiki.page/mediawiki.page.gallery.css new file mode 100644 index 00000000..20deb214 --- /dev/null +++ b/resources/src/mediawiki.page/mediawiki.page.gallery.css @@ -0,0 +1,101 @@ +/* Galleries */ +/* These display attributes look nonsensical, but are needed to support IE and FF2 */ +/* Don't forget to update mediawiki.page.gallery.print.css */ +li.gallerybox { + vertical-align: top; + display: -moz-inline-box; + display: inline-block; +} + +ul.gallery, +li.gallerybox { + zoom: 1; + *display: inline; +} + +ul.gallery { + margin: 2px; + padding: 2px; + display: block; +} + +li.gallerycaption { + font-weight: bold; + text-align: center; + display: block; + word-wrap: break-word; +} + +li.gallerybox div.thumb { + text-align: center; + border: 1px solid #ccc; + background-color: #f9f9f9; + margin: 2px; +} + +li.gallerybox div.thumb img { + display: block; + margin: 0 auto; +} + +div.gallerytext { + overflow: hidden; + font-size: 94%; + padding: 2px 4px; + word-wrap: break-word; +} + +/* new gallery stuff */ +ul.mw-gallery-nolines li.gallerybox div.thumb { + background-color: transparent; + border: none; +} + +ul.mw-gallery-nolines li.gallerybox div.gallerytext { + text-align: center; +} + +/* height constrained gallery */ + +ul.mw-gallery-packed li.gallerybox div.thumb, +ul.mw-gallery-packed-overlay li.gallerybox div.thumb, +ul.mw-gallery-packed-hover li.gallerybox div.thumb { + background-color: transparent; + border: none; +} + +ul.mw-gallery-packed li.gallerybox div.thumb img, +ul.mw-gallery-packed-overlay li.gallerybox div.thumb img, +ul.mw-gallery-packed-hover li.gallerybox div.thumb img { + margin: 0 auto; +} + +ul.mw-gallery-packed-hover li.gallerybox, +ul.mw-gallery-packed-overlay li.gallerybox { + position: relative; +} + +ul.mw-gallery-packed-hover div.gallerytextwrapper { + overflow: hidden; + height: 0; +} + +ul.mw-gallery-packed-hover li.gallerybox:hover div.gallerytextwrapper, +ul.mw-gallery-packed-overlay li.gallerybox div.gallerytextwrapper, +ul.mw-gallery-packed-hover li.gallerybox.mw-gallery-focused div.gallerytextwrapper { + position: absolute; + background: white; + background: rgba(255, 255, 255, 0.8); + padding: 5px 10px; + bottom: 0; + left: 0; /* Needed for IE */ + height: auto; + font-weight: bold; + margin: 2px; /* correspond to style on div.thumb */ +} + +ul.mw-gallery-packed-hover, +ul.mw-gallery-packed-overlay, +ul.mw-gallery-packed { + text-align: center; +} diff --git a/resources/src/mediawiki.page/mediawiki.page.gallery.js b/resources/src/mediawiki.page/mediawiki.page.gallery.js index 95140704..dfccf215 100644 --- a/resources/src/mediawiki.page/mediawiki.page.gallery.js +++ b/resources/src/mediawiki.page/mediawiki.page.gallery.js @@ -12,6 +12,7 @@ /** * Perform the layout justification. + * * @ignore * @context {HTMLElement} A `ul.mw-gallery-*` element */ @@ -30,14 +31,14 @@ $this = $( this ); if ( top !== lastTop ) { - rows[rows.length] = []; + rows[ rows.length ] = []; lastTop = top; } $img = $this.find( 'div.thumb a.image img' ); - if ( $img.length && $img[0].height ) { - imgHeight = $img[0].height; - imgWidth = $img[0].width; + if ( $img.length && $img[ 0 ].height ) { + imgHeight = $img[ 0 ].height; + imgWidth = $img[ 0 ].width; } else { // If we don't have a real image, get the containing divs width/height. // Note that if we do have a real image, using this method will generally @@ -54,7 +55,7 @@ } captionWidth = $this.children().children( 'div.gallerytextwrapper' ).width(); - rows[rows.length - 1][rows[rows.length - 1].length] = { + rows[ rows.length - 1 ][ rows[ rows.length - 1 ].length ] = { $elm: $this, width: $this.outerWidth(), imgWidth: imgWidth, @@ -96,25 +97,25 @@ maxWidth = $gallery.width(); combinedAspect = 0; combinedPadding = 0; - curRow = rows[i]; + curRow = rows[ i ]; curRowHeight = 0; for ( j = 0; j < curRow.length; j++ ) { if ( curRowHeight === 0 ) { - if ( isFinite( curRow[j].height ) ) { + if ( isFinite( curRow[ j ].height ) ) { // Get the height of this row, by taking the first // non-out of bounds height - curRowHeight = curRow[j].height; + curRowHeight = curRow[ j ].height; } } - if ( curRow[j].aspect === 0 || !isFinite( curRow[j].aspect ) ) { + if ( curRow[ j ].aspect === 0 || !isFinite( curRow[ j ].aspect ) ) { // One of the dimensions are 0. Probably should // not try to resize. - combinedPadding += curRow[j].width; + combinedPadding += curRow[ j ].width; } else { - combinedAspect += curRow[j].aspect; - combinedPadding += curRow[j].width - curRow[j].imgWidth; + combinedAspect += curRow[ j ].aspect; + combinedPadding += curRow[ j ].width - curRow[ j ].imgWidth; } } @@ -162,13 +163,13 @@ } for ( j = 0; j < curRow.length; j++ ) { - newWidth = preferredHeight * curRow[j].aspect; - padding = curRow[j].width - curRow[j].imgWidth; - $outerDiv = curRow[j].$elm; + newWidth = preferredHeight * curRow[ j ].aspect; + padding = curRow[ j ].width - curRow[ j ].imgWidth; + $outerDiv = curRow[ j ].$elm; $innerDiv = $outerDiv.children( 'div' ).first(); $imageDiv = $innerDiv.children( 'div.thumb' ); $imageElm = $imageDiv.find( 'img' ).first(); - imageElm = $imageElm.length ? $imageElm[0] : null; + imageElm = $imageElm.length ? $imageElm[ 0 ] : null; $caption = $outerDiv.find( 'div.gallerytextwrapper' ); // Since we are going to re-adjust the height, the vertical @@ -187,7 +188,7 @@ $outerDiv.width( newWidth + padding ); $innerDiv.width( newWidth + padding ); $imageDiv.width( newWidth ); - $caption.width( curRow[j].captionWidth + ( newWidth - curRow[j].imgWidth ) ); + $caption.width( curRow[ j ].captionWidth + ( newWidth - curRow[ j ].imgWidth ) ); } if ( imageElm ) { @@ -220,7 +221,7 @@ $( this ).find( 'div.gallerytextwrapper' ).width( captionWidth ); $imageElm = $( this ).find( 'img' ).first(); - imageElm = $imageElm.length ? $imageElm[0] : null; + imageElm = $imageElm.length ? $imageElm[ 0 ] : null; if ( imageElm ) { imageElm.width = imgWidth; imageElm.height = imgHeight; diff --git a/resources/src/mediawiki.page/mediawiki.page.gallery.print.css b/resources/src/mediawiki.page/mediawiki.page.gallery.print.css new file mode 100644 index 00000000..0c14865e --- /dev/null +++ b/resources/src/mediawiki.page/mediawiki.page.gallery.print.css @@ -0,0 +1,35 @@ +li.gallerybox { + vertical-align: top; + display: inline-block; +} + +ul.gallery, li.gallerybox { + zoom: 1; + *display: inline; +} + +ul.gallery { + margin: 2px; + padding: 2px; + display: block; +} + +li.gallerycaption { + font-weight: bold; + text-align: center; + display: block; + word-wrap: break-word; +} + +li.gallerybox div.thumb { + text-align: center; + border: 1px solid #ccc; + margin: 2px; +} + +div.gallerytext { + overflow: hidden; + font-size: 94%; + padding: 2px 4px; + word-wrap: break-word; +} diff --git a/resources/src/mediawiki.page/mediawiki.page.image.pagination.js b/resources/src/mediawiki.page/mediawiki.page.image.pagination.js index 9ad9c30a..49a51dfc 100644 --- a/resources/src/mediawiki.page/mediawiki.page.image.pagination.js +++ b/resources/src/mediawiki.page/mediawiki.page.image.pagination.js @@ -2,6 +2,7 @@ * Implement AJAX navigation for multi-page images so the user may browse without a full page reload. */ ( function ( mw, $ ) { + /*jshint latedef:false */ var jqXhr, $multipageimage, $spinner, cache = {}, cacheOrder = []; @@ -18,11 +19,11 @@ jqXhr = undefined; // Try the cache - if ( cache[url] ) { + if ( cache[ url ] ) { // Update access freshness cacheOrder.splice( $.inArray( url, cacheOrder ), 1 ); cacheOrder.push( url ); - return $.Deferred().resolve( cache[url] ).promise(); + return $.Deferred().resolve( cache[ url ] ).promise(); } // @todo Don't fetch the entire page. Ideally we'd only fetch the content portion or the data @@ -36,12 +37,12 @@ jqXhr = undefined; // Cache the newly loaded page - cache[url] = $contents; + cache[ url ] = $contents; cacheOrder.push( url ); // Remove the oldest entry if we're over the limit if ( cacheOrder.length > 10 ) { - delete cache[ cacheOrder[0] ]; + delete cache[ cacheOrder[ 0 ] ]; cacheOrder = cacheOrder.slice( 1 ); } } ); diff --git a/resources/src/mediawiki.page/mediawiki.page.patrol.ajax.js b/resources/src/mediawiki.page/mediawiki.page.patrol.ajax.js index cc72e168..f9b0d356 100644 --- a/resources/src/mediawiki.page/mediawiki.page.patrol.ajax.js +++ b/resources/src/mediawiki.page/mediawiki.page.patrol.ajax.js @@ -17,7 +17,7 @@ var $spinner, href, rcid, apiRequest; // Start preloading the notification module (normally loaded by mw.notify()) - mw.loader.load( ['mediawiki.notification'], null, true ); + mw.loader.load( 'mediawiki.notification' ); // Hide the link and create a spinner to show it inside the brackets. $spinner = $.createSpinner( { @@ -43,7 +43,7 @@ mw.notify( mw.msg( 'markedaspatrollednotify', title.toText() ) ); } else { // This should never happen as errors should trigger fail - mw.notify( mw.msg( 'markedaspatrollederrornotify' ) ); + mw.notify( mw.msg( 'markedaspatrollederrornotify' ), { type: 'error' } ); } } ) .fail( function ( error ) { @@ -53,9 +53,9 @@ $patrolLinks.show(); if ( error === 'noautopatrol' ) { // Can't patrol own - mw.notify( mw.msg( 'markedaspatrollederror-noautopatrol' ) ); + mw.notify( mw.msg( 'markedaspatrollederror-noautopatrol' ), { type: 'warn' } ); } else { - mw.notify( mw.msg( 'markedaspatrollederrornotify' ) ); + mw.notify( mw.msg( 'markedaspatrollederrornotify' ), { type: 'error' } ); } } ); diff --git a/resources/src/mediawiki.page/mediawiki.page.ready.js b/resources/src/mediawiki.page/mediawiki.page.ready.js index 36eb9d4f..9505bdd1 100644 --- a/resources/src/mediawiki.page/mediawiki.page.ready.js +++ b/resources/src/mediawiki.page/mediawiki.page.ready.js @@ -59,6 +59,17 @@ } $nodes.updateTooltipAccessKeys(); + // Infuse OOUI widgets, if any are present + $nodes = $( '[data-ooui]' ); + if ( $nodes.length ) { + // FIXME: We should only load the widgets that are being infused + mw.loader.using( [ 'mediawiki.widgets', 'mediawiki.widgets.UserInputWidget' ] ).done( function () { + $nodes.each( function () { + OO.ui.infuse( this ); + } ); + } ); + } + } ); }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.page/mediawiki.page.startup.js b/resources/src/mediawiki.page/mediawiki.page.startup.js index ddd4f0c4..708dcb5c 100644 --- a/resources/src/mediawiki.page/mediawiki.page.startup.js +++ b/resources/src/mediawiki.page/mediawiki.page.startup.js @@ -1,12 +1,11 @@ ( function ( mw, $ ) { - mw.page = {}; + // Support: MediaWiki < 1.26 + // Cached HTML will not yet have this from OutputPage::getHeadScripts. + document.documentElement.className = document.documentElement.className + .replace( /(^|\s)client-nojs(\s|$)/, '$1client-js$2' ); - // Client profile classes for <html> - // Allows for easy hiding/showing of JS or no-JS-specific UI elements - $( document.documentElement ) - .addClass( 'client-js' ) - .removeClass( 'client-nojs' ); + mw.page = {}; $( function () { mw.util.init(); diff --git a/resources/src/mediawiki.page/mediawiki.page.watch.ajax.js b/resources/src/mediawiki.page/mediawiki.page.watch.ajax.js index d252f0e4..a3197da3 100644 --- a/resources/src/mediawiki.page/mediawiki.page.watch.ajax.js +++ b/resources/src/mediawiki.page/mediawiki.page.watch.ajax.js @@ -84,12 +84,12 @@ actionPaths = mw.config.get( 'wgActionPaths' ); for ( key in actionPaths ) { if ( actionPaths.hasOwnProperty( key ) ) { - parts = actionPaths[key].split( '$1' ); + parts = actionPaths[ key ].split( '$1' ); for ( i = 0; i < parts.length; i++ ) { - parts[i] = $.escapeRE( parts[i] ); + parts[ i ] = mw.RegExp.escape( parts[ i ] ); } m = new RegExp( parts.join( '(.+)' ) ).exec( url ); - if ( m && m[1] ) { + if ( m && m[ 1 ] ) { return key; } @@ -116,7 +116,7 @@ var action, api, $link; // Start preloading the notification module (normally loaded by mw.notify()) - mw.loader.load( ['mediawiki.notification'], null, true ); + mw.loader.load( 'mediawiki.notification' ); action = mwUriGetAction( this.href ); @@ -138,7 +138,7 @@ api = new mw.Api(); - api[action]( title ) + api[ action ]( title ) .done( function ( watchResponse ) { var otherAction = action === 'watch' ? 'unwatch' : 'watch'; @@ -170,7 +170,10 @@ msg = mw.message( 'watcherrortext', link ); // Report to user about the error - mw.notify( msg, { tag: 'watch-self' } ); + mw.notify( msg, { + tag: 'watch-self', + type: 'error' + } ); } ); } ); } ); diff --git a/resources/src/mediawiki.skinning/content.css b/resources/src/mediawiki.skinning/content.css index 7dd5ee7f..454fe58d 100644 --- a/resources/src/mediawiki.skinning/content.css +++ b/resources/src/mediawiki.skinning/content.css @@ -23,14 +23,13 @@ * We use display:table. Even though it should only contain other table-* display * elements, there are no known problems with using this. * - * Because IE < 8, FF 2 and other older browsers don't support display:table, we fallback to + * Because IE < 8 and other older browsers don't support display:table, we fallback to * using inline-block mode, which features at least intrinsic width, but won't clear preceding * inline elements. In practice inline elements surrounding the TOC are uncommon enough that * this is an acceptable sacrifice. */ #toc, .toc { - display: -moz-inline-block; display: inline-block; display: table; diff --git a/resources/src/mediawiki.skinning/elements.css b/resources/src/mediawiki.skinning/elements.css index 8140d1a5..d706d261 100644 --- a/resources/src/mediawiki.skinning/elements.css +++ b/resources/src/mediawiki.skinning/elements.css @@ -64,6 +64,10 @@ a.new:visited, #p-personal a.new:visited { color: #b63; } +.mw-body a.external.free { + word-wrap: break-word; +} + /* Inline Elements */ img { border: none; @@ -194,11 +198,14 @@ code { padding: 1px 4px; } -pre, .mw-code { +pre, +.mw-code { color: black; background-color: #f9f9f9; border: 1px solid #ddd; padding: 1em; + /* Wrap lines in overflow. T2260, T103780 */ + white-space: pre-wrap; } /* Tables */ @@ -237,10 +244,6 @@ textarea { box-sizing: border-box; } -select { - vertical-align: top; -} - /* Emulate Center */ .center { width: 100%; diff --git a/resources/src/mediawiki.special/mediawiki.special.changeemail.js b/resources/src/mediawiki.special/mediawiki.special.changeemail.js index 67531f78..06851b93 100644 --- a/resources/src/mediawiki.special/mediawiki.special.changeemail.js +++ b/resources/src/mediawiki.special/mediawiki.special.changeemail.js @@ -4,6 +4,7 @@ ( function ( mw, $ ) { /** * Given an email validity status (true, false, null) update the label CSS class + * * @ignore */ function updateMailValidityLabel( mail ) { diff --git a/resources/src/mediawiki.special/mediawiki.special.changeslist.css b/resources/src/mediawiki.special/mediawiki.special.changeslist.css index 16fdf38a..bdae0dd2 100644 --- a/resources/src/mediawiki.special/mediawiki.special.changeslist.css +++ b/resources/src/mediawiki.special/mediawiki.special.changeslist.css @@ -7,9 +7,11 @@ } /* - * Titles, including username links, are especially prone for getting jumbled up + * Titles, including username links, and also tag names + * are prone to getting jumbled up * with other titles, usernames, etc. in mixed RTL-LTR environment. */ +.mw-changeslist .mw-tag-marker, .mw-changeslist .mw-title { unicode-bidi: embed; } diff --git a/resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css b/resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css index 0e026aff..a4843509 100644 --- a/resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css +++ b/resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css @@ -59,3 +59,7 @@ table.mw-enhanced-rc td.mw-enhanced-rc-nested { .mw-enhanced-watched .mw-enhanced-rc-time { font-weight: bold; } + +span.changedby { + font-size: 95%; +} diff --git a/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.js b/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.js index c9e55111..f217bf59 100644 --- a/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.js +++ b/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.js @@ -3,23 +3,22 @@ */ /* Remember the collapse state of the legend on recent changes and watchlist pages. */ -jQuery( document ).ready( function ( $ ) { +( function ( mw, $ ) { var cookieName = 'changeslist-state', - cookieOptions = { - expires: 30, - path: '/' - }, - isCollapsed = $.cookie( cookieName ) === 'collapsed'; + // Expanded by default + isCollapsed = mw.cookie.get( cookieName ) === 'collapsed'; - $( '.mw-changeslist-legend' ) - .makeCollapsible( { - collapsed: isCollapsed - } ) - .on( 'beforeExpand.mw-collapsible', function () { - $.cookie( cookieName, 'expanded', cookieOptions ); - } ) - .on( 'beforeCollapse.mw-collapsible', function () { - $.cookie( cookieName, 'collapsed', cookieOptions ); - } ); -} ); + $( function () { + $( '.mw-changeslist-legend' ) + .makeCollapsible( { + collapsed: isCollapsed + } ) + .on( 'beforeExpand.mw-collapsible', function () { + mw.cookie.set( cookieName, 'expanded' ); + } ) + .on( 'beforeCollapse.mw-collapsible', function () { + mw.cookie.set( cookieName, 'collapsed' ); + } ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.special/mediawiki.special.css b/resources/src/mediawiki.special/mediawiki.special.css index d2457262..a13ec3cc 100644 --- a/resources/src/mediawiki.special/mediawiki.special.css +++ b/resources/src/mediawiki.special/mediawiki.special.css @@ -84,13 +84,10 @@ td#mw-prefixindex-nav-form { font-weight: bold; } -.mw-specialpages-table { - margin-top: -1em; - margin-bottom: 1em; -} - -.mw-specialpages-table td { - vertical-align: top; +.mw-specialpages-list { + -webkit-columns: 16em 2; + -moz-columns: 16em 2; + columns: 16em 2; } /* Special:Statistics */ diff --git a/resources/src/mediawiki.special/mediawiki.special.movePage.css b/resources/src/mediawiki.special/mediawiki.special.movePage.css new file mode 100644 index 00000000..dd1c2aad --- /dev/null +++ b/resources/src/mediawiki.special/mediawiki.special.movePage.css @@ -0,0 +1,8 @@ +/*! + * Styles for Special:MovePage + */ + +.movepage-wrapper { + width: 50em; + margin: 1em 0; +} diff --git a/resources/src/mediawiki.special/mediawiki.special.movePage.js b/resources/src/mediawiki.special/mediawiki.special.movePage.js index 7e56050d..6d88c51c 100644 --- a/resources/src/mediawiki.special/mediawiki.special.movePage.js +++ b/resources/src/mediawiki.special/mediawiki.special.movePage.js @@ -1,6 +1,7 @@ /*! * JavaScript for Special:MovePage */ -jQuery( function ( $ ) { - $( '#wpReason, #wpNewTitleMain' ).byteLimit(); +jQuery( function () { + OO.ui.infuse( 'wpNewTitle' ); + OO.ui.infuse( 'wpReason' ).$input.byteLimit(); } ); diff --git a/resources/src/mediawiki.special/mediawiki.special.preferences.css b/resources/src/mediawiki.special/mediawiki.special.preferences.css index e27e34a0..9e5efd36 100644 --- a/resources/src/mediawiki.special/mediawiki.special.preferences.css +++ b/resources/src/mediawiki.special/mediawiki.special.preferences.css @@ -19,3 +19,20 @@ height: 0; zoom: 1; } + +/* When JS is enabled, .mw-preferences-messageboxes are replaced with mw.notifications */ +.mw-preferences-messagebox { + display: none; +} + +.prefsection td.mw-label { + width: 20%; +} + +.prefsection table { + width: 100%; +} + +.prefsection table.mw-htmlform-matrix { + width: auto; +} diff --git a/resources/src/mediawiki.special/mediawiki.special.preferences.js b/resources/src/mediawiki.special/mediawiki.special.preferences.js index 4bd747b2..6839d813 100644 --- a/resources/src/mediawiki.special/mediawiki.special.preferences.js +++ b/resources/src/mediawiki.special/mediawiki.special.preferences.js @@ -5,7 +5,8 @@ jQuery( function ( $ ) { var $preftoc, $preferences, $fieldsets, $legends, hash, labelFunc, $tzSelect, $tzTextbox, $localtimeHolder, servertime, - $checkBoxes, allowCloseWindowFn; + $checkBoxes, allowCloseWindow, + notif; labelFunc = function () { return this.id.replace( /^mw-prefsection/g, 'preftab' ); @@ -50,8 +51,8 @@ jQuery( function ( $ ) { * It uses document.getElementById for security reasons (HTML injections in $()). * * @ignore - * @param String name: the name of a tab without the prefix ("mw-prefsection-") - * @param String mode: [optional] A hash will be set according to the current + * @param {String} name the name of a tab without the prefix ("mw-prefsection-") + * @param {String} [mode] A hash will be set according to the current * open section. Set mode 'noHash' to surpress this. */ function switchPrefTab( name, mode ) { @@ -84,6 +85,26 @@ jQuery( function ( $ ) { } } + // Check for messageboxes (.successbox, .warningbox, .errorbox) to replace with notifications + if ( $( '.mw-preferences-messagebox' ).length ) { + // If there is a #mw-preferences-success box and javascript is enabled, use a slick notification instead! + if ( $( '#mw-preferences-success' ).length ) { + notif = mediaWiki.notification.notify( mediaWiki.message( 'savedprefs' ), { autoHide: false } ); + // 'change' event not reliable! + $( '#preftoc, .prefsection' ).one( 'change keydown mousedown', function () { + if ( notif ) { + notif.close(); + notif = null; + } + } ); + + // Remove now-unnecessary success=1 querystring to prevent reappearance of notification on reload + if ( history.replaceState ) { + history.replaceState( {}, document.title, location.href.replace( /&?success=1/, '' ) ); + } + } + } + // Populate the prefToc $legends.each( function ( i, legend ) { var $legend = $( legend ), @@ -183,15 +204,15 @@ jQuery( function ( $ ) { var minutes, arr = hour.split( ':' ); - arr[0] = parseInt( arr[0], 10 ); + arr[ 0 ] = parseInt( arr[ 0 ], 10 ); if ( arr.length === 1 ) { // Specification is of the form [-]XX - minutes = arr[0] * 60; + minutes = arr[ 0 ] * 60; } else { // Specification is of the form [-]XX:XX - minutes = Math.abs( arr[0] ) * 60 + parseInt( arr[1], 10 ); - if ( arr[0] < 0 ) { + minutes = Math.abs( arr[ 0 ] ) * 60 + parseInt( arr[ 1 ], 10 ); + if ( arr[ 0 ] < 0 ) { minutes *= -1; } } @@ -218,7 +239,7 @@ jQuery( function ( $ ) { minuteDiff = hoursToMinutes( $tzTextbox.val() ); } else { // Grab data from the $tzSelect value - minuteDiff = parseInt( type.split( '|' )[1], 10 ) || 0; + minuteDiff = parseInt( type.split( '|' )[ 1 ], 10 ) || 0; $tzTextbox.val( minutesToHours( minuteDiff ) ); } @@ -266,7 +287,7 @@ jQuery( function ( $ ) { // Set up a message to notify users if they try to leave the page without // saving. $( '#mw-prefs-form' ).data( 'origdata', $( '#mw-prefs-form' ).serialize() ); - allowCloseWindowFn = mediaWiki.confirmCloseWindow( { + allowCloseWindow = mediaWiki.confirmCloseWindow( { test: function () { return $( '#mw-prefs-form' ).serialize() !== $( '#mw-prefs-form' ).data( 'origdata' ); }, @@ -274,6 +295,6 @@ jQuery( function ( $ ) { message: mediaWiki.msg( 'prefswarning-warning', mediaWiki.msg( 'saveprefs' ) ), namespace: 'prefswarning' } ); - $( '#mw-prefs-form' ).submit( allowCloseWindowFn ); - $( '#mw-prefs-restoreprefs' ).click( allowCloseWindowFn ); + $( '#mw-prefs-form' ).submit( $.proxy( allowCloseWindow, 'release' ) ); + $( '#mw-prefs-restoreprefs' ).click( $.proxy( allowCloseWindow, 'release' ) ); } ); diff --git a/resources/src/mediawiki.special/mediawiki.special.search.css b/resources/src/mediawiki.special/mediawiki.special.search.css index 8f845dfa..b8693143 100644 --- a/resources/src/mediawiki.special/mediawiki.special.search.css +++ b/resources/src/mediawiki.special/mediawiki.special.search.css @@ -27,6 +27,7 @@ div.searchresult { } .mw-search-results { margin-left: 0.4em; + float: left; } .mw-search-results li { padding-bottom: 1.2em; diff --git a/resources/src/mediawiki.special/mediawiki.special.search.js b/resources/src/mediawiki.special/mediawiki.special.search.js index b27fe349..730119e8 100644 --- a/resources/src/mediawiki.special/mediawiki.special.search.js +++ b/resources/src/mediawiki.special/mediawiki.special.search.js @@ -39,12 +39,12 @@ var parts = $( this ).attr( 'href' ).split( 'search=' ), lastpart = '', prefix = 'search='; - if ( parts.length > 1 && parts[1].indexOf( '&' ) !== -1 ) { - lastpart = parts[1].slice( parts[1].indexOf( '&' ) ); + if ( parts.length > 1 && parts[ 1 ].indexOf( '&' ) !== -1 ) { + lastpart = parts[ 1 ].slice( parts[ 1 ].indexOf( '&' ) ); } else { prefix = '&search='; } - this.href = parts[0] + prefix + encodeURIComponent( searchterm ) + lastpart; + this.href = parts[ 0 ] + prefix + encodeURIComponent( searchterm ) + lastpart; } ); } ).trigger( 'change' ); diff --git a/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.js b/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.js index 8d3e86ae..7628ff88 100644 --- a/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.js +++ b/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.js @@ -28,7 +28,7 @@ mw.notify( mw.msg( 'addedwatchtext-short', title ) ); } ).fail( function () { $link.text( mw.msg( 'watch' ) ); - mw.notify( mw.msg( 'watcherrortext', title ) ); + mw.notify( mw.msg( 'watcherrortext', title ), { type: 'error' } ); } ); } else { $link.text( mw.msg( 'unwatching' ) ); @@ -38,7 +38,7 @@ mw.notify( mw.msg( 'removedwatchtext-short', title ) ); } ).fail( function () { $link.text( mw.msg( 'unwatch' ) ); - mw.notify( mw.msg( 'watcherrortext', title ) ); + mw.notify( mw.msg( 'watcherrortext', title ), { type: 'error' } ); } ); } diff --git a/resources/src/mediawiki.special/mediawiki.special.upload.js b/resources/src/mediawiki.special/mediawiki.special.upload.js index eeccda59..677d26d7 100644 --- a/resources/src/mediawiki.special/mediawiki.special.upload.js +++ b/resources/src/mediawiki.special/mediawiki.special.upload.js @@ -6,6 +6,7 @@ * @singleton */ ( function ( mw, $ ) { + /*jshint latedef:false */ var uploadWarning, uploadLicense, ajaxUploadDestCheck = mw.config.get( 'wgAjaxUploadDestCheck' ), $license = $( '#wpLicense' ); @@ -35,7 +36,7 @@ } // Check response cache if ( this.responseCache.hasOwnProperty( this.nameToCheck ) ) { - this.setWarning( this.responseCache[this.nameToCheck] ); + this.setWarning( this.responseCache[ this.nameToCheck ] ); return; } @@ -71,7 +72,7 @@ } ).done( function ( result ) { var resultOut = ''; if ( result.query ) { - resultOut = result.query.pages[result.query.pageids[0]].imageinfo[0]; + resultOut = result.query.pages[ result.query.pageids[ 0 ] ].imageinfo[ 0 ]; } $spinnerDestCheck.remove(); uploadWarning.processResult( resultOut, uploadWarning.nameToCheck ); @@ -80,7 +81,7 @@ processResult: function ( result, fileName ) { this.setWarning( result.html ); - this.responseCache[fileName] = result.html; + this.responseCache[ fileName ] = result.html; }, setWarning: function ( warning ) { @@ -107,7 +108,7 @@ return; } if ( this.responseCache.hasOwnProperty( license ) ) { - this.showPreview( this.responseCache[license] ); + this.showPreview( this.responseCache[ license ] ); return; } @@ -126,8 +127,8 @@ }, processResult: function ( result, license ) { - this.responseCache[license] = result.parse.text['*']; - this.showPreview( this.responseCache[license] ); + this.responseCache[ license ] = result.parse.text[ '*' ]; + this.showPreview( this.responseCache[ license ] ); }, showPreview: function ( preview ) { @@ -228,7 +229,7 @@ fname = fname.replace( / /g, '_' ); // Capitalise first letter if needed if ( mw.config.get( 'wgCapitalizeUploads' ) ) { - fname = fname.charAt( 0 ).toUpperCase().concat( fname.slice( 1 ) ); + fname = fname[ 0 ].toUpperCase() + fname.slice( 1 ); } // Output result @@ -265,15 +266,32 @@ * TODO: Put SVG back after working around Firefox 7 bug <https://bugzilla.wikimedia.org/show_bug.cgi?id=31643> * * @param {File} file - * @return boolean + * @return {boolean} */ function fileIsPreviewable( file ) { - var known = ['image/png', 'image/gif', 'image/jpeg', 'image/svg+xml'], + var known = [ 'image/png', 'image/gif', 'image/jpeg', 'image/svg+xml' ], tooHuge = 10 * 1024 * 1024; return ( $.inArray( file.type, known ) !== -1 ) && file.size > 0 && file.size < tooHuge; } /** + * Format a file size attractively. + * + * TODO: Match numeric formatting + * + * @param {number} s + * @return {string} + */ + function prettySize( s ) { + var sizeMsgs = [ 'size-bytes', 'size-kilobytes', 'size-megabytes', 'size-gigabytes' ]; + while ( s >= 1024 && sizeMsgs.length > 1 ) { + s /= 1024; + sizeMsgs = sizeMsgs.slice( 1 ); + } + return mw.msg( sizeMsgs[ 0 ], Math.round( s ) ); + } + + /** * Show a thumbnail preview of PNG, JPEG, GIF, and SVG files prior to upload * in browsers supporting HTML5 FileAPI. * @@ -291,13 +309,17 @@ ctx, meta, previewSize = 180, + $spinner = $.createSpinner( { size: 'small', type: 'block' } ) + .css( { width: previewSize, height: previewSize } ), thumb = mw.template.get( 'mediawiki.special.upload', 'thumbnail.html' ).render(); - thumb.find( '.filename' ).text( file.name ).end() - .find( '.fileinfo' ).text( prettySize( file.size ) ).end(); + thumb + .find( '.filename' ).text( file.name ).end() + .find( '.fileinfo' ).text( prettySize( file.size ) ).end() + .find( '.thumbinner' ).prepend( $spinner ).end(); - $canvas = $( '<canvas width="' + previewSize + '" height="' + previewSize + '" ></canvas>' ); - ctx = $canvas[0].getContext( '2d' ); + $canvas = $( '<canvas>' ).attr( { width: previewSize, height: previewSize } ); + ctx = $canvas[ 0 ].getContext( '2d' ); $( '#mw-htmlform-source' ).parent().prepend( thumb ); fetchPreview( file, function ( dataURL ) { @@ -369,7 +391,7 @@ ctx.clearRect( 0, 0, 180, 180 ); ctx.rotate( rotation / 180 * Math.PI ); ctx.drawImage( img, x, y, width, height ); - thumb.find( '.mw-small-spinner' ).replaceWith( $canvas ); + $spinner.replaceWith( $canvas ); // Image size info = mw.msg( 'widthheight', logicalWidth, logicalHeight ) + @@ -421,7 +443,7 @@ buffer = new Uint8Array( reader.result ), string = ''; for ( i = 0; i < buffer.byteLength; i++ ) { - string += String.fromCharCode( buffer[i] ); + string += String.fromCharCode( buffer[ i ] ); } callbackBinary( string ); @@ -451,23 +473,6 @@ } /** - * Format a file size attractively. - * - * TODO: Match numeric formatting - * - * @param {number} s - * @return {string} - */ - function prettySize( s ) { - var sizeMsgs = ['size-bytes', 'size-kilobytes', 'size-megabytes', 'size-gigabytes']; - while ( s >= 1024 && sizeMsgs.length > 1 ) { - s /= 1024; - sizeMsgs = sizeMsgs.slice( 1 ); - } - return mw.msg( sizeMsgs[0], Math.round( s ) ); - } - - /** * Clear the file upload preview area. */ function clearPreview() { @@ -483,10 +488,10 @@ function getMaxUploadSize( type ) { var sizes = mw.config.get( 'wgMaxUploadSize' ); - if ( sizes[type] !== undefined ) { - return sizes[type]; + if ( sizes[ type ] !== undefined ) { + return sizes[ type ]; } - return sizes['*']; + return sizes[ '*' ]; } $( '.mw-upload-source-error' ).remove(); @@ -511,7 +516,7 @@ clearPreview(); if ( this.files && this.files.length ) { // Note: would need to be updated to handle multiple files. - var file = this.files[0]; + var file = this.files[ 0 ]; if ( !checkMaxUploadSize( file ) ) { return; @@ -578,7 +583,7 @@ } ); $uploadForm.submit( function () { - allowCloseWindow(); + allowCloseWindow.release(); } ); } ); }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.special/mediawiki.special.userlogin.common.js b/resources/src/mediawiki.special/mediawiki.special.userlogin.common.js deleted file mode 100644 index f5289dee..00000000 --- a/resources/src/mediawiki.special/mediawiki.special.userlogin.common.js +++ /dev/null @@ -1,72 +0,0 @@ -/*! - * JavaScript for login and signup forms. - */ -( function ( mw, $ ) { - // Move the FancyCaptcha image into a more attractive container. - // The CAPTCHA is in a <div class="captcha"> at the top of the form. If it's a FancyCaptcha, - // then we remove it and insert it lower down, in a customized div with just what we need (e.g. - // no 'fancycaptcha-createaccount' message). - function adjustFancyCaptcha( $content, buttonSubmit ) { - var $submit = $content.find( buttonSubmit ), - tabIndex, - $captchaStuff, - $captchaImageContainer, - // JavaScript can't yet parse the message 'createacct-imgcaptcha-help' when it - // contains a MediaWiki transclusion, so PHP parses it and sends the HTML. - // This is only set for the signup form (and undefined for login). - helpMsg = mw.config.get( 'wgCreateacctImgcaptchaHelp' ), - helpHtml = ''; - - if ( !$submit.length ) { - return; - } - tabIndex = $submit.prop( 'tabIndex' ) - 1; - $captchaStuff = $content.find( '.captcha' ); - - if ( $captchaStuff.length ) { - // The FancyCaptcha has this class in the ConfirmEdit extension since 2013-04-18. - $captchaImageContainer = $captchaStuff.find( '.fancycaptcha-image-container' ); - if ( $captchaImageContainer.length !== 1 ) { - return; - } - - $captchaStuff.remove(); - - if ( helpMsg ) { - helpHtml = '<small class="mw-createacct-captcha-assisted">' + helpMsg + '</small>'; - } - - // Insert another div before the submit button that will include the - // repositioned FancyCaptcha div, an input field, and possible help. - $submit.closest( 'div' ).before( [ - '<div>', - '<label for="wpCaptchaWord">' + mw.message( 'createacct-captcha' ).escaped() + '</label>', - '<div class="mw-createacct-captcha-container">', - '<div class="mw-createacct-captcha-and-reload" />', - '<input id="wpCaptchaWord" class="mw-ui-input" name="wpCaptchaWord" type="text" placeholder="' + - mw.message( 'createacct-imgcaptcha-ph' ).escaped() + - '" tabindex="' + tabIndex + '" autocapitalize="off" autocorrect="off">', - helpHtml, - '</div>', - '</div>' - ].join( '' ) ); - - // Stick the FancyCaptcha container inside our bordered and framed parents. - $captchaImageContainer - .prependTo( $content.find( '.mw-createacct-captcha-and-reload' ) ); - - // Find the input field, add the text (if any) of the existing CAPTCHA - // field (although usually it's blanked out on every redisplay), - // and after it move over the hidden field that tells the CAPTCHA - // what to do. - $content.find( '#wpCaptchaWord' ) - .val( $captchaStuff.find( '#wpCaptchaWord' ).val() ) - .after( $captchaStuff.find( '#wpCaptchaId' ) ); - } - } - - $( function () { - // Work with both login and signup form - adjustFancyCaptcha( $( '#mw-content-text' ), '#wpCreateaccount, #wpLoginAttempt' ); - } ); -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js b/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js index a32a7902..a0c6ee2a 100644 --- a/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js +++ b/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js @@ -65,7 +65,7 @@ ususers: username // '|' in usernames is handled below } ) .done( function ( resp ) { - var userinfo = resp.query.users[0]; + var userinfo = resp.query.users[ 0 ]; if ( resp.query.users.length !== 1 ) { // Happens if the user types '|' into the field diff --git a/resources/src/mediawiki.special/mediawiki.special.version.css b/resources/src/mediawiki.special/mediawiki.special.version.css index 7c87d68f..5b259e70 100644 --- a/resources/src/mediawiki.special/mediawiki.special.version.css +++ b/resources/src/mediawiki.special/mediawiki.special.version.css @@ -1,10 +1,12 @@ /*! * Styling for Special:Version */ -.mw-version-ext-name { +.mw-version-ext-name, +.mw-version-library-name { font-weight: bold; } +.mw-version-ext-license, .mw-version-ext-vcs-timestamp { white-space: nowrap; } diff --git a/resources/src/mediawiki.special/templates/thumbnail.html b/resources/src/mediawiki.special/templates/thumbnail.html index 73042f24..bf0e7014 100644 --- a/resources/src/mediawiki.special/templates/thumbnail.html +++ b/resources/src/mediawiki.special/templates/thumbnail.html @@ -1,6 +1,5 @@ <div id="mw-upload-thumbnail" class="thumb tright"> <div class="thumbinner"> - <div class="mw-small-spinner" style="width: 180px; height: 180px"></div> <div class="thumbcaption"> <div class="filename"></div> <div class="fileinfo"></div> diff --git a/resources/src/mediawiki.toolbar/toolbar.js b/resources/src/mediawiki.toolbar/toolbar.js index 70d54ce3..0469cc50 100644 --- a/resources/src/mediawiki.toolbar/toolbar.js +++ b/resources/src/mediawiki.toolbar/toolbar.js @@ -174,7 +174,7 @@ $toolbar = $( '#toolbar' ); for ( i = 0; i < queue.length; i++ ) { - button = queue[i]; + button = queue[ i ]; if ( $.isArray( button ) ) { // Forwarded arguments array from mw.toolbar.addButton insertButton.apply( toolbar, button ); diff --git a/resources/src/mediawiki.ui/components/buttons.less b/resources/src/mediawiki.ui/components/buttons.less index f88f3ee6..77b3f9d8 100644 --- a/resources/src/mediawiki.ui/components/buttons.less +++ b/resources/src/mediawiki.ui/components/buttons.less @@ -47,7 +47,7 @@ zoom: 1; // Container styling - .button-colors(#FFF); + .button-colors(#FFF, #CCC, #777); border-radius: @borderRadius; min-width: 4em; @@ -135,10 +135,10 @@ // Styleguide 2.1.1. &.mw-ui-progressive, &.mw-ui-primary { - .button-colors(@colorProgressive); + .button-colors(@colorProgressive, @colorProgressiveHighlight, @colorProgressiveActive); &.mw-ui-quiet { - .button-colors-quiet(@colorProgressive); + .button-colors-quiet(@colorProgressive, @colorProgressiveHighlight, @colorProgressiveActive); } } @@ -158,10 +158,10 @@ // // Styleguide 2.1.2. &.mw-ui-constructive { - .button-colors(@colorConstructive); + .button-colors(@colorConstructive, @colorConstructiveHighlight, @colorConstructiveActive); &.mw-ui-quiet { - .button-colors-quiet(@colorConstructive); + .button-colors-quiet(@colorConstructive, @colorConstructiveHighlight, @colorConstructiveActive); } } @@ -180,10 +180,10 @@ // // Styleguide 2.1.3. &.mw-ui-destructive { - .button-colors(@colorDestructive); + .button-colors(@colorDestructive, @colorDestructiveHighlight, @colorDestructiveActive); &.mw-ui-quiet { - .button-colors-quiet(@colorDestructive); + .button-colors-quiet(@colorDestructive, @colorDestructiveHighlight, @colorDestructiveActive); } } @@ -220,7 +220,7 @@ background: transparent; border: none; text-shadow: none; - .button-colors-quiet(@colorButtonText); + .button-colors-quiet(@colorButtonText, @colorButtonTextHighlight, @colorButtonTextActive); &:hover, &:focus { diff --git a/resources/src/mediawiki.ui/components/checkbox.less b/resources/src/mediawiki.ui/components/checkbox.less index 4829f5f6..ac5becb8 100644 --- a/resources/src/mediawiki.ui/components/checkbox.less +++ b/resources/src/mediawiki.ui/components/checkbox.less @@ -54,6 +54,9 @@ // we hide the input element as instead we will style the label that follows // we use opacity so that VoiceOver software can still identify it opacity: 0; + // Render "on top of" the label, so that it's still clickable (T98905) + z-index: 1; + position: relative; // ensure the invisible checkbox takes up the required width width: @checkboxSize; height: @checkboxSize; diff --git a/resources/src/mediawiki.ui/components/icons.less b/resources/src/mediawiki.ui/components/icons.less index ad951b08..d9e8c420 100644 --- a/resources/src/mediawiki.ui/components/icons.less +++ b/resources/src/mediawiki.ui/components/icons.less @@ -1,13 +1,9 @@ @import "mediawiki.mixins"; - -// Variables -@iconSize: 1.4em; -@gutterWidth: 1em; +@import "mediawiki.ui/variables"; // Mixins .mixin-mw-ui-icon-bgimage(@iconSvg, @iconPng) { &.mw-ui-icon { - &:after, &:before { .background-image-svg(@iconSvg, @iconPng); } @@ -42,7 +38,7 @@ // // Styleguide 6.1.1. &.mw-ui-icon-element { - @width: @iconSize + ( 2 * @gutterWidth ); + @width: @iconSize + ( 2 * @iconGutterWidth ); text-indent: -999px; overflow: hidden; @@ -53,11 +49,10 @@ left: 0; right: 0; position: absolute; - margin: 0 @gutterWidth; + margin: 0 @iconGutterWidth; } } - &.mw-ui-icon-after:after, &.mw-ui-icon-before:before, &.mw-ui-icon-element:before { background-position: 50% 50%; @@ -81,27 +76,7 @@ &:before { position: relative; width: @iconSize; - margin-right: @gutterWidth; - } - } - - // Icons with text before - // - // Markup: - // <div class="mw-ui-icon mw-ui-icon-after mw-ui-icon-ok mw-ui-progressive mw-ui-button">OK</div> - // - // Styleguide 6.1.3 - &.mw-ui-icon-after { - &:after { - position: relative; - float: right; - width: @iconSize; - margin-left: @gutterWidth; + margin-right: @iconGutterWidth; } } } - -// Icons -.mw-ui-icon-ok { - .mixin-mw-ui-icon-bgimage('images/ok.svg', 'images/ok.png'); -} diff --git a/resources/src/mediawiki.ui/components/images/ok.png b/resources/src/mediawiki.ui/components/images/ok.png Binary files differdeleted file mode 100644 index 1ea6aa2d..00000000 --- a/resources/src/mediawiki.ui/components/images/ok.png +++ /dev/null diff --git a/resources/src/mediawiki.ui/components/images/ok.svg b/resources/src/mediawiki.ui/components/images/ok.svg deleted file mode 100644 index a3d3058a..00000000 --- a/resources/src/mediawiki.ui/components/images/ok.svg +++ /dev/null @@ -1 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22"><path d="M18.125 1.813l-10.5 10.75-3.844-3.75L0 12.719l7.72 7.452L22 5.625z" fill="#f0f0f0"/></svg> diff --git a/resources/src/mediawiki.widgets/AUTHORS.txt b/resources/src/mediawiki.widgets/AUTHORS.txt new file mode 100644 index 00000000..10064b24 --- /dev/null +++ b/resources/src/mediawiki.widgets/AUTHORS.txt @@ -0,0 +1,10 @@ +Authors (alphabetically) + +Alex Monk <krenair@wikimedia.org> +Bartosz Dziewoński <bdziewonski@wikimedia.org> +Ed Sanders <esanders@wikimedia.org> +James D. Forrester <jforrester@wikimedia.org> +Roan Kattouw <roan@wikimedia.org> +Sucheta Ghoshal <sghoshal@wikimedia.org> +Timo Tijhof <timo@wikimedia.org> +Trevor Parscal <trevor@wikimedia.org> diff --git a/resources/src/mediawiki.widgets/LICENSE.txt b/resources/src/mediawiki.widgets/LICENSE.txt new file mode 100644 index 00000000..b03ca801 --- /dev/null +++ b/resources/src/mediawiki.widgets/LICENSE.txt @@ -0,0 +1,25 @@ +Copyright (c) 2011-2015 MediaWiki Widgets Team and others under the +terms of The MIT License (MIT), as follows: + +This software consists of voluntary contributions made by many +individuals (AUTHORS.txt) For exact contribution history, see the +revision history and logs, available at https://gerrit.wikimedia.org + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js new file mode 100644 index 00000000..af83c5f2 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js @@ -0,0 +1,558 @@ +/*! + * MediaWiki Widgets – CalendarWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +/*global moment */ +( function ( $, mw ) { + + /** + * Creates an mw.widgets.CalendarWidget object. + * + * You will most likely want to use mw.widgets.DateInputWidget instead of CalendarWidget directly. + * + * @class + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.TabIndexedElement + * @mixins OO.ui.mixin.FloatableElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month' + * @cfg {string|null} [date=null] Day or month date (depending on `precision`), in the format + * 'YYYY-MM-DD' or 'YYYY-MM'. When null, the calendar will show today's date, but not select + * it. + */ + mw.widgets.CalendarWidget = function MWWCalendarWidget( config ) { + // Config initialization + config = config || {}; + + // Parent constructor + mw.widgets.CalendarWidget.parent.call( this, config ); + + // Mixin constructors + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) ); + OO.ui.mixin.FloatableElement.call( this, config ); + + // Properties + this.precision = config.precision || 'day'; + // Currently selected date (day or month) + this.date = null; + // Current UI state (date and precision we're displaying right now) + this.moment = null; + this.displayLayer = this.getDisplayLayers()[ 0 ]; // 'month', 'year', 'duodecade' + + this.$header = $( '<div>' ).addClass( 'mw-widget-calendarWidget-header' ); + this.$bodyOuterWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-outer-wrapper' ); + this.$bodyWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-wrapper' ); + this.$body = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body' ); + this.labelButton = new OO.ui.ButtonWidget( { + tabIndex: -1, + label: '', + framed: false, + classes: [ 'mw-widget-calendarWidget-labelButton' ] + } ); + this.upButton = new OO.ui.ButtonWidget( { + tabIndex: -1, + framed: false, + icon: 'collapse', + classes: [ 'mw-widget-calendarWidget-upButton' ] + } ); + this.prevButton = new OO.ui.ButtonWidget( { + tabIndex: -1, + framed: false, + icon: 'previous', + classes: [ 'mw-widget-calendarWidget-prevButton' ] + } ); + this.nextButton = new OO.ui.ButtonWidget( { + tabIndex: -1, + framed: false, + icon: 'next', + classes: [ 'mw-widget-calendarWidget-nextButton' ] + } ); + + // Events + this.labelButton.connect( this, { click: 'onUpButtonClick' } ); + this.upButton.connect( this, { click: 'onUpButtonClick' } ); + this.prevButton.connect( this, { click: 'onPrevButtonClick' } ); + this.nextButton.connect( this, { click: 'onNextButtonClick' } ); + this.$element.on( { + focus: this.onFocus.bind( this ), + mousedown: this.onClick.bind( this ), + keydown: this.onKeyDown.bind( this ) + } ); + + // Initialization + this.$element + .addClass( 'mw-widget-calendarWidget' ) + .append( this.$header, this.$bodyOuterWrapper.append( this.$bodyWrapper.append( this.$body ) ) ); + this.$header.append( + this.prevButton.$element, + this.nextButton.$element, + this.upButton.$element, + this.labelButton.$element + ); + this.setDate( config.date !== undefined ? config.date : null ); + }; + + /* Inheritance */ + + OO.inheritClass( mw.widgets.CalendarWidget, OO.ui.Widget ); + OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.TabIndexedElement ); + OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.FloatableElement ); + + /* Events */ + + /** + * @event change + * + * A change event is emitted when the chosen date changes. + * + * @param {string} date Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM' + */ + + /* Methods */ + + /** + * Get the date format ('YYYY-MM-DD' or 'YYYY-MM', depending on precision), which is used + * internally and for dates accepted by #setDate and returned by #getDate. + * + * @private + * @returns {string} Format + */ + mw.widgets.CalendarWidget.prototype.getDateFormat = function () { + return { + day: 'YYYY-MM-DD', + month: 'YYYY-MM' + }[ this.precision ]; + }; + + /** + * Get the date precision this calendar uses, 'day' or 'month'. + * + * @private + * @returns {string} Precision, 'day' or 'month' + */ + mw.widgets.CalendarWidget.prototype.getPrecision = function () { + return this.precision; + }; + + /** + * Get list of possible display layers. + * + * @private + * @returns {string[]} Layers + */ + mw.widgets.CalendarWidget.prototype.getDisplayLayers = function () { + return [ 'month', 'year', 'duodecade' ].slice( this.precision === 'month' ? 1 : 0 ); + }; + + /** + * Update the calendar. + * + * @private + * @param {string|null} [fade=null] Direction in which to fade out current calendar contents, + * 'previous', 'next', 'up' or 'down'; or 'auto', which has the same result as 'previous' or + * 'next' depending on whether the current date is later or earlier than the previous. + * @returns {string} Format + */ + mw.widgets.CalendarWidget.prototype.updateUI = function ( fade ) { + var items, today, selected, currentMonth, currentYear, currentDay, i, needsFade, + $bodyWrapper = this.$bodyWrapper; + + if ( + this.displayLayer === this.previousDisplayLayer && + this.date === this.previousDate && + this.previousMoment && + this.previousMoment.isSame( this.moment, this.precision === 'month' ? 'month' : 'day' ) + ) { + // Already displayed + return; + } + + if ( fade === 'auto' ) { + if ( !this.previousMoment ) { + fade = null; + } else if ( this.previousMoment.isBefore( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) { + fade = 'next'; + } else if ( this.previousMoment.isAfter( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) { + fade = 'previous'; + } else { + fade = null; + } + } + + items = []; + if ( this.$oldBody ) { + this.$oldBody.remove(); + } + this.$oldBody = this.$body.addClass( 'mw-widget-calendarWidget-old-body' ); + // Clone without children + this.$body = $( this.$body[ 0 ].cloneNode( false ) ) + .removeClass( 'mw-widget-calendarWidget-old-body' ) + .toggleClass( 'mw-widget-calendarWidget-body-month', this.displayLayer === 'month' ) + .toggleClass( 'mw-widget-calendarWidget-body-year', this.displayLayer === 'year' ) + .toggleClass( 'mw-widget-calendarWidget-body-duodecade', this.displayLayer === 'duodecade' ); + + today = moment(); + selected = moment( this.getDate(), this.getDateFormat() ); + + switch ( this.displayLayer ) { + case 'month': + this.labelButton.setLabel( this.moment.format( 'MMMM YYYY' ) ); + this.upButton.toggle( true ); + + // First week displayed is the first week spanned by the month, unless it begins on Monday, in + // which case first week displayed is the previous week. This makes the calendar "balanced" + // and also neatly handles 28-day February sometimes spanning only 4 weeks. + currentDay = moment( this.moment ).startOf( 'month' ).subtract( 1, 'day' ).startOf( 'week' ); + + // Day-of-week labels. Localisation-independent: works with weeks starting on Saturday, Sunday + // or Monday. + for ( i = 0; i < 7; i++ ) { + items.push( + $( '<div>' ) + .addClass( 'mw-widget-calendarWidget-day-heading' ) + .text( currentDay.format( 'dd' ) ) + ); + currentDay.add( 1, 'day' ); + } + currentDay.subtract( 7, 'days' ); + + // Actual calendar month. Always displays 6 weeks, for consistency (months can span 4 to 6 + // weeks). + for ( i = 0; i < 42; i++ ) { + items.push( + $( '<div>' ) + .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-day' ) + .toggleClass( 'mw-widget-calendarWidget-day-additional', !currentDay.isSame( this.moment, 'month' ) ) + .toggleClass( 'mw-widget-calendarWidget-day-today', currentDay.isSame( today, 'day' ) ) + .toggleClass( 'mw-widget-calendarWidget-item-selected', currentDay.isSame( selected, 'day' ) ) + .text( currentDay.format( 'D' ) ) + .data( 'date', currentDay.date() ) + .data( 'month', currentDay.month() ) + .data( 'year', currentDay.year() ) + ); + currentDay.add( 1, 'day' ); + } + break; + + case 'year': + this.labelButton.setLabel( this.moment.format( 'YYYY' ) ); + this.upButton.toggle( true ); + + currentMonth = moment( this.moment ).startOf( 'year' ); + for ( i = 0; i < 12; i++ ) { + items.push( + $( '<div>' ) + .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-month' ) + .toggleClass( 'mw-widget-calendarWidget-item-selected', currentMonth.isSame( selected, 'month' ) ) + .text( currentMonth.format( 'MMMM' ) ) + .data( 'month', currentMonth.month() ) + ); + currentMonth.add( 1, 'month' ); + } + // Shuffle the array to display months in columns rather than rows. + items = [ + items[ 0 ], items[ 6 ], // | January | July | + items[ 1 ], items[ 7 ], // | February | August | + items[ 2 ], items[ 8 ], // | March | September | + items[ 3 ], items[ 9 ], // | April | October | + items[ 4 ], items[ 10 ], // | May | November | + items[ 5 ], items[ 11 ] // | June | December | + ]; + break; + + case 'duodecade': + this.labelButton.setLabel( null ); + this.upButton.toggle( false ); + + currentYear = moment( { year: Math.floor( this.moment.year() / 20 ) * 20 } ); + for ( i = 0; i < 20; i++ ) { + items.push( + $( '<div>' ) + .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-year' ) + .toggleClass( 'mw-widget-calendarWidget-item-selected', currentYear.isSame( selected, 'year' ) ) + .text( currentYear.format( 'YYYY' ) ) + .data( 'year', currentYear.year() ) + ); + currentYear.add( 1, 'year' ); + } + break; + } + + this.$body.append.apply( this.$body, items ); + + $bodyWrapper + .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-up' ) + .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-down' ) + .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-previous' ) + .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-next' ); + + needsFade = this.previousDisplayLayer !== this.displayLayer; + if ( this.displayLayer === 'month' ) { + needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'month' ); + } else if ( this.displayLayer === 'year' ) { + needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'year' ); + } else if ( this.displayLayer === 'duodecade' ) { + needsFade = needsFade || ( + Math.floor( this.moment.year() / 20 ) * 20 !== + Math.floor( this.previousMoment.year() / 20 ) * 20 + ); + } + + if ( fade && needsFade ) { + this.$oldBody.find( '.mw-widget-calendarWidget-item-selected' ) + .removeClass( 'mw-widget-calendarWidget-item-selected' ); + if ( fade === 'previous' || fade === 'up' ) { + this.$body.insertBefore( this.$oldBody ); + } else if ( fade === 'next' || fade === 'down' ) { + this.$body.insertAfter( this.$oldBody ); + } + setTimeout( function () { + $bodyWrapper.addClass( 'mw-widget-calendarWidget-body-wrapper-fade-' + fade ); + }.bind( this ), 0 ); + } else { + this.$oldBody.replaceWith( this.$body ); + } + + this.previousMoment = moment( this.moment ); + this.previousDisplayLayer = this.displayLayer; + this.previousDate = this.date; + + this.$body.on( 'click', this.onBodyClick.bind( this ) ); + }; + + /** + * Handle click events on the "up" button, switching to less precise view. + * + * @private + */ + mw.widgets.CalendarWidget.prototype.onUpButtonClick = function () { + var + layers = this.getDisplayLayers(), + currentLayer = layers.indexOf( this.displayLayer ); + if ( currentLayer !== layers.length - 1 ) { + // One layer up + this.displayLayer = layers[ currentLayer + 1 ]; + this.updateUI( 'up' ); + } else { + this.updateUI(); + } + }; + + /** + * Handle click events on the "previous" button, switching to previous pane. + * + * @private + */ + mw.widgets.CalendarWidget.prototype.onPrevButtonClick = function () { + switch ( this.displayLayer ) { + case 'month': + this.moment.subtract( 1, 'month' ); + break; + case 'year': + this.moment.subtract( 1, 'year' ); + break; + case 'duodecade': + this.moment.subtract( 20, 'years' ); + break; + } + this.updateUI( 'previous' ); + }; + + /** + * Handle click events on the "next" button, switching to next pane. + * + * @private + */ + mw.widgets.CalendarWidget.prototype.onNextButtonClick = function () { + switch ( this.displayLayer ) { + case 'month': + this.moment.add( 1, 'month' ); + break; + case 'year': + this.moment.add( 1, 'year' ); + break; + case 'duodecade': + this.moment.add( 20, 'years' ); + break; + } + this.updateUI( 'next' ); + }; + + /** + * Handle click events anywhere in the body of the widget, which contains the matrix of days, + * months or years to choose. Maybe change the pane or switch to more precise view, depending on + * what gets clicked. + * + * @private + */ + mw.widgets.CalendarWidget.prototype.onBodyClick = function ( e ) { + var + $target = $( e.target ), + layers = this.getDisplayLayers(), + currentLayer = layers.indexOf( this.displayLayer ); + if ( $target.data( 'year' ) !== undefined ) { + this.moment.year( $target.data( 'year' ) ); + } + if ( $target.data( 'month' ) !== undefined ) { + this.moment.month( $target.data( 'month' ) ); + } + if ( $target.data( 'date' ) !== undefined ) { + this.moment.date( $target.data( 'date' ) ); + } + if ( currentLayer === 0 ) { + this.setDateFromMoment(); + this.updateUI( 'auto' ); + } else { + // One layer down + this.displayLayer = layers[ currentLayer - 1 ]; + this.updateUI( 'down' ); + } + }; + + /** + * Set the date. + * + * @param {string|null} [date=null] Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'. + * When null, the calendar will show today's date, but not select it. When invalid, the date + * is not changed. + */ + mw.widgets.CalendarWidget.prototype.setDate = function ( date ) { + var mom = date !== null ? moment( date, this.getDateFormat() ) : moment(); + if ( mom.isValid() ) { + this.moment = mom; + if ( date !== null ) { + this.setDateFromMoment(); + } else if ( this.date !== null ) { + this.date = null; + this.emit( 'change', this.date ); + } + this.displayLayer = this.getDisplayLayers()[ 0 ]; + this.updateUI(); + } + }; + + /** + * Reset the user interface of this widget to reflect selected date. + */ + mw.widgets.CalendarWidget.prototype.resetUI = function () { + this.moment = this.getDate() !== null ? moment( this.getDate(), this.getDateFormat() ) : moment(); + this.displayLayer = this.getDisplayLayers()[ 0 ]; + this.updateUI(); + }; + + /** + * Set the date from moment object. + * + * @private + */ + mw.widgets.CalendarWidget.prototype.setDateFromMoment = function () { + // Switch to English locale to avoid number formatting. We want the internal value to be + // '2015-07-24' and not '٢٠١٥-٠٧-٢٤' even if the UI language is Arabic. + var newDate = moment( this.moment ).locale( 'en' ).format( this.getDateFormat() ); + if ( this.date !== newDate ) { + this.date = newDate; + this.emit( 'change', this.date ); + } + }; + + /** + * Get current date, in the format 'YYYY-MM-DD' or 'YYYY-MM', depending on precision. Digits will + * not be localised. + * + * @returns {string|null} Date string + */ + mw.widgets.CalendarWidget.prototype.getDate = function () { + return this.date; + }; + + /** + * Handle focus events. + * + * @private + */ + mw.widgets.CalendarWidget.prototype.onFocus = function () { + this.displayLayer = this.getDisplayLayers()[ 0 ]; + this.updateUI( 'down' ); + }; + + /** + * Handle mouse click events. + * + * @private + * @param {jQuery.Event} e Mouse click event + */ + mw.widgets.CalendarWidget.prototype.onClick = function ( e ) { + if ( !this.isDisabled() && e.which === 1 ) { + // Prevent unintended focussing + return false; + } + }; + + /** + * Handle key down events. + * + * @private + * @param {jQuery.Event} e Key down event + */ + mw.widgets.CalendarWidget.prototype.onKeyDown = function ( e ) { + var + /*jshint -W024*/ + dir = OO.ui.Element.static.getDir( this.$element ), + /*jshint +W024*/ + nextDirectionKey = dir === 'ltr' ? OO.ui.Keys.RIGHT : OO.ui.Keys.LEFT, + prevDirectionKey = dir === 'ltr' ? OO.ui.Keys.LEFT : OO.ui.Keys.RIGHT, + changed = true; + + if ( !this.isDisabled() ) { + switch ( e.which ) { + case prevDirectionKey: + this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'day' ); + break; + case nextDirectionKey: + this.moment.add( 1, this.precision === 'month' ? 'month' : 'day' ); + break; + case OO.ui.Keys.UP: + this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'week' ); + break; + case OO.ui.Keys.DOWN: + this.moment.add( 1, this.precision === 'month' ? 'month' : 'week' ); + break; + case OO.ui.Keys.PAGEUP: + this.moment.subtract( 1, this.precision === 'month' ? 'year' : 'month' ); + break; + case OO.ui.Keys.PAGEDOWN: + this.moment.add( 1, this.precision === 'month' ? 'year' : 'month' ); + break; + default: + changed = false; + break; + } + + if ( changed ) { + this.displayLayer = this.getDisplayLayers()[ 0 ]; + this.setDateFromMoment(); + this.updateUI( 'auto' ); + return false; + } + } + }; + + /** + * @inheritdoc + */ + mw.widgets.CalendarWidget.prototype.toggle = function ( visible ) { + // Parent method + mw.widgets.CalendarWidget.parent.prototype.toggle.call( this, visible ); + + if ( this.$floatableContainer ) { + this.togglePositioning( this.isVisible() ); + } + + return this; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less new file mode 100644 index 00000000..9d30eb8a --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less @@ -0,0 +1,243 @@ +/*! + * MediaWiki Widgets – CalendarWidget styles. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ + +@calendarWidth: 21em; +@calendarHeight: 14em; + +.mw-widget-calendarWidget { + width: @calendarWidth; +} + +.mw-widget-calendarWidget-header { + position: relative; + line-height: 2.5em; +} + +.mw-widget-calendarWidget-header .oo-ui-buttonWidget { + margin-right: 0; +} + +.mw-widget-calendarWidget-header .mw-widget-calendarWidget-labelButton { + margin: 0 auto; + display: block; + width: @calendarWidth - 2*3em; + + .oo-ui-buttonElement-button { + width: @calendarWidth - 2*3em; + text-align: center; + } +} + +.mw-widget-calendarWidget-upButton { + position: absolute; + right: 3em; +} + +.mw-widget-calendarWidget-prevButton { + float: left; +} + +.mw-widget-calendarWidget-nextButton { + float: right; +} + +.mw-widget-calendarWidget-body-outer-wrapper { + clear: both; + position: relative; + overflow: hidden; + // Fit 7 days, 3em each + width: @calendarWidth; + // Fit 6 weeks + heading line, 2em each + height: @calendarHeight; +} + +.mw-widget-calendarWidget-body-wrapper { + .mw-widget-calendarWidget-body { + display: inline-block; + // Fit 7 days, 3em each + width: @calendarWidth; + // Fit 6 weeks + heading line, 2em each + height: @calendarHeight; + } + + .mw-widget-calendarWidget-old-body { + // background: #fdd; + } + + .mw-widget-calendarWidget-body:not(.mw-widget-calendarWidget-old-body):first-child { + margin-top: -@calendarHeight; + margin-left: -@calendarWidth; + } + + .mw-widget-calendarWidget-body:not(.mw-widget-calendarWidget-old-body):last-child { + margin-top: 0; + margin-left: 0; + } +} + +.mw-widget-calendarWidget-body-wrapper-fade-previous { + width: @calendarWidth * 2; + height: @calendarHeight; + + .mw-widget-calendarWidget-body:first-child { + margin-top: 0 !important; + margin-left: 0 !important; + transition: 0.5s margin-left; + } +} + +.mw-widget-calendarWidget-body-wrapper-fade-next { + width: @calendarWidth * 2; + height: @calendarHeight; + + .mw-widget-calendarWidget-body:first-child { + margin-left: -@calendarWidth !important; + margin-top: 0 !important; + transition: 0.5s margin-left; + } +} + +.mw-widget-calendarWidget-body-wrapper-fade-up { + width: @calendarWidth; + height: @calendarHeight * 2; + + .mw-widget-calendarWidget-body { + display: block; + } + + .mw-widget-calendarWidget-body:first-child { + margin-left: 0 !important; + margin-top: 0 !important; + transition: 0.5s margin-top; + } +} + +.mw-widget-calendarWidget-body-wrapper-fade-down { + width: @calendarWidth; + height: @calendarHeight * 2; + + .mw-widget-calendarWidget-body { + display: block; + } + + .mw-widget-calendarWidget-body:first-child { + margin-left: 0 !important; + margin-top: -@calendarHeight !important; + transition: 0.5s margin-top; + } +} + +.mw-widget-calendarWidget-day, +.mw-widget-calendarWidget-day-heading, +.mw-widget-calendarWidget-month, +.mw-widget-calendarWidget-year { + display: inline-block; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.mw-widget-calendarWidget-day, +.mw-widget-calendarWidget-day-heading { + // 7x7 grid + width: @calendarWidth / 7; + line-height: @calendarHeight / 7; + // Don't overlap the hacked-up fake box-shadow border we get when focussed + &:nth-child(7n) { + width: @calendarWidth / 7 - 0.2em; + margin-right: 0.2em; + } + &:nth-child(7n+1) { + width: @calendarWidth / 7 - 0.2em; + margin-left: 0.2em; + } + &:nth-child(42) ~ & { + line-height: @calendarHeight / 7 - 0.2em; + margin-bottom: 0.2em; + } +} + +.mw-widget-calendarWidget-month { + // 2x6 grid + width: @calendarWidth / 2; + line-height: @calendarHeight / 6; + // Don't overlap the hacked-up fake box-shadow border we get when focussed + &:nth-child(2n) { + width: @calendarWidth / 2 - 0.2em; + margin-right: 0.2em; + } + &:nth-child(2n+1) { + width: @calendarWidth / 2 - 0.2em; + margin-left: 0.2em; + } + &:nth-child(10) ~ & { + line-height: @calendarHeight / 6 - 0.2em; + margin-bottom: 0.2em; + } +} + +.mw-widget-calendarWidget-year { + // 5x4 grid + width: @calendarWidth / 5; + line-height: @calendarHeight / 4; + // Don't overlap the hacked-up fake box-shadow border we get when focussed + &:nth-child(5n) { + width: @calendarWidth / 5 - 0.2em; + margin-right: 0.2em; + } + &:nth-child(5n+1) { + width: @calendarWidth / 5 - 0.2em; + margin-left: 0.2em; + } + &:nth-child(15) ~ & { + line-height: @calendarHeight / 4 - 0.2em; + margin-bottom: 0.2em; + } +} + +.mw-widget-calendarWidget-item { + cursor: pointer; +} + +/* Theme-specific */ +.mw-widget-calendarWidget { + box-shadow: inset 0 0 0 1px #ccc; +} + +.mw-widget-calendarWidget:focus { + outline: none; + box-shadow: inset 0 0 0 2px #347bff; +} + +.mw-widget-calendarWidget-day { + color: #444; + border-radius: 0.1em; +} + +.mw-widget-calendarWidget-day-heading { + font-weight: bold; + color: #555; +} + +.mw-widget-calendarWidget-day-additional { + color: #aaa; +} + +.mw-widget-calendarWidget-day-today { + box-shadow: inset 0 0 0 1px #3787fb; +} + +.mw-widget-calendarWidget-item-selected { + background-color: #d8e6fe; + color: #3787fb; +} + +.mw-widget-calendarWidget-item:hover { + background-color: #eee; +} diff --git a/resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js b/resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js new file mode 100644 index 00000000..24b0e72b --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js @@ -0,0 +1,189 @@ +/*! + * MediaWiki Widgets - CategoryCapsuleItemWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + + /** + * @class mw.widgets.PageExistenceCache + * @private + * @param {mw.Api} [api] + */ + function PageExistenceCache( api ) { + this.api = api || new mw.Api(); + this.processExistenceCheckQueueDebounced = OO.ui.debounce( this.processExistenceCheckQueue ); + this.currentRequest = null; + this.existenceCache = {}; + this.existenceCheckQueue = {}; + } + + /** + * Check for existence of pages in the queue. + * + * @private + */ + PageExistenceCache.prototype.processExistenceCheckQueue = function () { + var queue, titles; + if ( this.currentRequest ) { + // Don't fire off a million requests at the same time + this.currentRequest.always( function () { + this.currentRequest = null; + this.processExistenceCheckQueueDebounced(); + }.bind( this ) ); + return; + } + queue = this.existenceCheckQueue; + this.existenceCheckQueue = {}; + titles = Object.keys( queue ).filter( function ( title ) { + if ( this.existenceCache.hasOwnProperty( title ) ) { + queue[ title ].resolve( this.existenceCache[ title ] ); + } + return !this.existenceCache.hasOwnProperty( title ); + }.bind( this ) ); + if ( !titles.length ) { + return; + } + this.currentRequest = this.api.get( { + action: 'query', + prop: [ 'info' ], + titles: titles + } ).done( function ( response ) { + var index, curr, title; + for ( index in response.query.pages ) { + curr = response.query.pages[ index ]; + title = new ForeignTitle( curr.title ).getPrefixedText(); + this.existenceCache[ title ] = curr.missing === undefined; + queue[ title ].resolve( this.existenceCache[ title ] ); + } + }.bind( this ) ); + }; + + /** + * Register a request to check whether a page exists. + * + * @private + * @param {mw.Title} title + * @return {jQuery.Promise} Promise resolved with true if the page exists or false otherwise + */ + PageExistenceCache.prototype.checkPageExistence = function ( title ) { + var key = title.getPrefixedText(); + if ( !this.existenceCheckQueue[ key ] ) { + this.existenceCheckQueue[ key ] = $.Deferred(); + } + this.processExistenceCheckQueueDebounced(); + return this.existenceCheckQueue[ key ].promise(); + }; + + /** + * @class mw.widgets.ForeignTitle + * @private + * @extends mw.Title + * + * @constructor + * @inheritdoc + */ + function ForeignTitle() { + ForeignTitle.parent.apply( this, arguments ); + } + OO.inheritClass( ForeignTitle, mw.Title ); + ForeignTitle.prototype.getNamespacePrefix = function () { + // We only need to handle categories here... + return 'Category:'; // HACK + }; + + /** + * @class mw.widgets.CategoryCapsuleItemWidget + * + * Category selector capsule item widget. Extends OO.ui.CapsuleItemWidget with the ability to link + * to the given page, and to show its existence status (i.e., whether it is a redlink). + * + * @uses mw.Api + * @extends OO.ui.CapsuleItemWidget + * + * @constructor + * @param {Object} config Configuration options + * @cfg {mw.Title} title Page title to use (required) + * @cfg {string} [apiUrl] API URL, if not the current wiki's API + */ + mw.widgets.CategoryCapsuleItemWidget = function MWWCategoryCapsuleItemWidget( config ) { + // Parent constructor + mw.widgets.CategoryCapsuleItemWidget.parent.call( this, $.extend( { + data: config.title.getMainText(), + label: config.title.getMainText() + }, config ) ); + + // Properties + this.title = config.title; + this.apiUrl = config.apiUrl || ''; + this.$link = $( '<a>' ) + .text( this.label ) + .attr( 'target', '_blank' ) + .on( 'click', function ( e ) { + // CapsuleMultiSelectWidget really wants to prevent you from clicking the link, don't let it + e.stopPropagation(); + } ); + + // Initialize + this.setMissing( false ); + this.$label.replaceWith( this.$link ); + this.setLabelElement( this.$link ); + + /*jshint -W024*/ + if ( !this.constructor.static.pageExistenceCaches[ this.apiUrl ] ) { + this.constructor.static.pageExistenceCaches[ this.apiUrl ] = + new PageExistenceCache( new mw.ForeignApi( this.apiUrl ) ); + } + this.constructor.static.pageExistenceCaches[ this.apiUrl ] + .checkPageExistence( new ForeignTitle( this.title.getPrefixedText() ) ) + .done( function ( exists ) { + this.setMissing( !exists ); + }.bind( this ) ); + /*jshint +W024*/ + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.CategoryCapsuleItemWidget, OO.ui.CapsuleItemWidget ); + + /* Static Properties */ + + /*jshint -W024*/ + /** + * Map of API URLs to PageExistenceCache objects. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.CategoryCapsuleItemWidget.static.pageExistenceCaches = { + '': new PageExistenceCache() + }; + /*jshint +W024*/ + + /* Methods */ + + /** + * Update label link href and CSS classes to reflect page existence status. + * + * @private + * @param {boolean} missing Whether the page is missing (does not exist) + */ + mw.widgets.CategoryCapsuleItemWidget.prototype.setMissing = function ( missing ) { + var + title = new ForeignTitle( this.title.getPrefixedText() ), // HACK + prefix = this.apiUrl.replace( '/w/api.php', '' ); // HACK + + if ( !missing ) { + this.$link + .attr( 'href', prefix + title.getUrl() ) + .removeClass( 'new' ); + } else { + this.$link + .attr( 'href', prefix + title.getUrl( { action: 'edit', redlink: 1 } ) ) + .addClass( 'new' ); + } + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js b/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js new file mode 100644 index 00000000..59f1d507 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js @@ -0,0 +1,378 @@ +/*! + * MediaWiki Widgets - CategorySelector class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + var CSP, + NS_CATEGORY = mw.config.get( 'wgNamespaceIds' ).category; + + /** + * Category selector widget. Displays an OO.ui.CapsuleMultiSelectWidget + * and autocompletes with available categories. + * + * var selector = new mw.widgets.CategorySelector( { + * searchTypes: [ + * mw.widgets.CategorySelector.SearchType.OpenSearch, + * mw.widgets.CategorySelector.SearchType.InternalSearch + * ] + * } ); + * + * $( '#content' ).append( selector.$element ); + * + * selector.setSearchType( [ mw.widgets.CategorySelector.SearchType.SubCategories ] ); + * + * @class mw.widgets.CategorySelector + * @uses mw.Api + * @extends OO.ui.CapsuleMultiSelectWidget + * @mixins OO.ui.mixin.PendingElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {mw.Api} [api] Instance of mw.Api (or subclass thereof) to use for queries + * @cfg {number} [limit=10] Maximum number of results to load + * @cfg {mw.widgets.CategorySelector.SearchType[]} [searchTypes=[mw.widgets.CategorySelector.SearchType.OpenSearch]] + * Default search API to use when searching. + */ + function CategorySelector( config ) { + // Config initialization + config = $.extend( { + limit: 10, + searchTypes: [ CategorySelector.SearchType.OpenSearch ] + }, config ); + this.limit = config.limit; + this.searchTypes = config.searchTypes; + this.validateSearchTypes(); + + // Parent constructor + mw.widgets.CategorySelector.parent.call( this, $.extend( true, {}, config, { + menu: { + filterFromInput: false + }, + // This allows the user to both select non-existent categories, and prevents the selector from + // being wiped from #onMenuItemsChange when we change the available options in the dropdown + allowArbitrary: true + } ) ); + + // Mixin constructors + OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) ); + + // Event handler to call the autocomplete methods + this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) ); + + // Initialize + this.api = config.api || new mw.Api(); + } + + /* Setup */ + + OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiSelectWidget ); + OO.mixinClass( CategorySelector, OO.ui.mixin.PendingElement ); + CSP = CategorySelector.prototype; + + /* Methods */ + + /** + * Gets new items based on the input by calling + * {@link #getNewMenuItems getNewItems} and updates the menu + * after removing duplicates based on the data value. + * + * @private + * @method + */ + CSP.updateMenuItems = function () { + this.getMenu().clearItems(); + this.getNewMenuItems( this.$input.val() ).then( function ( items ) { + var existingItems, filteredItems, + menu = this.getMenu(); + + // Never show the menu if the input lost focus in the meantime + if ( !this.$input.is( ':focus' ) ) { + return; + } + + // Array of strings of the data of OO.ui.MenuOptionsWidgets + existingItems = menu.getItems().map( function ( item ) { + return item.data; + } ); + + // Remove if items' data already exists + filteredItems = items.filter( function ( item ) { + return existingItems.indexOf( item ) === -1; + } ); + + // Map to an array of OO.ui.MenuOptionWidgets + filteredItems = filteredItems.map( function ( item ) { + return new OO.ui.MenuOptionWidget( { + data: item, + label: item + } ); + } ); + + menu.addItems( filteredItems ).toggle( true ); + }.bind( this ) ); + }; + + /** + * @inheritdoc + */ + CSP.clearInput = function () { + CategorySelector.parent.prototype.clearInput.call( this ); + // Abort all pending requests, we won't need their results + this.api.abort(); + }; + + /** + * Searches for categories based on the input. + * + * @private + * @method + * @param {string} input The input used to prefix search categories + * @return {jQuery.Promise} Resolves with an array of categories + */ + CSP.getNewMenuItems = function ( input ) { + var i, + promises = [], + deferred = new $.Deferred(); + + if ( $.trim( input ) === '' ) { + deferred.resolve( [] ); + return deferred.promise(); + } + + // Abort all pending requests, we won't need their results + this.api.abort(); + for ( i = 0; i < this.searchTypes.length; i++ ) { + promises.push( this.searchCategories( input, this.searchTypes[ i ] ) ); + } + + this.pushPending(); + + $.when.apply( $, promises ).done( function () { + var categories, categoryNames, + allData = [], + dataSets = Array.prototype.slice.apply( arguments ); + + // Collect values from all results + allData = allData.concat.apply( allData, dataSets ); + + // Remove duplicates + categories = allData.filter( function ( value, index, self ) { + return self.indexOf( value ) === index; + } ); + + // Get titles + categoryNames = categories.map( function ( name ) { + return mw.Title.newFromText( name, NS_CATEGORY ).getMainText(); + } ); + + deferred.resolve( categoryNames ); + + } ).always( this.popPending.bind( this ) ); + + return deferred.promise(); + }; + + /** + * @inheritdoc + */ + CSP.createItemWidget = function ( data ) { + return new mw.widgets.CategoryCapsuleItemWidget( { + apiUrl: this.api.apiUrl || undefined, + title: mw.Title.newFromText( data, NS_CATEGORY ) + } ); + }; + + /** + * Validates the values in `this.searchType`. + * + * @private + * @return {boolean} + */ + CSP.validateSearchTypes = function () { + var validSearchTypes = false, + searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length; + + // Check if all values are in the SearchType enum + validSearchTypes = this.searchTypes.every( function ( searchType ) { + return searchType > -1 && searchType < searchTypeEnumCount; + } ); + + if ( validSearchTypes === false ) { + throw new Error( 'Unknown searchType in searchTypes' ); + } + + // If the searchTypes has CategorySelector.SearchType.SubCategories + // it can be the only search type. + if ( this.searchTypes.indexOf( CategorySelector.SearchType.SubCategories ) > -1 && + this.searchTypes.length > 1 + ) { + throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' ); + } + + // If the searchTypes has CategorySelector.SearchType.ParentCategories + // it can be the only search type. + if ( this.searchTypes.indexOf( CategorySelector.SearchType.ParentCategories ) > -1 && + this.searchTypes.length > 1 + ) { + throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' ); + } + + return true; + }; + + /** + * Sets and validates the value of `this.searchType`. + * + * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes + */ + CSP.setSearchTypes = function ( searchTypes ) { + this.searchTypes = searchTypes; + this.validateSearchTypes(); + }; + + /** + * Searches categories based on input and searchType. + * + * @private + * @method + * @param {string} input The input used to prefix search categories + * @param {mw.widgets.CategorySelector.SearchType} searchType + * @return {jQuery.Promise} Resolves with an array of categories + */ + CSP.searchCategories = function ( input, searchType ) { + var deferred = new $.Deferred(); + + switch ( searchType ) { + case CategorySelector.SearchType.OpenSearch: + this.api.get( { + action: 'opensearch', + namespace: NS_CATEGORY, + limit: this.limit, + search: input + } ).done( function ( res ) { + var categories = res[ 1 ]; + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + case CategorySelector.SearchType.InternalSearch: + this.api.get( { + action: 'query', + list: 'allpages', + apnamespace: NS_CATEGORY, + aplimit: this.limit, + apfrom: input, + apprefix: input + } ).done( function ( res ) { + var categories = res.query.allpages.map( function ( page ) { + return page.title; + } ); + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + case CategorySelector.SearchType.Exists: + if ( input.indexOf( '|' ) > -1 ) { + deferred.resolve( [] ); + break; + } + + this.api.get( { + action: 'query', + prop: 'info', + titles: 'Category:' + input + } ).done( function ( res ) { + var page, + categories = []; + + for ( page in res.query.pages ) { + if ( parseInt( page, 10 ) > -1 ) { + categories.push( res.query.pages[ page ].title ); + } + } + + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + case CategorySelector.SearchType.SubCategories: + if ( input.indexOf( '|' ) > -1 ) { + deferred.resolve( [] ); + break; + } + + this.api.get( { + action: 'query', + list: 'categorymembers', + cmtype: 'subcat', + cmlimit: this.limit, + cmtitle: 'Category:' + input + } ).done( function ( res ) { + var categories = res.query.categorymembers.map( function ( category ) { + return category.title; + } ); + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + case CategorySelector.SearchType.ParentCategories: + if ( input.indexOf( '|' ) > -1 ) { + deferred.resolve( [] ); + break; + } + + this.api.get( { + action: 'query', + prop: 'categories', + cllimit: this.limit, + titles: 'Category:' + input + } ).done( function ( res ) { + var page, + categories = []; + + for ( page in res.query.pages ) { + if ( parseInt( page, 10 ) > -1 ) { + if ( $.isArray( res.query.pages[ page ].categories ) ) { + categories.push.apply( categories, res.query.pages[ page ].categories.map( function ( category ) { + return category.title; + } ) ); + } + } + } + + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + default: + throw new Error( 'Unknown searchType' ); + } + + return deferred.promise(); + }; + + /** + * @enum mw.widgets.CategorySelector.SearchType + * Types of search available. + */ + CategorySelector.SearchType = { + /** Search using action=opensearch */ + OpenSearch: 0, + + /** Search using action=query */ + InternalSearch: 1, + + /** Search for existing categories with the exact title */ + Exists: 2, + + /** Search only subcategories */ + SubCategories: 3, + + /** Search only parent categories */ + ParentCategories: 4 + }; + + mw.widgets.CategorySelector = CategorySelector; +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.base.css b/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.base.css new file mode 100644 index 00000000..b60883e9 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.base.css @@ -0,0 +1,26 @@ +/*! + * MediaWiki Widgets - base ComplexNamespaceInputWidget styles. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ + +.mw-widget-complexNamespaceInputWidget .mw-widget-namespaceInputWidget, +.mw-widget-complexNamespaceInputWidget .oo-ui-fieldLayout { + display: inline-block; + margin-right: 1em; +} + +/* TODO FieldLayout is not supposed to be used the way we use it here */ +.mw-widget-complexNamespaceInputWidget .oo-ui-fieldLayout { + vertical-align: middle; + margin-bottom: 0; +} + +.mw-widget-complexNamespaceInputWidget .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label { + padding-left: 0.5em; +} + +.mw-widget-complexNamespaceInputWidget .mw-widget-namespaceInputWidget { + max-width: 20em; +} diff --git a/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.js new file mode 100644 index 00000000..f67ed3de --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.js @@ -0,0 +1,118 @@ +/*! + * MediaWiki Widgets - ComplexNamespaceInputWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + + /** + * Namespace input widget. Displays a dropdown box with the choice of available namespaces, plus + * two checkboxes to include associated namespace or to invert selection. + * + * @class + * @extends OO.ui.Widget + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object} namespace Configuration for the NamespaceInputWidget dropdown with list + * of namespaces + * @cfg {string} namespace.includeAllValue If specified, add a "all namespaces" + * option to the dropdown, and use this as the input value for it + * @cfg {Object} invert Configuration for the "invert selection" CheckboxInputWidget. If + * null, the checkbox will not be generated. + * @cfg {Object} associated Configuration for the "include associated namespace" + * CheckboxInputWidget. If null, the checkbox will not be generated. + * @cfg {Object} invertLabel Configuration for the FieldLayout with label wrapping the + * "invert selection" checkbox + * @cfg {string} invertLabel.label Label text for the label + * @cfg {Object} associatedLabel Configuration for the FieldLayout with label wrapping + * the "include associated namespace" checkbox + * @cfg {string} associatedLabel.label Label text for the label + */ + mw.widgets.ComplexNamespaceInputWidget = function MwWidgetsComplexNamespaceInputWidget( config ) { + // Configuration initialization + config = $.extend( + { + // Config options for nested widgets + namespace: {}, + invert: {}, + invertLabel: {}, + associated: {}, + associatedLabel: {} + }, + config + ); + + // Parent constructor + mw.widgets.ComplexNamespaceInputWidget.parent.call( this, config ); + + // Properties + this.config = config; + + this.namespace = new mw.widgets.NamespaceInputWidget( config.namespace ); + if ( config.associated !== null ) { + this.associated = new OO.ui.CheckboxInputWidget( $.extend( + { value: '1' }, + config.associated + ) ); + // TODO Should use a LabelWidget? But they don't work like HTML <label>s yet + this.associatedLabel = new OO.ui.FieldLayout( + this.associated, + $.extend( + { align: 'inline' }, + config.associatedLabel + ) + ); + } + if ( config.invert !== null ) { + this.invert = new OO.ui.CheckboxInputWidget( $.extend( + { value: '1' }, + config.invert + ) ); + // TODO Should use a LabelWidget? But they don't work like HTML <label>s yet + this.invertLabel = new OO.ui.FieldLayout( + this.invert, + $.extend( + { align: 'inline' }, + config.invertLabel + ) + ); + } + + // Events + this.namespace.connect( this, { change: 'updateCheckboxesState' } ); + + // Initialization + this.$element + .addClass( 'mw-widget-complexNamespaceInputWidget' ) + .append( + this.namespace.$element, + this.invert ? this.invertLabel.$element : '', + this.associated ? this.associatedLabel.$element : '' + ); + this.updateCheckboxesState(); + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.ComplexNamespaceInputWidget, OO.ui.Widget ); + + /* Methods */ + + /** + * Update the disabled state of checkboxes when the value of namespace dropdown changes. + * + * @private + */ + mw.widgets.ComplexNamespaceInputWidget.prototype.updateCheckboxesState = function () { + var disabled = this.namespace.getValue() === this.namespace.allValue; + if ( this.invert ) { + this.invert.setDisabled( disabled ); + } + if ( this.associated ) { + this.associated.setDisabled( disabled ); + } + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.base.css b/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.base.css new file mode 100644 index 00000000..73a50d8f --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.base.css @@ -0,0 +1,20 @@ +/*! + * MediaWiki Widgets - base ComplexTitleInputWidget styles. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ + +.mw-widget-complexTitleInputWidget .mw-widget-namespaceInputWidget, +.mw-widget-complexTitleInputWidget .mw-widget-titleInputWidget { + display: inline-block; +} + +.mw-widget-complexTitleInputWidget .mw-widget-namespaceInputWidget { + max-width: 20em; + margin-right: 0.5em; +} + +.mw-widget-complexTitleInputWidget .mw-widget-titleInputWidget { + max-width: 29.5em; +} diff --git a/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.js new file mode 100644 index 00000000..0c6c15e4 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.js @@ -0,0 +1,63 @@ +/*! + * MediaWiki Widgets - ComplexTitleInputWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + + /** + * Like TitleInputWidget, but the namespace has to be input through a separate dropdown field. + * + * @class + * @extends OO.ui.Widget + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object} namespace Configuration for the NamespaceInputWidget dropdown with list of + * namespaces + * @cfg {Object} title Configuration for the TitleInputWidget text field + */ + mw.widgets.ComplexTitleInputWidget = function MwWidgetsComplexTitleInputWidget( config ) { + // Parent constructor + mw.widgets.ComplexTitleInputWidget.parent.call( this, config ); + + // Properties + this.namespace = new mw.widgets.NamespaceInputWidget( config.namespace ); + this.title = new mw.widgets.TitleInputWidget( $.extend( + {}, + config.title, + { + relative: true, + namespace: config.namespace.value || null + } + ) ); + + // Events + this.namespace.connect( this, { change: 'updateTitleNamespace' } ); + + // Initialization + this.$element + .addClass( 'mw-widget-complexTitleInputWidget' ) + .append( + this.namespace.$element, + this.title.$element + ); + this.updateTitleNamespace(); + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.ComplexTitleInputWidget, OO.ui.Widget ); + + /* Methods */ + + /** + * Update the namespace to use for search suggestions of the title when the value of namespace + * dropdown changes. + */ + mw.widgets.ComplexTitleInputWidget.prototype.updateTitleNamespace = function () { + this.title.setNamespace( Number( this.namespace.getValue() ) ); + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js new file mode 100644 index 00000000..b1e5151b --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js @@ -0,0 +1,629 @@ +/*! + * MediaWiki Widgets – DateInputWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +/*global moment */ +( function ( $, mw ) { + + /** + * Creates an mw.widgets.DateInputWidget object. + * + * @example + * // Date input widget showcase + * var fieldset = new OO.ui.FieldsetLayout( { + * items: [ + * new OO.ui.FieldLayout( + * new mw.widgets.DateInputWidget(), + * { + * align: 'top', + * label: 'Select date' + * } + * ), + * new OO.ui.FieldLayout( + * new mw.widgets.DateInputWidget( { precision: 'month' } ), + * { + * align: 'top', + * label: 'Select month' + * } + * ), + * new OO.ui.FieldLayout( + * new mw.widgets.DateInputWidget( { + * inputFormat: 'DD.MM.YYYY', + * displayFormat: 'Do [of] MMMM [anno Domini] YYYY' + * } ), + * { + * align: 'top', + * label: 'Select date (custom formats)' + * } + * ) + * ] + * } ); + * $( 'body' ).append( fieldset.$element ); + * + * The value is stored in 'YYYY-MM-DD' or 'YYYY-MM' format: + * + * @example + * // Accessing values in a date input widget + * var dateInput = new mw.widgets.DateInputWidget(); + * var $label = $( '<p>' ); + * $( 'body' ).append( $label, dateInput.$element ); + * dateInput.on( 'change', function () { + * // The value will always be a valid date or empty string, malformed input is ignored + * var date = dateInput.getValue(); + * $label.text( 'Selected date: ' + ( date || '(none)' ) ); + * } ); + * + * @class + * @extends OO.ui.InputWidget + * @mixins OO.ui.mixin.IndicatorElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month' + * @cfg {string} [value] Day or month date (depending on `precision`), in the format 'YYYY-MM-DD' + * or 'YYYY-MM'. If not given or empty string, no date is selected. + * @cfg {string} [inputFormat] Date format string to use for the textual input field. Displayed + * while the widget is active, and the user can type in a date in this format. Should be short + * and easy to type. When not given, defaults to 'YYYY-MM-DD' or 'YYYY-MM', depending on + * `precision`. + * @cfg {string} [displayFormat] Date format string to use for the clickable label. Displayed + * while the widget is inactive. Should be as unambiguous as possible (for example, prefer to + * spell out the month, rather than rely on the order), even if that makes it longer. When not + * given, the default is language-specific. + * @cfg {string} [placeholder] User-visible date format string displayed in the textual input + * field when it's empty. Should be the same as `inputFormat`, but translated to the user's + * language. When not given, defaults to a translated version of 'YYYY-MM-DD' or 'YYYY-MM', + * depending on `precision`. + * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`. + * @cfg {string} [mustBeAfter] Validates the date to be after this. In the 'YYYY-MM-DD' format. + * @cfg {string} [mustBeBefore] Validates the date to be before this. In the 'YYYY-MM-DD' format. + * @cfg {jQuery} [$overlay] Render the calendar into a separate layer. This configuration is + * useful in cases where the expanded calendar is larger than its container. The specified + * overlay layer is usually on top of the container and has a larger area. By default, the + * calendar uses relative positioning. + */ + mw.widgets.DateInputWidget = function MWWDateInputWidget( config ) { + // Config initialization + config = $.extend( { precision: 'day', required: false }, config ); + if ( config.required ) { + if ( config.indicator === undefined ) { + config.indicator = 'required'; + } + } + + var placeholder, mustBeAfter, mustBeBefore; + if ( config.placeholder ) { + placeholder = config.placeholder; + } else if ( config.inputFormat ) { + // We have no way to display a translated placeholder for custom formats + placeholder = ''; + } else { + // Messages: mw-widgets-dateinput-placeholder-day, mw-widgets-dateinput-placeholder-month + placeholder = mw.msg( 'mw-widgets-dateinput-placeholder-' + config.precision ); + } + + // Properties (must be set before parent constructor, which calls #setValue) + this.$handle = $( '<div>' ); + this.label = new OO.ui.LabelWidget(); + this.textInput = new OO.ui.TextInputWidget( { + required: config.required, + placeholder: placeholder, + validate: this.validateDate.bind( this ) + } ); + this.calendar = new mw.widgets.CalendarWidget( { + // Can't pass `$floatableContainer: this.$element` here, the latter is not set yet. + // Instead we call setFloatableContainer() below. + precision: config.precision + } ); + this.inCalendar = 0; + this.inTextInput = 0; + this.inputFormat = config.inputFormat; + this.displayFormat = config.displayFormat; + this.required = config.required; + + // Validate and set min and max dates as properties + mustBeAfter = moment( config.mustBeAfter, 'YYYY-MM-DD' ); + mustBeBefore = moment( config.mustBeBefore, 'YYYY-MM-DD' ); + if ( + config.mustBeAfter !== undefined && + mustBeAfter.isValid() + ) { + this.mustBeAfter = mustBeAfter; + } + + if ( + config.mustBeBefore !== undefined && + mustBeBefore.isValid() + ) { + this.mustBeBefore = mustBeBefore; + } + + // Parent constructor + mw.widgets.DateInputWidget.parent.call( this, config ); + + // Mixin constructors + OO.ui.mixin.IndicatorElement.call( this, config ); + + // Events + this.calendar.connect( this, { + change: 'onCalendarChange' + } ); + this.textInput.connect( this, { + enter: 'onEnter', + change: 'onTextInputChange' + } ); + this.$element.on( { + focusout: this.onBlur.bind( this ) + } ); + this.calendar.$element.on( { + click: this.onCalendarClick.bind( this ), + keypress: this.onCalendarKeyPress.bind( this ) + } ); + this.$handle.on( { + click: this.onClick.bind( this ), + keypress: this.onKeyPress.bind( this ) + } ); + + // Initialization + // Move 'tabindex' from this.$input (which is invisible) to the visible handle + this.setTabIndexedElement( this.$handle ); + this.$handle + .append( this.label.$element, this.$indicator ) + .addClass( 'mw-widget-dateInputWidget-handle' ); + this.calendar.$element + .addClass( 'mw-widget-dateInputWidget-calendar' ); + this.$element + .addClass( 'mw-widget-dateInputWidget' ) + .append( this.$handle, this.textInput.$element, this.calendar.$element ); + + if ( config.$overlay ) { + this.calendar.setFloatableContainer( this.$element ); + config.$overlay.append( this.calendar.$element ); + + // The text input and calendar are not in DOM order, so fix up focus transitions. + this.textInput.$input.on( 'keydown', function ( e ) { + if ( e.which === OO.ui.Keys.TAB ) { + if ( e.shiftKey ) { + // Tabbing backward from text input: normal browser behavior + $.noop(); + } else { + // Tabbing forward from text input: just focus the calendar + this.calendar.$element.focus(); + return false; + } + } + }.bind( this ) ); + this.calendar.$element.on( 'keydown', function ( e ) { + if ( e.which === OO.ui.Keys.TAB ) { + if ( e.shiftKey ) { + // Tabbing backward from calendar: just focus the text input + this.textInput.$input.focus(); + return false; + } else { + // Tabbing forward from calendar: focus the text input, then allow normal browser + // behavior to move focus to next focusable after it + this.textInput.$input.focus(); + } + } + }.bind( this ) ); + } + + // Set handle label and hide stuff + this.updateUI(); + this.textInput.toggle( false ); + this.calendar.toggle( false ); + }; + + /* Inheritance */ + + OO.inheritClass( mw.widgets.DateInputWidget, OO.ui.InputWidget ); + OO.mixinClass( mw.widgets.DateInputWidget, OO.ui.mixin.IndicatorElement ); + + /* Methods */ + + /** + * @inheritdoc + * @protected + */ + mw.widgets.DateInputWidget.prototype.getInputElement = function () { + return $( '<input type="hidden">' ); + }; + + /** + * Respond to calendar date change events. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.onCalendarChange = function () { + this.inCalendar++; + if ( !this.inTextInput ) { + // If this is caused by user typing in the input field, do not set anything. + // The value may be invalid (see #onTextInputChange), but displayable on the calendar. + this.setValue( this.calendar.getDate() ); + } + this.inCalendar--; + }; + + /** + * Respond to text input value change events. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.onTextInputChange = function () { + var mom, + widget = this, + value = this.textInput.getValue(), + valid = this.isValidDate( value ); + this.inTextInput++; + + if ( value === '' ) { + // No date selected + widget.setValue( '' ); + } else if ( valid ) { + // Well-formed date value, parse and set it + mom = moment( value, widget.getInputFormat() ); + // Use English locale to avoid number formatting + widget.setValue( mom.locale( 'en' ).format( widget.getInternalFormat() ) ); + } else { + // Not well-formed, but possibly partial? Try updating the calendar, but do not set the + // internal value. Generally this only makes sense when 'inputFormat' is little-endian (e.g. + // 'YYYY-MM-DD'), but that's hard to check for, and might be difficult to handle the parsing + // right for weird formats. So limit this trick to only when we're using the default + // 'inputFormat', which is the same as the internal format, 'YYYY-MM-DD'. + if ( widget.getInputFormat() === widget.getInternalFormat() ) { + widget.calendar.setDate( widget.textInput.getValue() ); + } + } + widget.inTextInput--; + + }; + + /** + * @inheritdoc + */ + mw.widgets.DateInputWidget.prototype.setValue = function ( value ) { + var oldValue = this.value; + + if ( !moment( value, this.getInternalFormat() ).isValid() ) { + value = ''; + } + + mw.widgets.DateInputWidget.parent.prototype.setValue.call( this, value ); + + if ( this.value !== oldValue ) { + this.updateUI(); + this.setValidityFlag(); + } + + return this; + }; + + /** + * Handle text input and calendar blur events. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.onBlur = function () { + var widget = this; + setTimeout( function () { + var $focussed = $( ':focus' ); + // Deactivate unless the focus moved to something else inside this widget + if ( + !OO.ui.contains( widget.$element[ 0 ], $focussed[ 0 ], true ) && + // Calendar might be in an $overlay + !OO.ui.contains( widget.calendar.$element[ 0 ], $focussed[ 0 ], true ) + ) { + widget.deactivate(); + } + }, 0 ); + }; + + /** + * @inheritdoc + */ + mw.widgets.DateInputWidget.prototype.focus = function () { + this.activate(); + return this; + }; + + /** + * @inheritdoc + */ + mw.widgets.DateInputWidget.prototype.blur = function () { + this.deactivate(); + return this; + }; + + /** + * Update the contents of the label, text input and status of calendar to reflect selected value. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.updateUI = function () { + if ( this.getValue() === '' ) { + this.textInput.setValue( '' ); + this.calendar.setDate( null ); + this.label.setLabel( mw.msg( 'mw-widgets-dateinput-no-date' ) ); + this.$element.addClass( 'mw-widget-dateInputWidget-empty' ); + } else { + if ( !this.inTextInput ) { + this.textInput.setValue( this.getMoment().format( this.getInputFormat() ) ); + } + if ( !this.inCalendar ) { + this.calendar.setDate( this.getValue() ); + } + this.label.setLabel( this.getMoment().format( this.getDisplayFormat() ) ); + this.$element.removeClass( 'mw-widget-dateInputWidget-empty' ); + } + }; + + /** + * Deactivate this input field for data entry. Closes the calendar and hides the text field. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.deactivate = function () { + this.$element.removeClass( 'mw-widget-dateInputWidget-active' ); + this.$handle.show(); + this.textInput.toggle( false ); + this.calendar.toggle( false ); + this.setValidityFlag(); + }; + + /** + * Activate this input field for data entry. Opens the calendar and shows the text field. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.activate = function () { + this.calendar.resetUI(); + this.$element.addClass( 'mw-widget-dateInputWidget-active' ); + this.$handle.hide(); + this.textInput.toggle( true ); + this.calendar.toggle( true ); + + this.textInput.$input.focus(); + }; + + /** + * Get the date format to be used for handle label when the input is inactive. + * + * @private + * @return {string} Format string + */ + mw.widgets.DateInputWidget.prototype.getDisplayFormat = function () { + if ( this.displayFormat !== undefined ) { + return this.displayFormat; + } + + if ( this.calendar.getPrecision() === 'month' ) { + return 'MMMM YYYY'; + } else { + // The formats Moment.js provides: + // * ll: Month name, day of month, year + // * lll: Month name, day of month, year, time + // * llll: Month name, day of month, day of week, year, time + // + // The format we want: + // * ????: Month name, day of month, day of week, year + // + // We try to construct it as 'llll - (lll - ll)' and hope for the best. + // This seems to work well for many languages (maybe even all?). + + var localeData = moment.localeData( moment.locale() ), + llll = localeData.longDateFormat( 'llll' ), + lll = localeData.longDateFormat( 'lll' ), + ll = localeData.longDateFormat( 'll' ), + format = llll.replace( lll.replace( ll, '' ), '' ); + + return format; + } + }; + + /** + * Get the date format to be used for the text field when the input is active. + * + * @private + * @return {string} Format string + */ + mw.widgets.DateInputWidget.prototype.getInputFormat = function () { + if ( this.inputFormat !== undefined ) { + return this.inputFormat; + } + + return { + day: 'YYYY-MM-DD', + month: 'YYYY-MM' + }[ this.calendar.getPrecision() ]; + }; + + /** + * Get the date format to be used internally for the value. This is not configurable in any way, + * and always either 'YYYY-MM-DD' or 'YYYY-MM'. + * + * @private + * @return {string} Format string + */ + mw.widgets.DateInputWidget.prototype.getInternalFormat = function () { + return { + day: 'YYYY-MM-DD', + month: 'YYYY-MM' + }[ this.calendar.getPrecision() ]; + }; + + /** + * Get the Moment object for current value. + * + * @return {Object} Moment object + */ + mw.widgets.DateInputWidget.prototype.getMoment = function () { + return moment( this.getValue(), this.getInternalFormat() ); + }; + + /** + * Handle mouse click events. + * + * @private + * @param {jQuery.Event} e Mouse click event + */ + mw.widgets.DateInputWidget.prototype.onClick = function ( e ) { + if ( !this.isDisabled() && e.which === 1 ) { + this.activate(); + } + return false; + }; + + /** + * Handle key press events. + * + * @private + * @param {jQuery.Event} e Key press event + */ + mw.widgets.DateInputWidget.prototype.onKeyPress = function ( e ) { + if ( !this.isDisabled() && + ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) + ) { + this.activate(); + return false; + } + }; + + /** + * Handle calendar key press events. + * + * @private + * @param {jQuery.Event} e Key press event + */ + mw.widgets.DateInputWidget.prototype.onCalendarKeyPress = function ( e ) { + if ( !this.isDisabled() && e.which === OO.ui.Keys.ENTER ) { + this.deactivate(); + this.$handle.focus(); + return false; + } + }; + + /** + * Handle calendar click events. + * + * @private + * @param {jQuery.Event} e Mouse click event + */ + mw.widgets.DateInputWidget.prototype.onCalendarClick = function ( e ) { + if ( + !this.isDisabled() && + e.which === 1 && + $( e.target ).hasClass( 'mw-widget-calendarWidget-day' ) + ) { + this.deactivate(); + this.$handle.focus(); + return false; + } + }; + + /** + * Handle text input enter events. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.onEnter = function () { + this.deactivate(); + this.$handle.focus(); + }; + + /** + * @private + * @param {string} date Date string, to be valid, must be in 'YYYY-MM-DD' or 'YYYY-MM' format or + * (unless the field is required) empty + * @returns {boolean} + */ + mw.widgets.DateInputWidget.prototype.validateDate = function ( date ) { + var isValid; + if ( date === '' ) { + isValid = !this.required; + } else { + isValid = this.isValidDate( date ) && this.isInRange( date ); + } + return isValid; + }; + + /** + * @private + * @param {string} date Date string, to be valid, must be in 'YYYY-MM-DD' or 'YYYY-MM' format + * @returns {boolean} + */ + mw.widgets.DateInputWidget.prototype.isValidDate = function ( date ) { + // "Half-strict mode": for example, for the format 'YYYY-MM-DD', 2015-1-3 instead of 2015-01-03 + // is okay, but 2015-01 isn't, and neither is 2015-01-foo. Use Moment's "fuzzy" mode and check + // parsing flags for the details (stoled from implementation of moment#isValid). + var + mom = moment( date, this.getInputFormat() ), + flags = mom.parsingFlags(); + + return mom.isValid() && flags.charsLeftOver === 0 && flags.unusedTokens.length === 0; + }; + + /** + * Validates if the date is within the range configured with {@link #cfg-mustBeAfter} + * and {@link #cfg-mustBeBefore}. + * + * @private + * @param {string} date Date string, to be valid, must be empty (no date selected) or in + * 'YYYY-MM-DD' or 'YYYY-MM' format to be valid + * @returns {boolean} + */ + mw.widgets.DateInputWidget.prototype.isInRange = function ( date ) { + var momentDate = moment( date, 'YYYY-MM-DD' ), + isAfter = ( this.mustBeAfter === undefined || momentDate.isAfter( this.mustBeAfter ) ), + isBefore = ( this.mustBeBefore === undefined || momentDate.isBefore( this.mustBeBefore ) ); + + return isAfter && isBefore; + }; + + /** + * Get the validity of current value. + * + * This method returns a promise that resolves if the value is valid and rejects if + * it isn't. Uses {@link #validateDate}. + * + * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not. + */ + mw.widgets.DateInputWidget.prototype.getValidity = function () { + var isValid = this.validateDate( this.getValue() ); + + if ( isValid ) { + return $.Deferred().resolve().promise(); + } else { + return $.Deferred().reject().promise(); + } + }; + + /** + * Sets the 'invalid' flag appropriately. + * + * @param {boolean} [isValid] Optionally override validation result + */ + mw.widgets.DateInputWidget.prototype.setValidityFlag = function ( isValid ) { + var widget = this, + setFlag = function ( valid ) { + if ( !valid ) { + widget.$input.attr( 'aria-invalid', 'true' ); + } else { + widget.$input.removeAttr( 'aria-invalid' ); + } + widget.setFlags( { invalid: !valid } ); + }; + + if ( isValid !== undefined ) { + setFlag( isValid ); + } else { + this.getValidity().then( function () { + setFlag( true ); + }, function () { + setFlag( false ); + } ); + } + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less new file mode 100644 index 00000000..873cca19 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less @@ -0,0 +1,134 @@ +/*! + * MediaWiki Widgets – DateInputWidget styles. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ + +.oo-ui-box-sizing( @type: border-box ) { + -webkit-box-sizing: @type; + -moz-box-sizing: @type; + box-sizing: @type; +} + +.oo-ui-unselectable() { + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.oo-ui-inline-spacing( @spacing, @cancelled-spacing: 0 ) { + margin-right: @spacing; + &:last-child { + margin-right: @cancelled-spacing; + } +} + +@indicator-size: unit(12 / 16 / 0.8, em); + +.mw-widget-dateInputWidget { + display: inline-block; + position: relative; + + &-handle { + width: 100%; + display: inline-block; + cursor: pointer; + position: relative; + + .oo-ui-unselectable(); + .oo-ui-box-sizing(border-box); + + > .oo-ui-indicatorElement-indicator { + display: none; + } + } + + &.oo-ui-indicatorElement .mw-widget-dateInputWidget-handle > .oo-ui-indicatorElement-indicator { + display: block; + position: absolute; + top: 0; + right: 0; + height: 100%; + } + + &.oo-ui-widget-disabled .mw-widget-dateInputWidget-handle { + cursor: default; + } + + &-calendar { + position: absolute; + z-index: 1; + } + + // Theme-specific styles + width: 21em; + margin: 0.25em 0; + + .oo-ui-inline-spacing(0.5em); + + &-handle { + padding: 0.5em 1em; + border: 1px solid #ccc; + border-radius: 0.1em; + line-height: 1.275em; + background-color: white; + } + + &.oo-ui-indicatorElement .mw-widget-dateInputWidget-handle > .oo-ui-indicatorElement-indicator { + width: @indicator-size; + margin: 0 0.775em; + } + + > .oo-ui-textInputWidget input { + padding-left: 1em; + } + + > .oo-ui-textInputWidget { + z-index: 2; + } + + &-calendar { + background-color: white; + margin-top: -2px; + + &:focus { + z-index: 3; + } + } + + &.oo-ui-widget-enabled { + .mw-widget-dateInputWidget-handle:hover { + border-color: #347bff; + } + } + + &.oo-ui-widget-disabled { + .mw-widget-dateInputWidget-handle { + color: #ccc; + text-shadow: 0 1px 1px #fff; + border-color: #ddd; + background-color: #f3f3f3; + + > .oo-ui-indicatorElement-indicator { + opacity: 0.2; + } + } + + } + + &.oo-ui-flaggedElement-invalid { + .mw-widget-dateInputWidget-handle { + border-color: red; + box-shadow: inset 0 0 0 0 red; + } + } + + &-empty { + .mw-widget-dateInputWidget-handle { + color: #ccc; + } + } +} diff --git a/resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js new file mode 100644 index 00000000..4f1b8749 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js @@ -0,0 +1,69 @@ +/*! + * MediaWiki Widgets - NamespaceInputWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + + /** + * Namespace input widget. Displays a dropdown box with the choice of available namespaces. + * + * @class + * @extends OO.ui.DropdownInputWidget + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string|null} [includeAllValue] Value for "all namespaces" option, if any + * @cfg {number[]} [exclude] List of namespace numbers to exclude from the selector + */ + mw.widgets.NamespaceInputWidget = function MwWidgetsNamespaceInputWidget( config ) { + // Configuration initialization + config = $.extend( {}, config, { options: this.getNamespaceDropdownOptions( config ) } ); + + // Parent constructor + mw.widgets.NamespaceInputWidget.parent.call( this, config ); + + // Initialization + this.$element.addClass( 'mw-widget-namespaceInputWidget' ); + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.NamespaceInputWidget, OO.ui.DropdownInputWidget ); + + /* Methods */ + + /** + * @private + */ + mw.widgets.NamespaceInputWidget.prototype.getNamespaceDropdownOptions = function ( config ) { + var options, + exclude = config.exclude || [], + NS_MAIN = 0; + + options = $.map( mw.config.get( 'wgFormattedNamespaces' ), function ( name, ns ) { + if ( ns < NS_MAIN || exclude.indexOf( Number( ns ) ) !== -1 ) { + return null; // skip + } + ns = String( ns ); + if ( ns === String( NS_MAIN ) ) { + name = mw.message( 'blanknamespace' ).text(); + } + return { data: ns, label: name }; + } ).sort( function ( a, b ) { + // wgFormattedNamespaces is an object, and so technically doesn't have to be ordered + return a.data - b.data; + } ); + + if ( config.includeAllValue !== null && config.includeAllValue !== undefined ) { + options.unshift( { + data: config.includeAllValue, + label: mw.message( 'namespacesall' ).text() + } ); + } + + return options; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css new file mode 100644 index 00000000..2c24b2bb --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css @@ -0,0 +1,57 @@ +/*! + * MediaWiki Widgets - TitleInputWidget styles. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ + +.mw-widget-titleInputWidget-menu-withImages .mw-widget-titleOptionWidget { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + min-height: 3.75em; + margin-left: 3.75em; +} + +.mw-widget-titleInputWidget-menu-withImages .mw-widget-titleOptionWidget:not(:last-child) { + margin-bottom: 1px; +} + +.mw-widget-titleInputWidget-menu-withImages .oo-ui-iconElement .oo-ui-iconElement-icon { + display: block; + width: 3.75em; + height: 3.75em; + left: -3.75em; + background-color: #ccc; + opacity: 0.4; +} + +.mw-widget-titleInputWidget-menu-withImages .oo-ui-iconElement .mw-widget-titleOptionWidget-hasImage { + border: 0; + background-size: cover; + opacity: 1; +} + +.mw-widget-titleInputWidget-menu-withImages .mw-widget-titleOptionWidget .oo-ui-labelElement-label { + line-height: 2.8em; +} + +.mw-widget-titleOptionWidget-description { + display: none; +} + +.mw-widget-titleInputWidget-menu-withDescriptions .mw-widget-titleOptionWidget .oo-ui-labelElement-label { + line-height: 1.5em; +} + +.mw-widget-titleInputWidget-menu-withDescriptions .mw-widget-titleOptionWidget-description { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.oo-ui-menuOptionWidget:not(.oo-ui-optionWidget-selected) .mw-widget-titleOptionWidget-description, +.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted .mw-widget-titleOptionWidget-description { + color: #888; +} diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js new file mode 100644 index 00000000..d5a7abc6 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js @@ -0,0 +1,341 @@ +/*! + * MediaWiki Widgets - TitleInputWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + + /** + * Creates an mw.widgets.TitleInputWidget object. + * + * @class + * @extends OO.ui.TextInputWidget + * @mixins OO.ui.mixin.LookupElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {number} [limit=10] Number of results to show + * @cfg {number} [namespace] Namespace to prepend to queries + * @cfg {boolean} [relative=true] If a namespace is set, return a title relative to it + * @cfg {boolean} [suggestions=true] Display search suggestions + * @cfg {boolean} [showRedirectTargets=true] Show the targets of redirects + * @cfg {boolean} [showRedlink] Show red link to exact match if it doesn't exist + * @cfg {boolean} [showImages] Show page images + * @cfg {boolean} [showDescriptions] Show page descriptions + * @cfg {Object} [cache] Result cache which implements a 'set' method, taking keyed values as an argument + */ + mw.widgets.TitleInputWidget = function MwWidgetsTitleInputWidget( config ) { + var widget = this; + + // Config initialization + config = $.extend( { + maxLength: 255, + limit: 10 + }, config ); + + // Parent constructor + mw.widgets.TitleInputWidget.parent.call( this, $.extend( {}, config, { autocomplete: false } ) ); + + // Mixin constructors + OO.ui.mixin.LookupElement.call( this, config ); + + // Properties + this.limit = config.limit; + this.maxLength = config.maxLength; + this.namespace = config.namespace !== undefined ? config.namespace : null; + this.relative = config.relative !== undefined ? config.relative : true; + this.suggestions = config.suggestions !== undefined ? config.suggestions : true; + this.showRedirectTargets = config.showRedirectTargets !== false; + this.showRedlink = !!config.showRedlink; + this.showImages = !!config.showImages; + this.showDescriptions = !!config.showDescriptions; + this.cache = config.cache; + + // Initialization + this.$element.addClass( 'mw-widget-titleInputWidget' ); + this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu' ); + if ( this.showImages ) { + this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu-withImages' ); + } + if ( this.showDescriptions ) { + this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu-withDescriptions' ); + } + this.setLookupsDisabled( !this.suggestions ); + + this.interwikiPrefixes = []; + this.interwikiPrefixesPromise = new mw.Api().get( { + action: 'query', + meta: 'siteinfo', + siprop: 'interwikimap' + } ).done( function ( data ) { + $.each( data.query.interwikimap, function ( index, interwiki ) { + widget.interwikiPrefixes.push( interwiki.prefix ); + } ); + } ); + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.TitleInputWidget, OO.ui.TextInputWidget ); + OO.mixinClass( mw.widgets.TitleInputWidget, OO.ui.mixin.LookupElement ); + + /* Methods */ + + /** + * Get the namespace to prepend to titles in suggestions, if any. + * + * @return {number|null} Namespace number + */ + mw.widgets.TitleInputWidget.prototype.getNamespace = function () { + return this.namespace; + }; + + /** + * Set the namespace to prepend to titles in suggestions, if any. + * + * @param {number|null} namespace Namespace number + */ + mw.widgets.TitleInputWidget.prototype.setNamespace = function ( namespace ) { + this.namespace = namespace; + this.lookupCache = {}; + this.closeLookupMenu(); + }; + + /** + * @inheritdoc + */ + mw.widgets.TitleInputWidget.prototype.onLookupMenuItemChoose = function ( item ) { + this.closeLookupMenu(); + this.setLookupsDisabled( true ); + this.setValue( item.getData() ); + this.setLookupsDisabled( !this.suggestions ); + }; + + /** + * @inheritdoc + */ + mw.widgets.TitleInputWidget.prototype.focus = function () { + var retval; + + // Prevent programmatic focus from opening the menu + this.setLookupsDisabled( true ); + + // Parent method + retval = mw.widgets.TitleInputWidget.parent.prototype.focus.apply( this, arguments ); + + this.setLookupsDisabled( !this.suggestions ); + + return retval; + }; + + /** + * @inheritdoc + */ + mw.widgets.TitleInputWidget.prototype.getLookupRequest = function () { + var req, + widget = this, + promiseAbortObject = { abort: function () { + // Do nothing. This is just so OOUI doesn't break due to abort being undefined. + } }; + + if ( mw.Title.newFromText( this.value ) ) { + return this.interwikiPrefixesPromise.then( function () { + var params, props, + interwiki = widget.value.substring( 0, widget.value.indexOf( ':' ) ); + if ( + interwiki && interwiki !== '' && + widget.interwikiPrefixes.indexOf( interwiki ) !== -1 + ) { + return $.Deferred().resolve( { query: { + pages: [ { + title: widget.value + } ] + } } ).promise( promiseAbortObject ); + } else { + params = { + action: 'query', + generator: 'prefixsearch', + gpssearch: widget.value, + gpsnamespace: widget.namespace !== null ? widget.namespace : undefined, + gpslimit: widget.limit, + ppprop: 'disambiguation' + }; + props = [ 'info', 'pageprops' ]; + if ( widget.showRedirectTargets ) { + params.redirects = '1'; + } + if ( widget.showImages ) { + props.push( 'pageimages' ); + params.pithumbsize = 80; + params.pilimit = widget.limit; + } + if ( widget.showDescriptions ) { + props.push( 'pageterms' ); + params.wbptterms = 'description'; + } + params.prop = props.join( '|' ); + req = new mw.Api().get( params ); + promiseAbortObject.abort = req.abort.bind( req ); // todo: ew + return req; + } + } ).promise( promiseAbortObject ); + } else { + // Don't send invalid titles to the API. + // Just pretend it returned nothing so we can show the 'invalid title' section + return $.Deferred().resolve( {} ).promise( promiseAbortObject ); + } + }; + + /** + * Get lookup cache item from server response data. + * + * @method + * @param {Mixed} response Response from server + */ + mw.widgets.TitleInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) { + return response.query || {}; + }; + + /** + * Get list of menu items from a server response. + * + * @param {Object} data Query result + * @returns {OO.ui.MenuOptionWidget[]} Menu items + */ + mw.widgets.TitleInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) { + var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects, + items = [], + titles = [], + titleObj = mw.Title.newFromText( this.value ), + redirectsTo = {}, + pageData = {}; + + if ( data.redirects ) { + for ( i = 0, len = data.redirects.length; i < len; i++ ) { + redirect = data.redirects[ i ]; + redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || []; + redirectsTo[ redirect.to ].push( redirect.from ); + } + } + + for ( index in data.pages ) { + suggestionPage = data.pages[ index ]; + pageData[ suggestionPage.title ] = { + missing: suggestionPage.missing !== undefined, + redirect: suggestionPage.redirect !== undefined, + disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined, + imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ), + description: OO.getProp( suggestionPage, 'terms', 'description' ) + }; + + // Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true + // and we encounter a cross-namespace redirect. + if ( this.namespace === null || this.namespace === suggestionPage.ns ) { + titles.push( suggestionPage.title ); + } + + redirects = redirectsTo[ suggestionPage.title ] || []; + for ( i = 0, len = redirects.length; i < len; i++ ) { + pageData[ redirects[ i ] ] = { + missing: false, + redirect: true, + disambiguation: false, + description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title ) + }; + titles.push( redirects[ i ] ); + } + } + + // If not found, run value through mw.Title to avoid treating a match as a + // mismatch where normalisation would make them matching (bug 48476) + + pageExistsExact = titles.indexOf( this.value ) !== -1; + pageExists = pageExistsExact || ( + titleObj && titles.indexOf( titleObj.getPrefixedText() ) !== -1 + ); + + if ( !pageExists ) { + pageData[ this.value ] = { + missing: true, redirect: false, disambiguation: false, + description: mw.msg( 'mw-widgets-titleinput-description-new-page' ) + }; + } + + if ( this.cache ) { + this.cache.set( pageData ); + } + + // Offer the exact text as a suggestion if the page exists + if ( pageExists && !pageExistsExact ) { + titles.unshift( this.value ); + } + // Offer the exact text as a new page if the title is valid + if ( this.showRedlink && !pageExists && titleObj ) { + titles.push( this.value ); + } + for ( i = 0, len = titles.length; i < len; i++ ) { + page = pageData[ titles[ i ] ] || {}; + items.push( new mw.widgets.TitleOptionWidget( this.getOptionWidgetData( titles[ i ], page ) ) ); + } + + return items; + }; + + /** + * Get menu option widget data from the title and page data + * + * @param {mw.Title} title Title object + * @param {Object} data Page data + * @return {Object} Data for option widget + */ + mw.widgets.TitleInputWidget.prototype.getOptionWidgetData = function ( title, data ) { + var mwTitle = new mw.Title( title ); + return { + data: this.namespace !== null && this.relative + ? mwTitle.getRelativeText( this.namespace ) + : title, + title: mwTitle, + imageUrl: this.showImages ? data.imageUrl : null, + description: this.showDescriptions ? data.description : null, + missing: data.missing, + redirect: data.redirect, + disambiguation: data.disambiguation, + query: this.value + }; + }; + + /** + * Get title object corresponding to given value, or #getValue if not given. + * + * @param {string} [value] Value to get a title for + * @returns {mw.Title|null} Title object, or null if value is invalid + */ + mw.widgets.TitleInputWidget.prototype.getTitle = function ( value ) { + var title = value !== undefined ? value : this.getValue(), + // mw.Title doesn't handle null well + titleObj = mw.Title.newFromText( title, this.namespace !== null ? this.namespace : undefined ); + + return titleObj; + }; + + /** + * @inheritdoc + */ + mw.widgets.TitleInputWidget.prototype.cleanUpValue = function ( value ) { + var widget = this; + value = mw.widgets.TitleInputWidget.parent.prototype.cleanUpValue.call( this, value ); + return $.trimByteLength( this.value, value, this.maxLength, function ( value ) { + var title = widget.getTitle( value ); + return title ? title.getMain() : value; + } ).newVal; + }; + + /** + * @inheritdoc + */ + mw.widgets.TitleInputWidget.prototype.isValid = function () { + return $.Deferred().resolve( !!this.getTitle() ).promise(); + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js b/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js new file mode 100644 index 00000000..ec0c9357 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js @@ -0,0 +1,82 @@ +/*! + * MediaWiki Widgets - TitleOptionWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + + /** + * Creates a mw.widgets.TitleOptionWidget object. + * + * @class + * @extends OO.ui.MenuOptionWidget + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [data] Label to display + * @cfg {mw.Title} [title] Page title object + * @cfg {string} [imageUrl] Thumbnail image URL with URL encoding + * @cfg {string} [description] Page description + * @cfg {boolean} [missing] Page doesn't exist + * @cfg {boolean} [redirect] Page is a redirect + * @cfg {boolean} [disambiguation] Page is a disambiguation page + * @cfg {string} [query] Matching query string + */ + mw.widgets.TitleOptionWidget = function MwWidgetsTitleOptionWidget( config ) { + var icon; + + if ( config.missing ) { + icon = 'page-not-found'; + } else if ( config.redirect ) { + icon = 'page-redirect'; + } else if ( config.disambiguation ) { + icon = 'page-disambiguation'; + } else { + icon = 'page-existing'; + } + + // Config initialization + config = $.extend( { + icon: icon, + label: config.data, + href: config.title.getUrl(), + autoFitLabel: false + }, config ); + + // Parent constructor + mw.widgets.TitleOptionWidget.parent.call( this, config ); + + // Initialization + this.$label.wrap( '<a>' ); + this.$link = this.$label.parent(); + this.$link.attr( 'href', config.href ); + this.$element.addClass( 'mw-widget-titleOptionWidget' ); + + // Highlight matching parts of link suggestion + this.$label.autoEllipsis( { hasSpan: false, tooltip: true, matchText: config.query } ); + + if ( config.missing ) { + this.$link.addClass( 'new' ); + } + + if ( config.imageUrl ) { + this.$icon + .addClass( 'mw-widget-titleOptionWidget-hasImage' ) + .css( 'background-image', 'url(' + config.imageUrl + ')' ); + } + + if ( config.description ) { + this.$element.append( + $( '<span>' ) + .addClass( 'mw-widget-titleOptionWidget-description' ) + .text( config.description ) + ); + } + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.TitleOptionWidget, OO.ui.MenuOptionWidget ); + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js new file mode 100644 index 00000000..0d0fb735 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js @@ -0,0 +1,119 @@ +/*! + * MediaWiki Widgets - UserInputWidget class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + + /** + * Creates a mw.widgets.UserInputWidget object. + * + * @class + * @extends OO.ui.TextInputWidget + * @mixins OO.ui.mixin.LookupElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {number} [limit=10] Number of results to show + */ + mw.widgets.UserInputWidget = function MwWidgetsUserInputWidget( config ) { + // Config initialization + config = config || {}; + + // Parent constructor + mw.widgets.UserInputWidget.parent.call( this, $.extend( {}, config, { autocomplete: false } ) ); + + // Mixin constructors + OO.ui.mixin.LookupElement.call( this, config ); + + // Properties + this.limit = config.limit || 10; + + // Initialization + this.$element.addClass( 'mw-widget-userInputWidget' ); + this.lookupMenu.$element.addClass( 'mw-widget-userInputWidget-menu' ); + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.UserInputWidget, OO.ui.TextInputWidget ); + OO.mixinClass( mw.widgets.UserInputWidget, OO.ui.mixin.LookupElement ); + + /* Methods */ + + /** + * @inheritdoc + */ + mw.widgets.UserInputWidget.prototype.onLookupMenuItemChoose = function ( item ) { + this.closeLookupMenu(); + this.setLookupsDisabled( true ); + this.setValue( item.getData() ); + this.setLookupsDisabled( false ); + }; + + /** + * @inheritdoc + */ + mw.widgets.UserInputWidget.prototype.focus = function () { + var retval; + + // Prevent programmatic focus from opening the menu + this.setLookupsDisabled( true ); + + // Parent method + retval = mw.widgets.UserInputWidget.parent.prototype.focus.apply( this, arguments ); + + this.setLookupsDisabled( false ); + + return retval; + }; + + /** + * @inheritdoc + */ + mw.widgets.UserInputWidget.prototype.getLookupRequest = function () { + var inputValue = this.value; + + return new mw.Api().get( { + action: 'query', + list: 'allusers', + // Prefix of list=allusers is case sensitive. Normalise first + // character to uppercase so that "fo" may yield "Foo". + auprefix: inputValue[ 0 ].toUpperCase() + inputValue.slice( 1 ), + aulimit: this.limit + } ); + }; + + /** + * Get lookup cache item from server response data. + * + * @method + * @param {Mixed} response Response from server + */ + mw.widgets.UserInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) { + return response.query.allusers || {}; + }; + + /** + * Get list of menu items from a server response. + * + * @param {Object} data Query result + * @returns {OO.ui.MenuOptionWidget[]} Menu items + */ + mw.widgets.UserInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) { + var len, i, user, + items = []; + + for ( i = 0, len = data.length; i < len; i++ ) { + user = data[ i ] || {}; + items.push( new OO.ui.MenuOptionWidget( { + label: user.name, + data: user.name + } ) ); + } + + return items; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.legacy/images/feed-icon.png b/resources/src/mediawiki/images/feed-icon.png Binary files differindex 00f49f6c..00f49f6c 100644 --- a/resources/src/mediawiki.legacy/images/feed-icon.png +++ b/resources/src/mediawiki/images/feed-icon.png diff --git a/resources/src/mediawiki.legacy/images/feed-icon.svg b/resources/src/mediawiki/images/feed-icon.svg index 6e5f570a..6e5f570a 100644 --- a/resources/src/mediawiki.legacy/images/feed-icon.svg +++ b/resources/src/mediawiki/images/feed-icon.svg diff --git a/resources/src/mediawiki.legacy/images/question.png b/resources/src/mediawiki/images/question.png Binary files differindex f7405d26..f7405d26 100644 --- a/resources/src/mediawiki.legacy/images/question.png +++ b/resources/src/mediawiki/images/question.png diff --git a/resources/src/mediawiki.legacy/images/question.svg b/resources/src/mediawiki/images/question.svg index 98fbe8dd..98fbe8dd 100644 --- a/resources/src/mediawiki.legacy/images/question.svg +++ b/resources/src/mediawiki/images/question.svg 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 132c334f..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() { @@ -386,12 +393,12 @@ // on links from hiding a notification. .on( 'click', 'a', function ( e ) { e.stopPropagation(); - } ) - .hide(); + } ); // Prepend the notification area to the content area and save it's object. mw.util.$content.prepend( $area ); offset = $area.offset(); + $area.hide(); function updateAreaMode() { var isFloating = $window.scrollTop() > offset.top; @@ -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 ]; }, /** diff --git a/resources/src/moment-local-dmy.js b/resources/src/moment-local-dmy.js new file mode 100644 index 00000000..c67b93e9 --- /dev/null +++ b/resources/src/moment-local-dmy.js @@ -0,0 +1,16 @@ +// Use DMY date format for Moment.js, in accordance with MediaWiki's date formatting routines. +// This affects English only (and languages without localisations, that fall back to English). +// http://momentjs.com/docs/#/customization/long-date-formats/ +/*global moment */ +moment.locale( 'en', { + longDateFormat: { + // Unchanged, but have to be repeated here: + LT: 'h:mm A', + LTS: 'h:mm:ss A', + // Customized: + L: 'DD/MM/YYYY', + LL: 'D MMMM YYYY', + LLL: 'D MMMM YYYY LT', + LLLL: 'dddd, D MMMM YYYY LT' + } +} ); diff --git a/resources/src/oojs-ui-local.css b/resources/src/oojs-ui-local.css new file mode 100644 index 00000000..ab780fed --- /dev/null +++ b/resources/src/oojs-ui-local.css @@ -0,0 +1,7 @@ +/* HACK: Set sane font-size for OOjs UI dialogs, in the most common case. This should be skin's + responsibility, but alas our skins tend to have the weirdest font-sizes on body. This shall be + removed when we make the MediaWiki skins bundled with tarball sane. (T91152) */ +body > .oo-ui-windowManager { + font-size: 12.8px; + font-size: 0.8rem; +} diff --git a/resources/src/polyfill-nodeTypes.js b/resources/src/polyfill-nodeTypes.js new file mode 100644 index 00000000..556b51b4 --- /dev/null +++ b/resources/src/polyfill-nodeTypes.js @@ -0,0 +1,19 @@ +/** + * Adds window.Node with node types according to: + * http://www.w3.org/TR/DOM-Level-2-Core/core.html#ID-1950641247 + */ + +window.Node = window.Node || { + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + CDATA_SECTION_NODE: 4, + ENTITY_REFERENCE_NODE: 5, + ENTITY_NODE: 6, + PROCESSING_INSTRUCTION_NODE: 7, + COMMENT_NODE: 8, + DOCUMENT_NODE: 9, + DOCUMENT_TYPE_NODE: 10, + DOCUMENT_FRAGMENT_NODE: 11, + NOTATION_NODE: 12 +}; diff --git a/resources/src/startup.js b/resources/src/startup.js index a62cc9d6..1a10f837 100644 --- a/resources/src/startup.js +++ b/resources/src/startup.js @@ -3,8 +3,16 @@ * continue loading jQuery and the MediaWiki modules. This code should work on * even the most ancient of browsers, so be very careful when editing. */ +/*jshint unused: false, evil: true */ +/*globals mw, RLQ: true, $VARS, $CODE, performance */ -var mediaWikiLoadStart = ( new Date() ).getTime(); +var mediaWikiLoadStart = ( new Date() ).getTime(), + + mwPerformance = ( window.performance && performance.mark ) ? performance : { + mark: function () {} + }; + +mwPerformance.mark( 'mwLoadStart' ); /** * Returns false for Grade C supported browsers. @@ -16,8 +24,6 @@ var mediaWikiLoadStart = ( new Date() ).getTime(); * - https://www.mediawiki.org/wiki/Compatibility#Browsers * - https://jquery.com/browser-support/ */ - -/*jshint unused: false */ function isCompatible( ua ) { if ( ua === undefined ) { ua = navigator.userAgent; @@ -26,18 +32,18 @@ function isCompatible( ua ) { // Browsers with outdated or limited JavaScript engines get the no-JS experience return !( // Internet Explorer < 8 - ( ua.indexOf( 'MSIE' ) !== -1 && parseFloat( ua.split( 'MSIE' )[1] ) < 8 ) || + ( ua.indexOf( 'MSIE' ) !== -1 && parseFloat( ua.split( 'MSIE' )[ 1 ] ) < 8 ) || // Firefox < 3 - ( ua.indexOf( 'Firefox/' ) !== -1 && parseFloat( ua.split( 'Firefox/' )[1] ) < 3 ) || + ( ua.indexOf( 'Firefox/' ) !== -1 && parseFloat( ua.split( 'Firefox/' )[ 1 ] ) < 3 ) || // Opera < 12 ( ua.indexOf( 'Opera/' ) !== -1 && ( ua.indexOf( 'Version/' ) === -1 ? // "Opera/x.y" - parseFloat( ua.split( 'Opera/' )[1] ) < 10 : + parseFloat( ua.split( 'Opera/' )[ 1 ] ) < 10 : // "Opera/9.80 ... Version/x.y" - parseFloat( ua.split( 'Version/' )[1] ) < 12 + parseFloat( ua.split( 'Version/' )[ 1 ] ) < 12 ) ) || // "Mozilla/0.0 ... Opera x.y" - ( ua.indexOf( 'Opera ' ) !== -1 && parseFloat( ua.split( ' Opera ' )[1] ) < 10 ) || + ( ua.indexOf( 'Opera ' ) !== -1 && parseFloat( ua.split( ' Opera ' )[ 1 ] ) < 10 ) || // BlackBerry < 6 ua.match( /BlackBerry[^\/]*\/[1-5]\./ ) || // Open WebOS < 1.5 @@ -52,11 +58,56 @@ function isCompatible( ua ) { ua.match( /Opera Mini/ ) || // Nokia's Ovi Browser ua.match( /S40OviBrowser/ ) || + // MeeGo's browser + ua.match( /MeeGo/ ) || // Google Glass browser groks JS but UI is too limited ( ua.match( /Glass/ ) && ua.match( /Android/ ) ) ); } -/** - * The startUp() function will be auto-generated and added below. - */ +// Conditional script injection +( function () { + if ( !isCompatible() ) { + // Undo class swapping in case of an unsupported browser. + // See OutputPage::getHeadScripts(). + document.documentElement.className = document.documentElement.className + .replace( /(^|\s)client-js(\s|$)/, '$1client-nojs$2' ); + return; + } + + /** + * The $CODE and $VARS placeholders are substituted in ResourceLoaderStartUpModule.php. + */ + function startUp() { + mw.config = new mw.Map( $VARS.wgLegacyJavaScriptGlobals ); + + $CODE.registrations(); + + mw.config.set( $VARS.configuration ); + + // Must be after mw.config.set because these callbacks may use mw.loader which + // needs to have values 'skin', 'debug' etc. from mw.config. + window.RLQ = window.RLQ || []; + while ( RLQ.length ) { + RLQ.shift()(); + } + window.RLQ = { + push: function ( fn ) { + fn(); + } + }; + } + + var script = document.createElement( 'script' ); + script.src = $VARS.baseModulesUri; + script.onload = script.onreadystatechange = function () { + if ( !script.readyState || /loaded|complete/.test( script.readyState ) ) { + // Clean up + script.onload = script.onreadystatechange = null; + script = null; + // Callback + startUp(); + } + }; + document.getElementsByTagName( 'head' )[ 0 ].appendChild( script ); +}() ); |