diff options
Diffstat (limited to 'resources/src/mediawiki')
41 files changed, 2103 insertions, 1412 deletions
diff --git a/resources/src/mediawiki/images/help.png b/resources/src/mediawiki/images/help.png Binary files differnew file mode 100644 index 00000000..99105822 --- /dev/null +++ b/resources/src/mediawiki/images/help.png diff --git a/resources/src/mediawiki/images/help.svg b/resources/src/mediawiki/images/help.svg new file mode 100644 index 00000000..3662cb58 --- /dev/null +++ b/resources/src/mediawiki/images/help.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g><path d="M12.001 2.085c-5.478 0-9.916 4.438-9.916 9.916 0 5.476 4.438 9.914 9.916 9.914 5.476 0 9.914-4.438 9.914-9.914 0-5.478-4.438-9.916-9.914-9.916zm.001 18c-4.465 0-8.084-3.619-8.084-8.083 0-4.465 3.619-8.084 8.084-8.084 4.464 0 8.083 3.619 8.083 8.084 0 4.464-3.619 8.083-8.083 8.083z"/><g><path d="M11.766 6.688c-2.5 0-3.219 2.188-3.219 2.188l1.411.854s.298-.791.901-1.229c.516-.375 1.625-.625 2.219.125.701.885-.17 1.587-1.078 2.719-.953 1.186-1 3.655-1 3.655h1.969s.135-2.318 1.041-3.381c.603-.707 1.443-1.338 1.443-2.494s-1.187-2.437-3.687-2.437z"/><path d="M11 16h2v2h-2z"/></g></g></svg> diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.svg b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.svg new file mode 100644 index 00000000..b34fb382 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.svg @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + version="1.1" + width="30" + height="30" + id="svg2"> + <defs + id="defs4" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + transform="matrix(0.41333074,0,0,0.41333074,-183.39876,-197.95599)" + id="layer1"> + <g + transform="translate(455.60433,484.94177)" + id="g3163"> + <path + d="M 0,0.03543307 0,60.519684 43.192915,30.259842 z" + id="path3165" + style="fill:#cccccc;fill-opacity:1;fill-rule:nonzero;stroke:none" /> + <path + d="m 43.157481,0.03543307 5.633859,0 0,60.48425093 -5.633859,0 z" + id="path3167" + style="fill:#cccccc;fill-opacity:1;fill-rule:nonzero;stroke:none" /> + </g> + </g> +</svg> diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.svg b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.svg new file mode 100644 index 00000000..529e8d0f --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.svg @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + version="1.1" + width="30" + height="30" + id="svg2"> + <defs + id="defs4" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + transform="matrix(0.4132798,0,0,0.4132798,-87.72955,-233.35372)" + id="layer1"> + <path + d="m 272.96237,570.69005 0,60.4894 -43.19393,-30.2447 z" + id="path3023-7" + style="fill:#cccccc;fill-opacity:1;stroke:none" /> + <rect + width="5.6406202" + height="60.489399" + x="-229.82111" + y="570.68774" + transform="scale(-1,1)" + id="rect3799-9" + style="color:#000000;fill:#cccccc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:20;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> + </g> +</svg> diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.svg b/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.svg new file mode 100644 index 00000000..9fbcf20e --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.svg @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + version="1.1" + width="30" + height="30" + id="svg2"> + <defs + id="defs4" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + transform="matrix(0.41329555,0,0,0.41329555,-111.35036,-135.3531)" + id="layer1"> + <path + d="m 284.11732,333.54605 0,60.4894 43.19395,-30.2447 z" + id="path3023-7-2" + style="fill:#cccccc;fill-opacity:1;stroke:none" /> + </g> +</svg> diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.svg b/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.svg new file mode 100644 index 00000000..3130f109 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.svg @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + version="1.1" + width="30" + height="30" + id="svg2"> + <defs + id="defs4" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + transform="matrix(0.41329555,0,0,0.41329555,-139.69062,-163.69336)" + id="layer1"> + <path + d="m 395.88269,402.11748 0,60.4894 -43.19395,-30.2447 z" + id="path3023-7-2-8" + style="fill:#cccccc;fill-opacity:1;stroke:none" /> + </g> +</svg> diff --git a/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.svg b/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.svg new file mode 100644 index 00000000..57df4c0d --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.svg @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + version="1.1" + width="30" + height="30" + id="svg2"> + <defs + id="defs4" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + transform="matrix(0.41327999,0,0,0.41327999,-98.356798,-226.26904)" + id="layer1"> + <path + d="m 249.89477,553.5472 0,60.4894 43.19391,-30.2447 z" + id="path3023" + style="fill:#0000aa;fill-opacity:1;stroke:none" /> + <rect + width="5.6406202" + height="60.489399" + x="293.03604" + y="553.54492" + id="rect3799" + style="color:#000000;fill:#0000aa;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:20;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> + </g> +</svg> diff --git a/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.svg b/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.svg new file mode 100644 index 00000000..dbb473bb --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.svg @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + version="1.1" + width="30" + height="30" + id="svg2" + inkscape:version="0.48.4 r9939" + sodipodi:docname="pager-arrow-fastforward-rtl.svg"> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1366" + inkscape:window-height="692" + id="namedview8" + showgrid="false" + inkscape:zoom="17.4" + inkscape:cx="7.0114943" + inkscape:cy="15" + inkscape:window-x="0" + inkscape:window-y="24" + inkscape:window-maximized="1" + inkscape:current-layer="svg2" /> + <defs + id="defs4" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + transform="matrix(0.07055556,0,0,0.07055556,-9.1581596,-2.7587241)" + id="layer1"> + <path + d="m 485.26916,74.546776 0,354.317014 -253.00859,-177.15851 z" + id="path3023-2" + style="fill:#0000aa;fill-opacity:1;stroke:none" + inkscape:connector-curvature="0" /> + <rect + width="33.039963" + height="354.31699" + x="-232.56898" + y="74.533081" + transform="scale(-1,1)" + id="rect3799-6" + style="color:#000000;fill:#0000aa;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:20;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> + </g> +</svg> diff --git a/resources/src/mediawiki/images/pager-arrow-forward-ltr.svg b/resources/src/mediawiki/images/pager-arrow-forward-ltr.svg new file mode 100644 index 00000000..1ebf9c15 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-forward-ltr.svg @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + version="1.1" + width="30" + height="30" + id="svg2"> + <defs + id="defs4" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + transform="matrix(0.41329555,0,0,0.41329555,-162.12666,-110.55537)" + id="layer1"> + <path + d="m 406.97447,273.54605 0,60.4894 43.19391,-30.2447 z" + id="path3023-3-9" + style="fill:#0000aa;fill-opacity:1;stroke:none" /> + </g> +</svg> diff --git a/resources/src/mediawiki/images/pager-arrow-forward-rtl.svg b/resources/src/mediawiki/images/pager-arrow-forward-rtl.svg new file mode 100644 index 00000000..b494409a --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-forward-rtl.svg @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + version="1.1" + width="30" + height="30" + id="svg2"> + <defs + id="defs4" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + transform="matrix(0.41329555,0,0,0.41329555,-78.28671,-153.06577)" + id="layer1"> + <path + d="m 247.31124,376.4032 0,60.4894 -43.19391,-30.2447 z" + id="path3023-3" + style="fill:#0000aa;fill-opacity:1;stroke:none" /> + </g> +</svg> diff --git a/resources/src/mediawiki/mediawiki.Title.js b/resources/src/mediawiki/mediawiki.Title.js index 7ced42fe..3efb7eca 100644 --- a/resources/src/mediawiki/mediawiki.Title.js +++ b/resources/src/mediawiki/mediawiki.Title.js @@ -8,7 +8,7 @@ /** * @class mw.Title * - * Parse titles into an object struture. Note that when using the constructor + * Parse titles into an object structure. Note that when using the constructor * directly, passing invalid titles will result in an exception. Use #newFromText to use the * logic directly and get null for invalid titles which is easier to work with. * @@ -119,7 +119,7 @@ rSplit = /^(.+?)_*:_*(.*)$/, - // See Title.php#getTitleInvalidRegex + // See MediaWikiTitleCodec.php#getTitleInvalidRegex rInvalid = new RegExp( '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' + // URL percent encoding sequences interfere with the ability @@ -508,7 +508,7 @@ normalizeExtension = function ( extension ) { // Remove only trailing space (that is removed by MW anyway) - extension = extension.toLowerCase().replace(/\s*$/, ''); + extension = extension.toLowerCase().replace( /\s*$/, '' ); return extension; }; @@ -731,7 +731,10 @@ set: function ( titles, state ) { titles = $.isArray( titles ) ? titles : [titles]; state = state === undefined ? true : !!state; - var pages = this.pages, i, len = titles.length; + var i, + pages = this.pages, + len = titles.length; + for ( i = 0; i < len; i++ ) { pages[ titles[i] ] = state; } diff --git a/resources/src/mediawiki/mediawiki.Uri.js b/resources/src/mediawiki/mediawiki.Uri.js index 55663128..abfb2790 100644 --- a/resources/src/mediawiki/mediawiki.Uri.js +++ b/resources/src/mediawiki/mediawiki.Uri.js @@ -127,15 +127,29 @@ */ /** - * A factory method to create a variation of mw.Uri with a different default location (for - * relative URLs, including protocol-relative URLs). Used so the library is still testable & - * purely functional. + * A factory method to create a Uri class with a default location to resolve relative URLs + * against (including protocol-relative URLs). * * @method + * @param {string|Function} documentLocation A full url, or function returning one. + * If passed a function, the return value may change over time and this will be honoured. (T74334) * @member mw */ mw.UriRelative = function ( documentLocation ) { - var defaultUri; + var getDefaultUri = ( function () { + // Cache + var href, uri; + + return function () { + var hrefCur = typeof documentLocation === 'string' ? documentLocation : documentLocation(); + if ( href === hrefCur ) { + return uri; + } + href = hrefCur; + uri = new Uri( href ); + return uri; + }; + }() ); /** * @class mw.Uri @@ -147,8 +161,8 @@ * @param {Object|string} [uri] URI string, or an Object with appropriate properties (especially * another URI object to clone). Object must have non-blank `protocol`, `host`, and `path` * properties. If omitted (or set to `undefined`, `null` or empty string), then an object - * will be created for the default `uri` of this constructor (`document.location` for - * mw.Uri, other values for other instances -- see mw.UriRelative for details). + * will be created for the default `uri` of this constructor (`location.href` for mw.Uri, + * other values for other instances -- see mw.UriRelative for details). * @param {Object|boolean} [options] Object with options, or (backwards compatibility) a boolean * for strictMode * @param {boolean} [options.strictMode=false] Trigger strict mode parsing of the url. @@ -156,6 +170,9 @@ * override each other (`true`) or automagically convert them to an array (`false`). */ function Uri( uri, options ) { + var prop, + defaultUri = getDefaultUri(); + options = typeof options === 'object' ? options : { strictMode: !!options }; options = $.extend( { strictMode: false, @@ -167,7 +184,7 @@ this.parse( uri, options ); } else if ( typeof uri === 'object' ) { // Copy data over from existing URI object - for ( var prop in uri ) { + for ( prop in uri ) { // Only copy direct properties, not inherited ones if ( uri.hasOwnProperty( prop ) ) { // Deep copy object properties @@ -390,14 +407,12 @@ } }; - defaultUri = new Uri( documentLocation ); - return Uri; }; - // If we are running in a browser, inject the current document location (for relative URLs). - if ( document && document.location && document.location.href ) { - mw.Uri = mw.UriRelative( document.location.href ); - } + // Default to the current browsing location (for relative URLs). + mw.Uri = mw.UriRelative( function () { + return location.href; + } ); }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.apihelp.css b/resources/src/mediawiki/mediawiki.apihelp.css new file mode 100644 index 00000000..d1272323 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.apihelp.css @@ -0,0 +1,86 @@ +.apihelp-header { + clear: both; + margin-bottom: 0.1em; +} + +div.apihelp-linktrail { + font-size: smaller; +} + +.apihelp-block { + margin-top: 0.5em; +} + +.apihelp-block-head { + font-weight: bold; +} + +.apihelp-flags { + font-size: smaller; + float: right; + border: 1px solid black; + padding: 0.25em; + width: 20em; +} + +.apihelp-deprecated, .apihelp-flag-deprecated, +.apihelp-flag-internal strong { + font-weight: bold; + color: red; +} + +.apihelp-empty { + color: #888; +} + +.apihelp-help-urls ul { + list-style-image: none; + list-style-type: none; + margin-left: 0; +} + +.apihelp-parameters dl, +.apihelp-examples dl, +.apihelp-permissions dl { + margin-left: 2em; +} + +.apihelp-parameters dt { + float: left; + clear: left; + min-width: 10em; + white-space: nowrap; + line-height: 1.5em; +} + +.apihelp-parameters dt:after { + content: ':\A0' +} + +.apihelp-parameters dd { + margin: 0 0 0.5em 10em; + line-height: 1.5em; +} + +.apihelp-parameters dd p:first-child { + margin-top: 0; +} + +.apihelp-parameters dd.info { + margin-left: 12em; + text-indent: -2em; +} + +.apihelp-examples dt { + font-weight: normal; +} + +.api-main-links { + text-align: center; +} +.api-main-links ul:before { + content: '['; +} +.api-main-links ul:after { + content: ']'; +} diff --git a/resources/src/mediawiki/mediawiki.apipretty.css b/resources/src/mediawiki/mediawiki.apipretty.css new file mode 100644 index 00000000..fe5e634d --- /dev/null +++ b/resources/src/mediawiki/mediawiki.apipretty.css @@ -0,0 +1,11 @@ +h1.firstHeading { + display: none; +} + +.api-pretty-header { + font-size: small; +} + +.api-pretty-content { + white-space: pre-wrap; +} diff --git a/resources/src/mediawiki/mediawiki.confirmCloseWindow.js b/resources/src/mediawiki/mediawiki.confirmCloseWindow.js new file mode 100644 index 00000000..7fc5c424 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.confirmCloseWindow.js @@ -0,0 +1,68 @@ +( function ( mw, $ ) { + /** + * @method confirmCloseWindow + * @member mw + * + * Prevent the closing of a window with a confirm message (the onbeforeunload event seems to + * 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. + * + * var allowCloseWindow = mw.confirmCloseWindow(); + * // ... do stuff that can't be interrupted ... + * allowCloseWindow(); + * + * @param {Object} [options] + * @param {string} [options.namespace] Namespace for the event registration + * @param {string} [options.message] + * @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 + */ + mw.confirmCloseWindow = function ( options ) { + var savedUnloadHandler, + mainEventName = 'beforeunload', + showEventName = 'pageshow'; + + options = $.extend( { + message: mw.message( 'mwe-prevent-close' ).text(), + test: function () { return true; } + }, options ); + + if ( options.namespace ) { + mainEventName += '.' + options.namespace; + showEventName += '.' + options.namespace; + } + + $( window ).on( mainEventName, function () { + if ( options.test() ) { + // remove the handler while the alert is showing - otherwise breaks caching in Firefox (3?). + // but if they continue working on this page, immediately re-register this handler + savedUnloadHandler = window.onbeforeunload; + window.onbeforeunload = null; + setTimeout( function () { + window.onbeforeunload = savedUnloadHandler; + }, 1 ); + + // show an alert with this message + if ( $.isFunction( options.message ) ) { + return options.message(); + } else { + return options.message; + } + } + } ).on( showEventName, function () { + // Re-add onbeforeunload handler + if ( !window.onbeforeunload && savedUnloadHandler ) { + window.onbeforeunload = savedUnloadHandler; + } + } ); + + // return the function they can use to stop this + return function () { + $( window ).off( mainEventName + ' ' + showEventName ); + }; + }; +} )( mediaWiki, jQuery ); diff --git a/resources/src/mediawiki/mediawiki.content.json.css b/resources/src/mediawiki/mediawiki.content.json.css index d93e291e..9e20264f 100644 --- a/resources/src/mediawiki/mediawiki.content.json.css +++ b/resources/src/mediawiki/mediawiki.content.json.css @@ -18,19 +18,25 @@ padding: 0.5em 1em; } -.mw-json td { - background-color: #eee; - font-style: italic; -} - -.mw-json .value { +.mw-json .value, +.mw-json-single-value { background-color: #dcfae3; font-family: monospace, monospace; white-space: pre-wrap; } +.mw-json-single-value { + background-color: #eee; +} + +.mw-json-empty { + background-color: #fff; + font-style: italic; +} + .mw-json tr { margin-bottom: 0.5em; + background-color: #eee; } .mw-json th { diff --git a/resources/src/mediawiki/mediawiki.cookie.js b/resources/src/mediawiki/mediawiki.cookie.js index 6f9f0abb..8f091e4d 100644 --- a/resources/src/mediawiki/mediawiki.cookie.js +++ b/resources/src/mediawiki/mediawiki.cookie.js @@ -27,13 +27,14 @@ * @param {string|null} value Value of cookie. If `value` is `null` then this method will * instead remove a cookie by name of `key`. * @param {Object|Date} [options] Options object, or expiry date - * @param {Date|null} [options.expires=wgCookieExpiration] The expiry date of the cookie. + * @param {Date|number|null} [options.expires] The expiry date of the cookie, or lifetime in seconds. * - * Default cookie expiration is based on `wgCookieExpiration`. If `wgCookieExpiration` is - * 0, a session cookie is set (expires when the browser is closed). For non-zero values of - * `wgCookieExpiration`, the cookie expires `wgCookieExpiration` seconds from now. + * If `options.expires` is null, then a session cookie is set. + * + * By default cookie expiration is based on `wgCookieExpiration`. Similar to `WebResponse` + * in PHP, we set a session cookie if `wgCookieExpiration` is 0. And for non-zero values + * it is interpreted as lifetime in seconds. * - * If options.expires is null, then a session cookie is set. * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie * @param {string} [options.path=wgCookiePath] The path attribute of the cookie @@ -69,16 +70,20 @@ options = $.extend( defaultOptions, options ); } - // $.cookie makes session cookies when expiry is omitted, - // however our default is to expire wgCookieExpiration seconds from now. - // Note: If wgCookieExpiration is 0, that is considered a special value indicating + // Default to using wgCookieExpiration (lifetime in seconds). + // If wgCookieExpiration is 0, that is considered a special value indicating // all cookies should be session cookies by default. if ( options.expires === undefined && config.wgCookieExpiration !== 0 ) { date = new Date(); date.setTime( Number( date ) + ( config.wgCookieExpiration * 1000 ) ); options.expires = date; + } else if ( typeof options.expires === 'number' ) { + // Lifetime in seconds + date = new Date(); + date.setTime( Number( date ) + ( options.expires * 1000 ) ); + options.expires = date; } else if ( options.expires === null ) { - // $.cookie makes a session cookie when expires is omitted + // $.cookie makes a session cookie when options.expires is omitted delete options.expires; } @@ -123,4 +128,4 @@ } }; -} ( mediaWiki, jQuery ) ); +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.debug.js b/resources/src/mediawiki/mediawiki.debug.js index 4935984f..bdff99f7 100644 --- a/resources/src/mediawiki/mediawiki.debug.js +++ b/resources/src/mediawiki/mediawiki.debug.js @@ -170,8 +170,6 @@ paneTriggerBitDiv( 'includes', 'PHP includes', this.data.includes.length ); - paneTriggerBitDiv( 'profile', 'Profile', this.data.profile.length ); - gitInfo = ''; if ( this.data.gitRevision !== false ) { gitInfo = '(' + this.data.gitRevision.slice( 0, 7 ) + ')'; @@ -211,8 +209,7 @@ querylist: this.buildQueryTable(), debuglog: this.buildDebugLogTable(), request: this.buildRequestPane(), - includes: this.buildIncludesPane(), - profile: this.buildProfilePane() + includes: this.buildIncludesPane() }; for ( id in panes ) { @@ -381,10 +378,6 @@ } return $table; - }, - - buildProfilePane: function () { - return mw.Debug.profile.init(); } }; diff --git a/resources/src/mediawiki/mediawiki.debug.profile.css b/resources/src/mediawiki/mediawiki.debug.profile.css deleted file mode 100644 index ab27da9d..00000000 --- a/resources/src/mediawiki/mediawiki.debug.profile.css +++ /dev/null @@ -1,45 +0,0 @@ -.mw-debug-profile-tipsy .tipsy-inner { - /* undo max-width from vector on .tipsy-inner */ - max-width: none; - /* needed for some browsers to provide space for the scrollbar without wrapping text */ - min-width: 100%; - max-height: 150px; - overflow-y: auto; -} - -.mw-debug-profile-underline { - stroke-width: 1; - stroke: #dfdfdf; -} - -.mw-debug-profile-period { - fill: red; -} - -/* connecting line between endpoints on long events */ -.mw-debug-profile-period line { - stroke: red; - stroke-width: 2; -} - -.mw-debug-profile-tipsy, -.mw-debug-profile-timeline text { - color: #444; - fill: #444; - /* using em's causes the two locations to have different sizes */ - font-size: 12px; - font-family: sans-serif; -} - -.mw-debug-profile-meta, -.mw-debug-profile-timeline tspan { - /* using em's causes the two locations to have different sizes */ - font-size: 10px; -} - -.mw-debug-profile-no-data { - text-align: center; - padding-top: 5em; - font-weight: bold; - font-size: 1.2em; -} diff --git a/resources/src/mediawiki/mediawiki.debug.profile.js b/resources/src/mediawiki/mediawiki.debug.profile.js deleted file mode 100644 index 04f7acd0..00000000 --- a/resources/src/mediawiki/mediawiki.debug.profile.js +++ /dev/null @@ -1,556 +0,0 @@ -/*! - * JavaScript for the debug toolbar profiler, enabled through $wgDebugToolbar - * and StartProfiler.php. - * - * @author Erik Bernhardson - * @since 1.23 - */ - -( function ( mw, $ ) { - 'use strict'; - - /** - * @singleton - * @class mw.Debug.profile - */ - var profile = mw.Debug.profile = { - /** - * Object containing data for the debug toolbar - * - * @property ProfileData - */ - data: null, - - /** - * @property DOMElement - */ - container: null, - - /** - * Initializes the profiling pane. - */ - init: function ( data, width, mergeThresholdPx, dropThresholdPx ) { - data = data || mw.config.get( 'debugInfo' ).profile; - profile.width = width || $(window).width() - 20; - // merge events from same pixel(some events are very granular) - mergeThresholdPx = mergeThresholdPx || 2; - // only drop events if requested - dropThresholdPx = dropThresholdPx || 0; - - if ( - !Array.prototype.map || - !Array.prototype.reduce || - !Array.prototype.filter || - !document.createElementNS || - !document.createElementNS.bind - ) { - profile.container = profile.buildRequiresBrowserFeatures(); - } else if ( data.length === 0 ) { - profile.container = profile.buildNoData(); - } else { - // Initialize createSvgElement (now that we know we have - // document.createElementNS and bind) - this.createSvgElement = document.createElementNS.bind( document, 'http://www.w3.org/2000/svg' ); - - // generate a flyout - profile.data = new ProfileData( data, profile.width, mergeThresholdPx, dropThresholdPx ); - // draw it - profile.container = profile.buildSvg( profile.container ); - profile.attachFlyout(); - } - - return profile.container; - }, - - buildRequiresBrowserFeatures: function () { - return $( '<div>' ) - .text( 'Certain browser features, including parts of ECMAScript 5 and document.createElementNS, are required for the profile visualization.' ) - .get( 0 ); - }, - - buildNoData: function () { - return $( '<div>' ).addClass( 'mw-debug-profile-no-data' ) - .text( 'No events recorded, ensure profiling is enabled in StartProfiler.php.' ) - .get( 0 ); - }, - - /** - * Creates DOM nodes appropriately namespaced for SVG. - * Initialized in init after checking support - * - * @param string tag to create - * @return DOMElement - */ - createSvgElement: null, - - /** - * @param DOMElement|undefined - */ - buildSvg: function ( node ) { - var container, group, i, g, - timespan = profile.data.timespan, - gapPerEvent = 38, - space = 10.5, - currentHeight = space, - totalHeight = 0; - - profile.ratio = ( profile.width - space * 2 ) / ( timespan.end - timespan.start ); - totalHeight += gapPerEvent * profile.data.groups.length; - - if ( node ) { - $( node ).empty(); - } else { - node = profile.createSvgElement( 'svg' ); - node.setAttribute( 'version', '1.2' ); - node.setAttribute( 'baseProfile', 'tiny' ); - } - node.style.height = totalHeight; - node.style.width = profile.width; - - // use a container that can be transformed - container = profile.createSvgElement( 'g' ); - node.appendChild( container ); - - for ( i = 0; i < profile.data.groups.length; i++ ) { - group = profile.data.groups[i]; - g = profile.buildTimeline( group ); - - g.setAttribute( 'transform', 'translate( 0 ' + currentHeight + ' )' ); - container.appendChild( g ); - - currentHeight += gapPerEvent; - } - - return node; - }, - - /** - * @param Object group of periods to transform into graphics - */ - buildTimeline: function ( group ) { - var text, tspan, line, i, - sum = group.timespan.sum, - ms = ' ~ ' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms', - timeline = profile.createSvgElement( 'g' ); - - timeline.setAttribute( 'class', 'mw-debug-profile-timeline' ); - - // draw label - text = profile.createSvgElement( 'text' ); - text.setAttribute( 'x', profile.xCoord( group.timespan.start ) ); - text.setAttribute( 'y', 0 ); - text.textContent = group.name; - timeline.appendChild( text ); - - // draw metadata - tspan = profile.createSvgElement( 'tspan' ); - tspan.textContent = ms; - text.appendChild( tspan ); - - // draw timeline periods - for ( i = 0; i < group.periods.length; i++ ) { - timeline.appendChild( profile.buildPeriod( group.periods[i] ) ); - } - - // full-width line under each timeline - line = profile.createSvgElement( 'line' ); - line.setAttribute( 'class', 'mw-debug-profile-underline' ); - line.setAttribute( 'x1', 0 ); - line.setAttribute( 'y1', 28 ); - line.setAttribute( 'x2', profile.width ); - line.setAttribute( 'y2', 28 ); - timeline.appendChild( line ); - - return timeline; - }, - - /** - * @param Object period to transform into graphics - */ - buildPeriod: function ( period ) { - var node, - head = profile.xCoord( period.start ), - tail = profile.xCoord( period.end ), - g = profile.createSvgElement( 'g' ); - - g.setAttribute( 'class', 'mw-debug-profile-period' ); - $( g ).data( 'period', period ); - - if ( head + 16 > tail ) { - node = profile.createSvgElement( 'rect' ); - node.setAttribute( 'x', head ); - node.setAttribute( 'y', 8 ); - node.setAttribute( 'width', 2 ); - node.setAttribute( 'height', 9 ); - g.appendChild( node ); - - node = profile.createSvgElement( 'rect' ); - node.setAttribute( 'x', head ); - node.setAttribute( 'y', 8 ); - node.setAttribute( 'width', ( period.end - period.start ) * profile.ratio || 2 ); - node.setAttribute( 'height', 6 ); - g.appendChild( node ); - } else { - node = profile.createSvgElement( 'polygon' ); - node.setAttribute( 'points', pointList( [ - [ head, 8 ], - [ head, 19 ], - [ head + 8, 8 ], - [ head, 8] - ] ) ); - g.appendChild( node ); - - node = profile.createSvgElement( 'polygon' ); - node.setAttribute( 'points', pointList( [ - [ tail, 8 ], - [ tail, 19 ], - [ tail - 8, 8 ], - [ tail, 8 ] - ] ) ); - g.appendChild( node ); - - node = profile.createSvgElement( 'line' ); - node.setAttribute( 'x1', head ); - node.setAttribute( 'y1', 9 ); - node.setAttribute( 'x2', tail ); - node.setAttribute( 'y2', 9 ); - g.appendChild( node ); - } - - return g; - }, - - /** - * @param Object - */ - buildFlyout: function ( period ) { - var contained, sum, ms, mem, i, - node = $( '<div>' ); - - for ( i = 0; i < period.contained.length; i++ ) { - contained = period.contained[i]; - sum = contained.end - contained.start; - ms = '' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms'; - mem = formatBytes( contained.memory ); - - $( '<div>' ).text( contained.source.name ) - .append( $( '<span>' ).text( ' ~ ' + ms + ' / ' + mem ).addClass( 'mw-debug-profile-meta' ) ) - .appendTo( node ); - } - - return node; - }, - - /** - * Attach a hover flyout to all .mw-debug-profile-period groups. - */ - attachFlyout: function () { - // for some reason addClass and removeClass from jQuery - // arn't working on svg elements in chrome <= 33.0 (possibly more) - var $container = $( profile.container ), - addClass = function ( node, value ) { - var current = node.getAttribute( 'class' ), - list = current ? current.split( ' ' ) : false, - idx = list ? list.indexOf( value ) : -1; - - if ( idx === -1 ) { - node.setAttribute( 'class', current ? ( current + ' ' + value ) : value ); - } - }, - removeClass = function ( node, value ) { - var current = node.getAttribute( 'class' ), - list = current ? current.split( ' ' ) : false, - idx = list ? list.indexOf( value ) : -1; - - if ( idx !== -1 ) { - list.splice( idx, 1 ); - node.setAttribute( 'class', list.join( ' ' ) ); - } - }, - // hide all tipsy flyouts - hide = function () { - $container.find( '.mw-debug-profile-period.tipsy-visible' ) - .each( function () { - removeClass( this, 'tipsy-visible' ); - $( this ).tipsy( 'hide' ); - } ); - }; - - $container.find( '.mw-debug-profile-period' ).tipsy( { - fade: true, - gravity: function () { - return $.fn.tipsy.autoNS.call( this ) + $.fn.tipsy.autoWE.call( this ); - }, - className: 'mw-debug-profile-tipsy', - center: false, - html: true, - trigger: 'manual', - title: function () { - return profile.buildFlyout( $( this ).data( 'period' ) ).html(); - } - } ).on( 'mouseenter', function () { - hide(); - addClass( this, 'tipsy-visible' ); - $( this ).tipsy( 'show' ); - } ); - - $container.on( 'mouseleave', function ( event ) { - var $from = $( event.relatedTarget ), - $to = $( event.target ); - // only close the tipsy if we are not - if ( $from.closest( '.tipsy' ).length === 0 && - $to.closest( '.tipsy' ).length === 0 && - $to.get( 0 ).namespaceURI !== 'http://www.w4.org/2000/svg' - ) { - hide(); - } - } ).on( 'click', function () { - // convenience method for closing - hide(); - } ); - }, - - /** - * @return number the x co-ordinate for the specified timestamp - */ - xCoord: function ( msTimestamp ) { - return ( msTimestamp - profile.data.timespan.start ) * profile.ratio; - } - }; - - function ProfileData( data, width, mergeThresholdPx, dropThresholdPx ) { - // validate input data - this.data = data.map( function ( event ) { - event.periods = event.periods.filter( function ( period ) { - return period.start && period.end - && period.start < period.end - // period start must be a reasonable ms timestamp - && period.start > 1000000; - } ); - return event; - } ).filter( function ( event ) { - return event.name && event.periods.length > 0; - } ); - - // start and end time of the data - this.timespan = this.data.reduce( function ( result, event ) { - return event.periods.reduce( periodMinMax, result ); - }, periodMinMax.initial() ); - - // transform input data - this.groups = this.collate( width, mergeThresholdPx, dropThresholdPx ); - - return this; - } - - /** - * There are too many unique events to display a line for each, - * so this does a basic grouping. - */ - ProfileData.groupOf = function ( label ) { - var pos, prefix = 'Profile section ended by close(): '; - if ( label.indexOf( prefix ) === 0 ) { - label = label.slice( prefix.length ); - } - - pos = [ '::', ':', '-' ].reduce( function ( result, separator ) { - var pos = label.indexOf( separator ); - if ( pos === -1 ) { - return result; - } else if ( result === -1 ) { - return pos; - } else { - return Math.min( result, pos ); - } - }, -1 ); - - if ( pos === -1 ) { - return label; - } else { - return label.slice( 0, pos ); - } - }; - - /** - * @return Array list of objects with `name` and `events` keys - */ - ProfileData.groupEvents = function ( events ) { - var group, i, - groups = {}; - - // Group events together - for ( i = events.length - 1; i >= 0; i-- ) { - group = ProfileData.groupOf( events[i].name ); - if ( groups[group] ) { - groups[group].push( events[i] ); - } else { - groups[group] = [events[i]]; - } - } - - // Return an array of groups - return Object.keys( groups ).map( function ( group ) { - return { - name: group, - events: groups[group] - }; - } ); - }; - - ProfileData.periodSorter = function ( a, b ) { - if ( a.start === b.start ) { - return a.end - b.end; - } - return a.start - b.start; - }; - - ProfileData.genMergePeriodReducer = function ( mergeThresholdMs ) { - return function ( result, period ) { - if ( result.length === 0 ) { - // period is first result - return [{ - start: period.start, - end: period.end, - contained: [period] - }]; - } - var last = result[result.length - 1]; - if ( period.end < last.end ) { - // end is contained within previous - result[result.length - 1].contained.push( period ); - } else if ( period.start - mergeThresholdMs < last.end ) { - // neighbors within merging distance - result[result.length - 1].end = period.end; - result[result.length - 1].contained.push( period ); - } else { - // period is next result - result.push( { - start: period.start, - end: period.end, - contained: [period] - } ); - } - return result; - }; - }; - - /** - * Collect all periods from the grouped events and apply merge and - * drop transformations - */ - ProfileData.extractPeriods = function ( events, mergeThresholdMs, dropThresholdMs ) { - // collect the periods from all events - return events.reduce( function ( result, event ) { - if ( !event.periods.length ) { - return result; - } - result.push.apply( result, event.periods.map( function ( period ) { - // maintain link from period to event - period.source = event; - return period; - } ) ); - return result; - }, [] ) - // sort combined periods - .sort( ProfileData.periodSorter ) - // Apply merge threshold. Original periods - // are maintained in the `contained` property - .reduce( ProfileData.genMergePeriodReducer( mergeThresholdMs ), [] ) - // Apply drop threshold - .filter( function ( period ) { - return period.end - period.start > dropThresholdMs; - } ); - }; - - /** - * runs a callback on all periods in the group. Only valid after - * groups.periods[0..n].contained are populated. This runs against - * un-transformed data and is better suited to summing or other - * stat collection - */ - ProfileData.reducePeriods = function ( group, callback, result ) { - return group.periods.reduce( function ( result, period ) { - return period.contained.reduce( callback, result ); - }, result ); - }; - - /** - * Transforms this.data grouping by labels, merging neighboring - * events in the groups, and drops events and groups below the - * display threshold. Groups are returned sorted by starting time. - */ - ProfileData.prototype.collate = function ( width, mergeThresholdPx, dropThresholdPx ) { - // ms to pixel ratio - var ratio = ( this.timespan.end - this.timespan.start ) / width, - // transform thresholds to ms - mergeThresholdMs = mergeThresholdPx * ratio, - dropThresholdMs = dropThresholdPx * ratio; - - return ProfileData.groupEvents( this.data ) - // generate data about the grouped events - .map( function ( group ) { - // Cleaned periods from all events - group.periods = ProfileData.extractPeriods( group.events, mergeThresholdMs, dropThresholdMs ); - // min and max timestamp per group - group.timespan = ProfileData.reducePeriods( group, periodMinMax, periodMinMax.initial() ); - // ms from first call to end of last call - group.timespan.length = group.timespan.end - group.timespan.start; - // collect the un-transformed periods - group.timespan.sum = ProfileData.reducePeriods( group, function ( result, period ) { - result.push( period ); - return result; - }, [] ) - // sort by start time - .sort( ProfileData.periodSorter ) - // merge overlapping - .reduce( ProfileData.genMergePeriodReducer( 0 ), [] ) - // sum - .reduce( function ( result, period ) { - return result + period.end - period.start; - }, 0 ); - - return group; - }, this ) - // remove groups that have had all their periods filtered - .filter( function ( group ) { - return group.periods.length > 0; - } ) - // sort events by first start - .sort( function ( a, b ) { - return ProfileData.periodSorter( a.timespan, b.timespan ); - } ); - }; - - // reducer to find edges of period array - function periodMinMax( result, period ) { - if ( period.start < result.start ) { - result.start = period.start; - } - if ( period.end > result.end ) { - result.end = period.end; - } - return result; - } - - periodMinMax.initial = function () { - return { start: Number.POSITIVE_INFINITY, end: Number.NEGATIVE_INFINITY }; - }; - - function formatBytes( bytes ) { - var i, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - if ( bytes === 0 ) { - return '0 Bytes'; - } - i = parseInt( Math.floor( Math.log( bytes ) / Math.log( 1024 ) ), 10 ); - return Math.round( bytes / Math.pow( 1024, i ), 2 ) + ' ' + sizes[i]; - } - - // turns a 2d array into a point list for svg - // polygon points attribute - // ex: [[1,2],[3,4],[4,2]] = '1,2 3,4 4,2' - function pointList( pairs ) { - return pairs.map( function ( pair ) { - return pair.join( ',' ); - } ).join( ' ' ); - } -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.errorLogger.js b/resources/src/mediawiki/mediawiki.errorLogger.js new file mode 100644 index 00000000..9f4f19dd --- /dev/null +++ b/resources/src/mediawiki/mediawiki.errorLogger.js @@ -0,0 +1,49 @@ +/** + * Try to catch errors in modules which don't do their own error handling. + * @class mw.errorLogger + * @singleton + */ +( function ( mw ) { + 'use strict'; + + mw.errorLogger = { + /** + * Fired via mw.track when an error is not handled by local code and is caught by the + * window.onerror handler. + * + * @event global_error + * @param {string} errorMessage Error errorMessage. + * @param {string} url URL where error was raised. + * @param {number} lineNumber Line number where error was raised. + * @param {number} [columnNumber] Line number where error was raised. Not all browsers + * support this. + * @param {Error|Mixed} [errorObject] The error object. Typically an instance of Error, but anything + * (even a primitive value) passed to a throw clause will end up here. + */ + + /** + * Install a window.onerror handler that will report via mw.track, while preserving + * any previous handler. + * @param {Object} window + */ + installGlobalHandler: function ( window ) { + // We will preserve the return value of the previous handler. window.onerror works the + // opposite way than normal event handlers (returning true will prevent the default + // action, returning false will let the browser handle the error normally, by e.g. + // logging to the console), so our fallback old handler needs to return false. + var oldHandler = window.onerror || function () { return false; }; + + /** + * Dumb window.onerror handler which forwards the errors via mw.track. + * @fires global_error + */ + window.onerror = function ( errorMessage, url, lineNumber, columnNumber, errorObject ) { + mw.track( 'global.error', { errorMessage: errorMessage, url: url, + lineNumber: lineNumber, columnNumber: columnNumber, errorObject: errorObject } ); + return oldHandler.apply( this, arguments ); + }; + } + }; + + mw.errorLogger.installGlobalHandler( window ); +}( mediaWiki ) ); diff --git a/resources/src/mediawiki/mediawiki.feedback.css b/resources/src/mediawiki/mediawiki.feedback.css index 6bd47bb2..f2859db3 100644 --- a/resources/src/mediawiki/mediawiki.feedback.css +++ b/resources/src/mediawiki/mediawiki.feedback.css @@ -7,3 +7,16 @@ width: 18px; height: 18px; } + +.mw-feedbackDialog-welcome-message, +.mw-feedbackDialog-feedback-terms { + line-height: 1.2em; +} + +.mw-feedbackDialog-feedback-form { + margin-top: 1em; +} + +.mw-feedbackDialog-feedback-termsofuse { + margin-left: 2.5em; +} diff --git a/resources/src/mediawiki/mediawiki.feedback.js b/resources/src/mediawiki/mediawiki.feedback.js index 1c0d8332..d9401001 100644 --- a/resources/src/mediawiki/mediawiki.feedback.js +++ b/resources/src/mediawiki/mediawiki.feedback.js @@ -3,8 +3,11 @@ * * @author Ryan Kaldari, 2010 * @author Neil Kandalgaonkar, 2010-11 + * @author Moriel Schottlender, 2015 * @since 1.19 */ +/*jshint es3:false */ +/*global OO*/ ( function ( mw, $ ) { /** * This is a way of getting simple feedback from users. It's useful @@ -32,289 +35,469 @@ * * @class * @constructor - * @param {Object} [options] - * @param {mw.Api} [options.api] if omitted, will just create a standard API - * @param {mw.Title} [options.title="Feedback"] The title of the page where you collect - * feedback. - * @param {string} [options.dialogTitleMessageKey="feedback-submit"] Message key for the - * title of the dialog box - * @param {string} [options.bugsLink="//bugzilla.wikimedia.org/enter_bug.cgi"] URL where - * bugs can be posted - * @param {mw.Uri|string} [options.bugsListLink="//bugzilla.wikimedia.org/query.cgi"] - * URL where bugs can be listed + * @param {Object} [config] Configuration object + * @cfg {mw.Title} [title="Feedback"] The title of the page where you collect + * feedback. + * @cfg {string} [dialogTitleMessageKey="feedback-dialog-title"] Message key for the + * title of the dialog box + * @cfg {mw.Uri|string} [bugsLink="//phabricator.wikimedia.org/maniphest/task/create/"] URL where + * bugs can be posted + * @cfg {mw.Uri|string} [bugsListLink="//phabricator.wikimedia.org/maniphest/query/advanced"] URL + * where bugs can be listed + * @cfg {boolean} [showUseragentCheckbox=false] Show a Useragent agreement checkbox as part of the form. + * @cfg {boolean} [useragentCheckboxMandatory=false] Make the Useragent checkbox mandatory. + * @cfg {string|jQuery} [useragentCheckboxMessage] Supply a custom message for the useragent checkbox. + * defaults to the message 'feedback-terms'. */ - mw.Feedback = function ( options ) { - if ( options === undefined ) { - options = {}; - } + mw.Feedback = function MwFeedback( config ) { + config = config || {}; - if ( options.api === undefined ) { - options.api = new mw.Api(); - } + this.dialogTitleMessageKey = config.dialogTitleMessageKey || 'feedback-dialog-title'; - if ( options.title === undefined ) { - options.title = new mw.Title( 'Feedback' ); - } + // Feedback page title + this.feedbackPageTitle = config.title || new mw.Title( 'Feedback' ); - if ( options.dialogTitleMessageKey === undefined ) { - options.dialogTitleMessageKey = 'feedback-submit'; - } + this.messagePosterPromise = mw.messagePoster.factory.create( this.feedbackPageTitle ); - if ( options.bugsLink === undefined ) { - options.bugsLink = '//bugzilla.wikimedia.org/enter_bug.cgi'; - } + // Links + this.bugsTaskSubmissionLink = config.bugsLink || '//phabricator.wikimedia.org/maniphest/task/create/'; + this.bugsTaskListLink = config.bugsListLink || '//phabricator.wikimedia.org/maniphest/query/advanced'; - if ( options.bugsListLink === undefined ) { - options.bugsListLink = '//bugzilla.wikimedia.org/query.cgi'; - } + // Terms of use + this.useragentCheckboxShow = !!config.showUseragentCheckbox; + this.useragentCheckboxMandatory = !!config.useragentCheckboxMandatory; + this.useragentCheckboxMessage = config.useragentCheckboxMessage || + $( '<p>' ).append( mw.msg( 'feedback-terms' ) ); - $.extend( this, options ); - this.setup(); + // Message dialog + this.thankYouDialog = new OO.ui.MessageDialog(); }; - mw.Feedback.prototype = { - /** - * Sets up interface - */ - setup: function () { - var $feedbackPageLink, - $bugNoteLink, - $bugsListLink, - fb = this; - - $feedbackPageLink = $( '<a>' ) - .attr( { - href: fb.title.getUrl(), - target: '_blank' - } ) - .css( { - whiteSpace: 'nowrap' - } ); + /* Initialize */ + OO.initClass( mw.Feedback ); - $bugNoteLink = $( '<a>' ).attr( { href: '#' } ).click( function () { - fb.displayBugs(); - } ); - - $bugsListLink = $( '<a>' ).attr( { - href: fb.bugsListLink, - target: '_blank' - } ); - - // TODO: Use a stylesheet instead of these inline styles - this.$dialog = - $( '<div style="position: relative;"></div>' ).append( - $( '<div class="feedback-mode feedback-form"></div>' ).append( - $( '<small>' ).append( - $( '<p>' ).msg( - 'feedback-bugornote', - $bugNoteLink, - fb.title.getNameText(), - $feedbackPageLink.clone() - ) - ), - $( '<div style="margin-top: 1em;"></div>' ) - .msg( 'feedback-subject' ) - .append( - $( '<br>' ), - $( '<input type="text" class="feedback-subject" name="subject" maxlength="60" style="width: 100%; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box;"/>' ) - ), - $( '<div style="margin-top: 0.4em;"></div>' ) - .msg( 'feedback-message' ) - .append( - $( '<br>' ), - $( '<textarea name="message" class="feedback-message" rows="5" cols="60"></textarea>' ) - ) - ), - $( '<div class="feedback-mode feedback-bugs"></div>' ).append( - $( '<p>' ).msg( 'feedback-bugcheck', $bugsListLink ) - ), - $( '<div class="feedback-mode feedback-submitting" style="text-align: center; margin: 3em 0;"></div>' ) - .msg( 'feedback-adding' ) - .append( - $( '<br>' ), - $( '<span class="feedback-spinner"></span>' ) - ), - $( '<div class="feedback-mode feedback-thanks" style="text-align: center; margin:1em"></div>' ).msg( - 'feedback-thanks', fb.title.getNameText(), $feedbackPageLink.clone() + /* Static Properties */ + mw.Feedback.static.windowManager = null; + mw.Feedback.static.dialog = null; + + /* Methods */ + + /** + * 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 + * to the external task reporting site. + */ + mw.Feedback.prototype.onDialogSubmit = function ( status ) { + var dialogConfig = {}; + switch ( status ) { + case 'submitted': + dialogConfig = { + title: mw.msg( 'feedback-thanks-title' ), + message: $( '<span>' ).append( + mw.message( + 'feedback-thanks', + this.feedbackPageTitle.getNameText(), + $( '<a>' ) + .attr( { + target: '_blank', + href: this.feedbackPageTitle.getUrl() + } ) + ).parse() ), - $( '<div class="feedback-mode feedback-error" style="position: relative;"></div>' ).append( - $( '<div class="feedback-error-msg style="color: #990000; margin-top: 0.4em;"></div>' ) - ) - ); + actions: [ + { + action: 'accept', + label: mw.msg( 'feedback-close' ), + flags: 'primary' + } + ] + }; + break; + case 'error1': + case 'error2': + case 'error3': + case 'error4': + dialogConfig = { + title: mw.msg( 'feedback-error-title' ), + message: mw.msg( 'feedback-' + status ), + actions: [ + { + action: 'accept', + label: mw.msg( 'feedback-close' ), + flags: 'primary' + } + ] + }; + break; + } - this.$dialog.dialog( { - width: 500, - autoOpen: false, - title: mw.message( this.dialogTitleMessageKey ).escaped(), - modal: true, - buttons: fb.buttons - } ); + // Show the message dialog + if ( !$.isEmptyObject( dialogConfig ) ) { + this.constructor.static.windowManager.openWindow( + this.thankYouDialog, + dialogConfig + ); + } + }; - this.subjectInput = this.$dialog.find( 'input.feedback-subject' ).get( 0 ); - this.messageInput = this.$dialog.find( 'textarea.feedback-message' ).get( 0 ); - }, + /** + * Modify the display form, and then open it, focusing interface on the subject. + * + * @param {Object} [contents] Prefilled contents for the feedback form. + * @param {string} [contents.subject] The subject of the feedback, as plaintext + * @param {string} [contents.message] The content of the feedback, as wikitext + */ + mw.Feedback.prototype.launch = function ( contents ) { + // Dialog + if ( !this.constructor.static.dialog ) { + this.constructor.static.dialog = new mw.Feedback.Dialog(); + this.constructor.static.dialog.connect( this, { submit: 'onDialogSubmit' } ); + } + if ( !this.constructor.static.windowManager ) { + this.constructor.static.windowManager = new OO.ui.WindowManager(); + this.constructor.static.windowManager.addWindows( [ + this.constructor.static.dialog, + this.thankYouDialog + ] ); + $( 'body' ) + .append( this.constructor.static.windowManager.$element ); + } + // Open the dialog + this.constructor.static.windowManager.openWindow( + this.constructor.static.dialog, + { + title: mw.msg( this.dialogTitleMessageKey ), + settings: { + messagePosterPromise: this.messagePosterPromise, + title: this.feedbackPageTitle, + dialogTitleMessageKey: this.dialogTitleMessageKey, + bugsTaskSubmissionLink: this.bugsTaskSubmissionLink, + bugsTaskListLink: this.bugsTaskListLink, + useragentCheckbox: { + show: this.useragentCheckboxShow, + mandatory: this.useragentCheckboxMandatory, + message: this.useragentCheckboxMessage + } + }, + contents: contents + } + ); + }; - /** - * Displays a section of the dialog. - * - * @param {"form"|"bugs"|"submitting"|"thanks"|"error"} s - * The section of the dialog to show. - */ - display: function ( s ) { - // Hide the buttons - this.$dialog.dialog( { buttons: {} } ); - // Hide everything - this.$dialog.find( '.feedback-mode' ).hide(); - // Show the desired div - this.$dialog.find( '.feedback-' + s ).show(); - }, + /** + * mw.Feedback Dialog + * + * @class + * @extends OO.ui.ProcessDialog + * + * @constructor + * @param {Object} config Configuration object + */ + mw.Feedback.Dialog = function mwFeedbackDialog( config ) { + // Parent constructor + mw.Feedback.Dialog.super.call( this, config ); + + this.status = ''; + this.feedbackPageTitle = null; + // Initialize + this.$element.addClass( 'mwFeedback-Dialog' ); + }; - /** - * Display the submitting section. - */ - displaySubmitting: function () { - this.display( 'submitting' ); + OO.inheritClass( mw.Feedback.Dialog, OO.ui.ProcessDialog ); + + /* Static properties */ + mw.Feedback.Dialog.static.name = 'mwFeedbackDialog'; + mw.Feedback.Dialog.static.title = mw.msg( 'feedback-dialog-title' ); + mw.Feedback.Dialog.static.size = 'medium'; + mw.Feedback.Dialog.static.actions = [ + { + action: 'submit', + label: mw.msg( 'feedback-submit' ), + flags: [ 'primary', 'constructive' ] }, - - /** - * Display the bugs section. - */ - displayBugs: function () { - var fb = this, - bugsButtons = {}; - - this.display( 'bugs' ); - bugsButtons[ mw.msg( 'feedback-bugnew' ) ] = function () { - window.open( fb.bugsLink, '_blank' ); - }; - bugsButtons[ mw.msg( 'feedback-cancel' ) ] = function () { - fb.cancel(); - }; - this.$dialog.dialog( { - buttons: bugsButtons - } ); + { + action: 'external', + label: mw.msg( 'feedback-external-bug-report-button' ), + flags: 'constructive' }, + { + action: 'cancel', + label: mw.msg( 'feedback-cancel' ), + flags: 'safe' + } + ]; - /** - * Display the thanks section. - */ - displayThanks: function () { - var fb = this, - closeButton = {}; - - this.display( 'thanks' ); - closeButton[ mw.msg( 'feedback-close' ) ] = function () { - fb.$dialog.dialog( 'close' ); - }; - this.$dialog.dialog( { - buttons: closeButton - } ); - }, + /** + * @inheritdoc + */ + mw.Feedback.Dialog.prototype.initialize = function () { + var feedbackSubjectFieldLayout, feedbackMessageFieldLayout, + feedbackFieldsetLayout, termsOfUseLabel; + + // Parent method + mw.Feedback.Dialog.super.prototype.initialize.call( this ); + + this.feedbackPanel = new OO.ui.PanelLayout( { + scrollable: false, + expanded: false, + padded: true + } ); + + this.$spinner = $( '<div>' ) + .addClass( 'feedback-spinner' ); + + // Feedback form + this.feedbackMessageLabel = new OO.ui.LabelWidget( { + classes: [ 'mw-feedbackDialog-welcome-message' ] + } ); + this.feedbackSubjectInput = new OO.ui.TextInputWidget( { + multiline: false + } ); + this.feedbackMessageInput = new OO.ui.TextInputWidget( { + autosize: true, + multiline: true + } ); + feedbackSubjectFieldLayout = new OO.ui.FieldLayout( this.feedbackSubjectInput, { + label: mw.msg( 'feedback-subject' ) + } ); + feedbackMessageFieldLayout = new OO.ui.FieldLayout( this.feedbackMessageInput, { + label: mw.msg( 'feedback-message' ) + } ); + feedbackFieldsetLayout = new OO.ui.FieldsetLayout( { + items: [ feedbackSubjectFieldLayout, feedbackMessageFieldLayout ], + classes: [ 'mw-feedbackDialog-feedback-form' ] + } ); + + // Useragent terms of use + this.useragentCheckbox = new OO.ui.CheckboxInputWidget(); + this.useragentFieldLayout = new OO.ui.FieldLayout( this.useragentCheckbox, { + classes: [ 'mw-feedbackDialog-feedback-terms' ], + align: 'inline' + } ); + + termsOfUseLabel = new OO.ui.LabelWidget( { + classes: [ 'mw-feedbackDialog-feedback-termsofuse' ], + label: $( '<p>' ).append( mw.msg( 'feedback-termsofuse' ) ) + } ); + + this.feedbackPanel.$element.append( + this.feedbackMessageLabel.$element, + feedbackFieldsetLayout.$element, + this.useragentFieldLayout.$element, + termsOfUseLabel.$element + ); + + // Events + this.feedbackSubjectInput.connect( this, { change: 'validateFeedbackForm' } ); + this.feedbackMessageInput.connect( this, { change: 'validateFeedbackForm' } ); + this.feedbackMessageInput.connect( this, { change: 'updateSize' } ); + this.useragentCheckbox.connect( this, { change: 'validateFeedbackForm' } ); + + this.$body.append( this.feedbackPanel.$element ); + }; - /** - * Display the feedback form - * @param {Object} [contents] Prefilled contents for the feedback form. - * @param {string} [contents.subject] The subject of the feedback - * @param {string} [contents.message] The content of the feedback - */ - displayForm: function ( contents ) { - var fb = this, - formButtons = {}; - - this.subjectInput.value = ( contents && contents.subject ) ? contents.subject : ''; - this.messageInput.value = ( contents && contents.message ) ? contents.message : ''; - - this.display( 'form' ); - - // Set up buttons for dialog box. We have to do it the hard way since the json keys are localized - formButtons[ mw.msg( 'feedback-submit' ) ] = function () { - fb.submit(); - }; - formButtons[ mw.msg( 'feedback-cancel' ) ] = function () { - fb.cancel(); - }; - this.$dialog.dialog( { buttons: formButtons } ); // put the buttons back - }, + /** + * Validate the feedback form + */ + mw.Feedback.Dialog.prototype.validateFeedbackForm = function () { + var isValid = ( + ( + !this.useragentMandatory || + this.useragentCheckbox.isSelected() + ) && + ( + !!this.feedbackMessageInput.getValue() || + !!this.feedbackSubjectInput.getValue() + ) + ); + + this.actions.setAbilities( { submit: isValid } ); + }; - /** - * Display an error on the form. - * - * @param {string} message Should be a valid message key. - */ - displayError: function ( message ) { - var fb = this, - closeButton = {}; - - this.display( 'error' ); - this.$dialog.find( '.feedback-error-msg' ).msg( message ); - closeButton[ mw.msg( 'feedback-close' ) ] = function () { - fb.$dialog.dialog( 'close' ); - }; - this.$dialog.dialog( { buttons: closeButton } ); - }, + /** + * @inheritdoc + */ + mw.Feedback.Dialog.prototype.getBodyHeight = function () { + return this.feedbackPanel.$element.outerHeight( true ); + }; - /** - * Close the feedback form. - */ - cancel: function () { - this.$dialog.dialog( 'close' ); - }, + /** + * @inheritdoc + */ + mw.Feedback.Dialog.prototype.getSetupProcess = function ( data ) { + return mw.Feedback.Dialog.super.prototype.getSetupProcess.call( this, data ) + .next( function () { + var plainMsg, parsedMsg, + settings = data.settings; + data.contents = data.contents || {}; + + // Prefill subject/message + this.feedbackSubjectInput.setValue( data.contents.subject ); + this.feedbackMessageInput.setValue( data.contents.message ); + + this.status = ''; + this.messagePosterPromise = settings.messagePosterPromise; + this.setBugReportLink( settings.bugsTaskSubmissionLink ); + this.feedbackPageTitle = settings.title; + this.feedbackPageName = settings.title.getNameText(); + this.feedbackPageUrl = settings.title.getUrl(); + + // Useragent checkbox + if ( settings.useragentCheckbox.show ) { + this.useragentFieldLayout.setLabel( settings.useragentCheckbox.message ); + } - /** - * Submit the feedback form. - */ - submit: function () { - var subject, message, - fb = this; - - // Get the values to submit. - subject = this.subjectInput.value; - - // We used to include "mw.html.escape( navigator.userAgent )" but there are legal issues - // with posting this without their explicit consent - message = this.messageInput.value; - if ( message.indexOf( '~~~' ) === -1 ) { - message += ' ~~~~'; - } + this.useragentMandatory = settings.useragentCheckbox.mandatory; + this.useragentFieldLayout.toggle( settings.useragentCheckbox.show ); + + // HACK: Setting a link in the messages doesn't work. There is already a report + // about this, and the bug report offers a somewhat hacky work around that + // includes setting a separate message to be parsed. + // We want to make sure the user can configure both the title of the page and + // a separate url, so this must be allowed to parse correctly. + // See https://phabricator.wikimedia.org/T49395#490610 + mw.messages.set( { + 'feedback-dialog-temporary-message': + '<a href="' + this.feedbackPageUrl + '" target="_blank">' + this.feedbackPageName + '</a>' + } ); + plainMsg = mw.message( 'feedback-dialog-temporary-message' ).plain(); + mw.messages.set( { 'feedback-dialog-temporary-message-parsed': plainMsg } ); + parsedMsg = mw.message( 'feedback-dialog-temporary-message-parsed' ); + this.feedbackMessageLabel.setLabel( + // Double-parse + $( '<span>' ) + .append( mw.message( 'feedback-dialog-intro', parsedMsg ).parse() ) + ); - this.displaySubmitting(); - - // Post the message, resolving redirects - this.api.newSection( - this.title, - subject, - message, - { redirect: true } - ) - .done( function ( result ) { - if ( result.edit !== undefined ) { - if ( result.edit.result === 'Success' ) { - fb.displayThanks(); - } else { - // unknown API result - fb.displayError( 'feedback-error1' ); - } - } else { - // edit failed - fb.displayError( 'feedback-error2' ); + this.validateFeedbackForm(); + }, this ); + }; + + /** + * @inheritdoc + */ + mw.Feedback.Dialog.prototype.getReadyProcess = function ( data ) { + return mw.Feedback.Dialog.super.prototype.getReadyProcess.call( this, data ) + .next( function () { + this.feedbackSubjectInput.focus(); + }, this ); + }; + + /** + * @inheritdoc + */ + mw.Feedback.Dialog.prototype.getActionProcess = function ( action ) { + if ( action === 'cancel' ) { + return new OO.ui.Process( function () { + this.close( { action: action } ); + }, this ); + } else if ( action === 'external' ) { + return new OO.ui.Process( function () { + // Open in a new window + window.open( this.getBugReportLink(), '_blank' ); + // Close the dialog + this.close(); + }, this ); + } else if ( action === 'submit' ) { + return new OO.ui.Process( function () { + var fb = this, + userAgentMessage = ':' + + '<small>' + + mw.msg( 'feedback-useragent' ) + + ' ' + + mw.html.escape( navigator.userAgent ) + + '</small>\n\n', + subject = this.feedbackSubjectInput.getValue(), + message = this.feedbackMessageInput.getValue(); + + // Add user agent if checkbox is selected + if ( this.useragentCheckbox.isSelected() ) { + message = userAgentMessage + message; } - } ) - .fail( function () { - // ajax request failed - fb.displayError( 'feedback-error3' ); - } ); - }, - /** - * Modify the display form, and then open it, focusing interface on the subject. - * @param {Object} [contents] Prefilled contents for the feedback form. - * @param {string} [contents.subject] The subject of the feedback - * @param {string} [contents.message] The content of the feedback - */ - launch: function ( contents ) { - this.displayForm( contents ); - this.$dialog.dialog( 'open' ); - this.subjectInput.focus(); + // Post the message + return this.messagePosterPromise.then( function ( poster ) { + return fb.postMessage( poster, subject, message ); + }, function () { + fb.status = 'error4'; + mw.log.warn( 'Feedback report failed because MessagePoster could not be fetched' ); + } ).always( function () { + fb.close(); + } ); + }, this ); } + // Fallback to parent handler + return mw.Feedback.Dialog.super.prototype.getActionProcess.call( this, action ); }; + + /** + * Posts the message + * + * @private + * + * @param {mw.messagePoster.MessagePoster} poster Poster implementation used to leave feedback + * @param {string} subject Subject of message + * @param {string} message Body of message + * @return {jQuery.Promise} Promise representing success of message posting action + */ + mw.Feedback.Dialog.prototype.postMessage = function ( poster, subject, message ) { + var fb = this; + + return poster.post( + subject, + message + ).then( function () { + fb.status = 'submitted'; + }, function ( mainCode, secondaryCode, details ) { + if ( mainCode === 'api-fail' ) { + if ( secondaryCode === 'http' ) { + fb.status = 'error3'; + // ajax request failed + mw.log.warn( 'Feedback report failed with HTTP error: ' + details.textStatus ); + } else { + fb.status = 'error2'; + mw.log.warn( 'Feedback report failed with API error: ' + secondaryCode ); + } + } else { + fb.status = 'error1'; + } + } ); + }; + + /** + * @inheritdoc + */ + mw.Feedback.Dialog.prototype.getTeardownProcess = function ( data ) { + return mw.Feedback.Dialog.super.prototype.getTeardownProcess.call( this, data ) + .first( function () { + this.emit( 'submit', this.status, this.feedbackPageName, this.feedbackPageUrl ); + // Cleanup + this.status = ''; + this.feedbackPageTitle = null; + this.feedbackSubjectInput.setValue( '' ); + this.feedbackMessageInput.setValue( '' ); + this.useragentCheckbox.setSelected( false ); + }, this ); + }; + + /** + * Set the bug report link + * @param {string} link Link to the external bug report form + */ + mw.Feedback.Dialog.prototype.setBugReportLink = function ( link ) { + this.bugReportLink = link; + }; + + /** + * Get the bug report link + * @returns {string} Link to the external bug report form + */ + mw.Feedback.Dialog.prototype.getBugReportLink = function () { + return this.bugReportLink; + }; + }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.filewarning.js b/resources/src/mediawiki/mediawiki.filewarning.js new file mode 100644 index 00000000..882affe1 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.filewarning.js @@ -0,0 +1,68 @@ +/*! + * mediawiki.filewarning + * + * @author Mark Holmquist, 2015 + * @since 1.25 + */ +/*global OO*/ +( function ( mw, $, oo ) { + var warningConfig = mw.config.get( 'wgFileWarning' ), + warningMessages = warningConfig.messages, + warningLink = warningConfig.link, + $origMimetype = $( '.fullMedia .fileInfo .mime-type' ), + $mimetype = $origMimetype.clone(), + $header = $( '<h3>' ) + .addClass( 'mediawiki-filewarning-header empty' ), + $main = $( '<p>' ) + .addClass( 'mediawiki-filewarning-main empty' ), + $info = $( '<a>' ) + .addClass( 'mediawiki-filewarning-info empty' ), + $footer = $( '<p>' ) + .addClass( 'mediawiki-filewarning-footer empty' ), + dialog = new oo.ui.PopupButtonWidget( { + classes: [ 'mediawiki-filewarning-anchor' ], + label: $mimetype, + flags: [ 'warning' ], + icon: 'alert', + framed: false, + popup: { + classes: [ 'mediawiki-filewarning' ], + padded: true, + width: 400, + $content: $header.add( $main ).add( $info ).add( $footer ) + } + } ); + + function loadMessage( $target, message ) { + if ( message ) { + $target.removeClass( 'empty' ) + .text( mw.message( message ).text() ); + } + } + + // The main message must be populated for the dialog to show. + if ( warningConfig && warningConfig.messages && warningConfig.messages.main ) { + $mimetype.addClass( 'has-warning' ); + + $origMimetype.replaceWith( dialog.$element ); + + if ( warningMessages ) { + loadMessage( $main, warningMessages.main ); + loadMessage( $header, warningMessages.header ); + loadMessage( $footer, warningMessages.footer ); + + if ( warningLink ) { + loadMessage( $info, warningMessages.info ); + $info.attr( 'href', warningLink ); + } + } + + // Make OOUI open the dialog, it won't appear until the user + // hovers over the warning. + dialog.getPopup().toggle( true ); + + // Override toggle handler because we don't need it for this popup + // object at all. Sort of nasty, but it gets the job done. + dialog.getPopup().toggle = $.noop; + } +}( mediaWiki, jQuery, OO ) ); diff --git a/resources/src/mediawiki/mediawiki.filewarning.less b/resources/src/mediawiki/mediawiki.filewarning.less new file mode 100644 index 00000000..489ac428 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.filewarning.less @@ -0,0 +1,29 @@ +@import "mediawiki.ui/variables" + +.mediawiki-filewarning { + display: none; + + .mediawiki-filewarning-header { + padding: 0; + font-weight: 600; + } + + .mediawiki-filewarning-footer { + color: #888888; + } + + .empty { + display: none; + } + + .mediawiki-filewarning-anchor:hover & { + display: block; + } +} + +.mime-type { + &.has-warning { + font-weight: bold; + color: @colorMediumSevere; + } +} diff --git a/resources/src/mediawiki/mediawiki.helplink.less b/resources/src/mediawiki/mediawiki.helplink.less new file mode 100644 index 00000000..dd6bf745 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.helplink.less @@ -0,0 +1,11 @@ +@import "mediawiki.mixins"; + +#mw-indicator-mw-helplink a { + .background-image-svg('images/help.svg', 'images/help.png'); + background-repeat: no-repeat; + background-position: left center; + padding-left: 28px; + display: inline-block; + height: 24px; + line-height: 24px; +} diff --git a/resources/src/mediawiki/mediawiki.hlist.js b/resources/src/mediawiki/mediawiki.hlist.js index 0bbf8fad..8ba57f6f 100644 --- a/resources/src/mediawiki/mediawiki.hlist.js +++ b/resources/src/mediawiki/mediawiki.hlist.js @@ -1,31 +1,15 @@ /*! - * .hlist fallbacks for IE 6, 7 and 8. + * .hlist fallbacks for IE 8. * @author [[User:Edokter]] */ ( function ( mw, $ ) { var profile = $.client.profile(); - if ( profile.name === 'msie' ) { - if ( profile.versionNumber === 8 ) { - /* IE 8: Add pseudo-selector class to last-child list items */ - mw.hook( 'wikipage.content' ).add( function ( $content ) { - $content.find( '.hlist' ).find( 'dd:last-child, dt:last-child, li:last-child' ) - .addClass( 'hlist-last-child' ); - } ); - } - else if ( profile.versionNumber <= 7 ) { - /* IE 7 and below: Generate interpuncts and parentheses */ - mw.hook( 'wikipage.content' ).add( function ( $content ) { - var $hlists = $content.find( '.hlist' ); - $hlists.find( 'dt:not(:last-child)' ) - .append( ': ' ); - $hlists.find( 'dd:not(:last-child)' ) - .append( '<b>·</b> ' ); - $hlists.find( 'li:not(:last-child)' ) - .append( '<b>·</b> ' ); - $hlists.find( 'dl dl, dl ol, dl ul, ol dl, ol ol, ol ul, ul dl, ul ol, ul ul' ) - .prepend( '( ' ).append( ') ' ); - } ); - } + if ( profile.name === 'msie' && profile.versionNumber === 8 ) { + /* Add pseudo-selector class to last-child list items */ + mw.hook( 'wikipage.content' ).add( function ( $content ) { + $content.find( '.hlist' ).find( 'dd:last-child, dt:last-child, li:last-child' ) + .addClass( 'hlist-last-child' ); + } ); } }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.htmlform.js b/resources/src/mediawiki/mediawiki.htmlform.js index 594800e1..4a4a97e9 100644 --- a/resources/src/mediawiki/mediawiki.htmlform.js +++ b/resources/src/mediawiki/mediawiki.htmlform.js @@ -237,7 +237,9 @@ } ); function enhance( $root ) { - var $matrixTooltips, $autocomplete; + var $matrixTooltips, $autocomplete, + // cache the separator to avoid object creation on each keypress + colonSeparator = mw.message( 'colon-separator' ).text(); /** * @ignore @@ -261,6 +263,36 @@ handleSelectOrOther.call( this, true ); } ); + // Add a dynamic max length to the reason field of SelectAndOther + // This checks the length together with the value from the select field + // When the reason list is changed and the bytelimit is longer than the allowed, + // nothing is done + $root + .find( '.mw-htmlform-select-and-other-field' ) + .each( function () { + var $this = $( this ), + // find the reason list + $reasonList = $root.find( '#' + $this.data( 'id-select' ) ), + // cache the current selection to avoid expensive lookup + currentValReasonList = $reasonList.val(); + + $reasonList.change( function () { + currentValReasonList = $reasonList.val(); + } ); + + $this.byteLimit( function ( input ) { + // Should be built the same as in HTMLSelectAndOtherField::loadDataFromRequest + var comment = currentValReasonList; + if ( comment === 'other' ) { + comment = input; + } else if ( input !== '' ) { + // Entry from drop down menu + additional comment + comment += colonSeparator + input; + } + return comment; + } ); + } ); + // Set up hide-if elements $root.find( '.mw-htmlform-hide-if' ).each( function () { var v, $fields, test, func, @@ -368,12 +400,12 @@ } // Add/remove cloner clones without having to resubmit the form - $root.find( '.mw-htmlform-cloner-delete-button' ).click( function ( ev ) { + $root.find( '.mw-htmlform-cloner-delete-button' ).filter( ':input' ).click( function ( ev ) { ev.preventDefault(); $( this ).closest( 'li.mw-htmlform-cloner-li' ).remove(); } ); - $root.find( '.mw-htmlform-cloner-create-button' ).click( function ( ev ) { + $root.find( '.mw-htmlform-cloner-create-button' ).filter( ':input' ).click( function ( ev ) { var $ul, $li, html; ev.preventDefault(); diff --git a/resources/src/mediawiki/mediawiki.inspect.js b/resources/src/mediawiki/mediawiki.inspect.js index 8e9fc89f..22d3cbb3 100644 --- a/resources/src/mediawiki/mediawiki.inspect.js +++ b/resources/src/mediawiki/mediawiki.inspect.js @@ -7,6 +7,9 @@ /*jshint devel:true */ ( function ( mw, $ ) { + var inspect, + hasOwn = Object.prototype.hasOwnProperty; + function sortByProperty( array, prop, descending ) { var order = descending ? -1 : 1; return array.sort( function ( a, b ) { @@ -16,16 +19,20 @@ function humanSize( bytes ) { if ( !$.isNumeric( bytes ) || bytes === 0 ) { return bytes; } - var i = 0, units = [ '', ' kB', ' MB', ' GB', ' TB', ' PB' ]; + var i = 0, + units = [ '', ' kB', ' MB', ' GB', ' TB', ' PB' ]; + for ( ; bytes >= 1024; bytes /= 1024 ) { i++; } - return bytes.toFixed( 1 ) + units[i]; + // Maintain one decimal for kB and above, but don't + // add ".0" for bytes. + return bytes.toFixed( i > 0 ? 1 : 0 ) + units[i]; } /** * @class mw.inspect * @singleton */ - var inspect = { + inspect = { /** * Return a map of all dependency relationships between loaded modules. @@ -34,16 +41,21 @@ * two properties, 'requires' and 'requiredBy'. */ getDependencyGraph: function () { - var modules = inspect.getLoadedModules(), graph = {}; + var modules = inspect.getLoadedModules(), + graph = {}; $.each( modules, function ( moduleIndex, moduleName ) { var dependencies = mw.loader.moduleRegistry[moduleName].dependencies || []; - graph[moduleName] = graph[moduleName] || { requiredBy: [] }; + if ( !hasOwn.call( graph, moduleName ) ) { + graph[moduleName] = { requiredBy: [] }; + } graph[moduleName].requires = dependencies; $.each( dependencies, function ( depIndex, depName ) { - graph[depName] = graph[depName] || { requiredBy: [] }; + if ( !hasOwn.call( graph, depName ) ) { + graph[depName] = { requiredBy: [] }; + } graph[depName].requiredBy.push( moduleName ); } ); } ); diff --git a/resources/src/mediawiki/mediawiki.jqueryMsg.js b/resources/src/mediawiki/mediawiki.jqueryMsg.js index ad71b083..79939f64 100644 --- a/resources/src/mediawiki/mediawiki.jqueryMsg.js +++ b/resources/src/mediawiki/mediawiki.jqueryMsg.js @@ -136,7 +136,7 @@ * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements). * e.g. * - * window.gM = mediaWiki.parser.getMessageFunction( options ); + * window.gM = mediaWiki.jqueryMsg.getMessageFunction( options ); * $( 'p#headline' ).html( gM( 'hello-user', username ) ); * * Like the old gM() function this returns only strings, so it destroys any bindings. If you want to preserve bindings use the @@ -178,7 +178,7 @@ * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links. * e.g. * - * $.fn.msg = mediaWiki.parser.getJqueryPlugin( options ); + * $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options ); * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } ); * $( 'p#headline' ).msg( 'hello-user', userlink ); * @@ -267,7 +267,8 @@ * @return {string|Array} string of '[key]' if message missing, simple string if possible, array of arrays if needs parsing */ getAst: function ( key ) { - var cacheKey = [key, this.settings.onlyCurlyBraceTransform].join( ':' ), wikiText; + var wikiText, + cacheKey = [key, this.settings.onlyCurlyBraceTransform].join( ':' ); if ( this.astCache[ cacheKey ] === undefined ) { wikiText = this.settings.messages.get( key ); @@ -290,7 +291,7 @@ * @return {Mixed} abstract syntax tree */ wikiTextToAst: function ( input ) { - var pos, settings = this.settings, concat = Array.prototype.concat, + var pos, regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets, doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral, escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral, @@ -298,7 +299,9 @@ htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag, openExtlink, closeExtlink, wikilinkPage, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon, templateContents, openTemplate, closeTemplate, - nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result; + nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result, + settings = this.settings, + concat = Array.prototype.concat; // Indicates current position in input as we parse through it. // Shared among all parsing functions below. @@ -686,10 +689,10 @@ // Subset of allowed HTML markup. // Most elements and many attributes allowed on the server are not supported yet. function html() { - var result = null, parsedOpenTagResult, parsedHtmlContents, - parsedCloseTagResult, wrappedAttributes, attributes, - startTagName, endTagName, startOpenTagPos, startCloseTagPos, - endOpenTagPos, endCloseTagPos; + var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult, + wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos, + startCloseTagPos, endOpenTagPos, endCloseTagPos, + result = null; // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match. // 1. open through closeHtmlTag @@ -1015,16 +1018,11 @@ page = nodes[0]; url = mw.util.getUrl( page ); - // [[Some Page]] or [[Namespace:Some Page]] if ( nodes.length === 1 ) { + // [[Some Page]] or [[Namespace:Some Page]] anchor = page; - } - - /* - * [[Some Page|anchor text]] or - * [[Namespace:Some Page|anchor] - */ - else { + } else { + // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]] anchor = nodes[1]; } @@ -1129,17 +1127,42 @@ * @return {string} selected pluralized form according to current language */ plural: function ( nodes ) { - var forms, formIndex, node, count; + var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count, + explicitPluralForms = {}; + count = parseFloat( this.language.convertNumber( nodes[0], true ) ); forms = nodes.slice( 1 ); for ( formIndex = 0; formIndex < forms.length; formIndex++ ) { - node = forms[formIndex]; - if ( node.jquery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) { - // This is a nested node, already expanded. - forms[formIndex] = forms[formIndex].html(); + form = forms[formIndex]; + + if ( form.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 ); + // 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; + } + } + } 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; } } - return forms.length ? this.language.convertPlural( count, forms ) : ''; + + // Remove explicit plural forms from the forms. They were set undefined in the above loop. + forms = $.map( forms, function ( form ) { + return form; + } ); + + return this.language.convertPlural( count, forms, explicitPluralForms ); }, /** diff --git a/resources/src/mediawiki/mediawiki.js b/resources/src/mediawiki/mediawiki.js index e29c734d..ee57c21f 100644 --- a/resources/src/mediawiki/mediawiki.js +++ b/resources/src/mediawiki/mediawiki.js @@ -1,7 +1,7 @@ /** * Base library for MediaWiki. * - * Exposed as globally as `mediaWiki` with `mw` as shortcut. + * Exposed globally as `mediaWiki` with `mw` as shortcut. * * @class mw * @alternateClassName mediaWiki @@ -10,8 +10,6 @@ ( function ( $ ) { 'use strict'; - /* Private Members */ - var mw, hasOwn = Object.prototype.hasOwnProperty, slice = Array.prototype.slice, @@ -19,87 +17,104 @@ trackQueue = []; /** - * Log a message to window.console, if possible. Useful to force logging of some - * errors that are otherwise hard to detect (I.e., this logs also in production mode). - * Gets console references in each invocation, so that delayed debugging tools work - * fine. No need for optimization here, which would only result in losing logs. - * - * @private - * @method log_ - * @param {string} msg text for the log entry. - * @param {Error} [e] - */ - function log( msg, e ) { - var console = window.console; - if ( console && console.log ) { - console.log( msg ); - // If we have an exception object, log it through .error() to trigger - // proper stacktraces in browsers that support it. There are no (known) - // browsers that don't support .error(), that do support .log() and - // have useful exception handling through .log(). - if ( e && console.error ) { - console.error( String( e ), e ); - } - } - } - - /* Object constructors */ - - /** - * Creates an object that can be read from or written to from prototype functions - * that allow both single and multiple variables at once. + * Create an object that can be read from or written to from methods that allow + * interaction both with single and multiple properties at once. * * @example * - * var addies, wanted, results; + * var collection, query, results; * * // Create your address book - * addies = new mw.Map(); + * collection = new mw.Map(); * * // This data could be coming from an external source (eg. API/AJAX) - * addies.set( { - * 'John Doe' : '10 Wall Street, New York, USA', - * 'Jane Jackson' : '21 Oxford St, London, UK', - * 'Dominique van Halen' : 'Kalverstraat 7, Amsterdam, NL' + * collection.set( { + * 'John Doe': 'john@example.org', + * 'Jane Doe': 'jane@example.org', + * 'George van Halen': 'gvanhalen@example.org' * } ); * - * wanted = ['Dominique van Halen', 'George Johnson', 'Jane Jackson']; + * wanted = ['John Doe', 'Jane Doe', 'Daniel Jackson']; * * // You can detect missing keys first - * if ( !addies.exists( wanted ) ) { - * // One or more are missing (in this case: "George Johnson") + * if ( !collection.exists( wanted ) ) { + * // One or more are missing (in this case: "Daniel Jackson") * mw.log( 'One or more names were not found in your address book' ); * } * - * // Or just let it give you what it can - * results = addies.get( wanted, 'Middle of Nowhere, Alaska, US' ); - * mw.log( results['Jane Jackson'] ); // "21 Oxford St, London, UK" - * mw.log( results['George Johnson'] ); // "Middle of Nowhere, Alaska, US" + * // Or just let it give you what it can. Optionally fill in from a default. + * results = collection.get( wanted, 'nobody@example.com' ); + * mw.log( results['Jane Doe'] ); // "jane@example.org" + * mw.log( results['Daniel Jackson'] ); // "nobody@example.com" * * @class mw.Map * * @constructor - * @param {Object|boolean} [values] Value-bearing object to map, or boolean - * true to map over the global object. Defaults to an empty object. + * @param {Object|boolean} [values] The value-baring object to be mapped. Defaults to an + * empty object. + * For backwards-compatibility with mw.config, this can also be `true` in which case values + * are copied to the Window object as global variables (T72470). Values are copied in + * one direction only. Changes to globals are not reflected in the map. */ function Map( values ) { - this.values = values === true ? window : ( values || {} ); - return this; + if ( values === true ) { + this.values = {}; + + // Override #set to also set the global variable + this.set = function ( selection, value ) { + var s; + + if ( $.isPlainObject( selection ) ) { + for ( s in selection ) { + setGlobalMapValue( this, s, selection[s] ); + } + return true; + } + if ( typeof selection === 'string' && arguments.length ) { + setGlobalMapValue( this, selection, value ); + return true; + } + return false; + }; + + return; + } + + this.values = values || {}; + } + + /** + * Alias property to the global object. + * + * @private + * @static + * @param {mw.Map} map + * @param {string} key + * @param {Mixed} value + */ + function setGlobalMapValue( map, 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.' + ); } Map.prototype = { /** - * Get the value of one or multiple a keys. + * Get the value of one or more keys. * - * If called with no arguments, all values will be returned. + * If called with no arguments, all values are returned. * - * @param {string|Array} selection String key or array of keys to get values for. - * @param {Mixed} [fallback] Value to use in case key(s) do not exist. - * @return mixed If selection was a string returns the value or null, - * If selection was an array, returns an object of key/values (value is null if not found), - * If selection was not passed or invalid, will return the 'values' object member (be careful as - * objects are always passed by reference in JavaScript!). - * @return {string|Object|null} Values as a string or object, null if invalid/inexistant. + * @param {string|Array} [selection] Key or array of keys to retrieve values for. + * @param {Mixed} [fallback=null] Value for keys that don't exist. + * @return {Mixed|Object| null} If selection was a string, returns the value, + * If selection was an array, returns an object of key/values. + * If no selection is passed, the 'values' container is returned. (Beware that, + * as is the default in JavaScript, the object is returned by reference.) */ get: function ( selection, fallback ) { var results, i; @@ -127,16 +142,16 @@ return this.values; } - // invalid selection key + // Invalid selection key return null; }, /** - * Sets one or multiple key/value pairs. + * Set one or more key/value pairs. * - * @param {string|Object} selection String key to set value for, or object mapping keys to values. + * @param {string|Object} selection Key to set value for, or object mapping keys to values * @param {Mixed} [value] Value to set (optional, only in use when key is a string) - * @return {Boolean} This returns true on success, false on failure. + * @return {boolean} True on success, false on failure */ set: function ( selection, value ) { var s; @@ -155,10 +170,10 @@ }, /** - * Checks if one or multiple keys exist. + * Check if one or more keys exist. * - * @param {Mixed} selection String key or array of keys to check - * @return {boolean} Existence of key(s) + * @param {Mixed} selection Key or array of keys to check + * @return {boolean} True if the key(s) exist */ exists: function ( selection ) { var s; @@ -230,7 +245,7 @@ * @class mw.Message * * @constructor - * @param {mw.Map} map Message storage + * @param {mw.Map} map Message store * @param {string} key * @param {Array} [parameters] */ @@ -244,24 +259,22 @@ Message.prototype = { /** - * Simple message parser, does $N replacement and nothing else. + * Get parsed contents of the message. * + * The default parser does simple $N replacements and nothing else. * This may be overridden to provide a more complex message parser. - * - * The primary override is in mediawiki.jqueryMsg. + * The primary override is in the mediawiki.jqueryMsg module. * * This function will not be called for nonexistent messages. + * + * @return {string} Parsed message */ parser: function () { - var parameters = this.parameters; - return this.map.get( this.key ).replace( /\$(\d+)/g, function ( str, match ) { - var index = parseInt( match, 10 ) - 1; - return parameters[index] !== undefined ? parameters[index] : '$' + match; - } ); + return mw.format.apply( null, [ this.map.get( this.key ) ].concat( this.parameters ) ); }, /** - * Appends (does not replace) parameters for replacement to the .parameters property. + * Add (does not replace) parameters for `N$` placeholder values. * * @param {Array} parameters * @chainable @@ -275,9 +288,10 @@ }, /** - * Converts message object to its string form based on the state of format. + * Convert message object to its string form based on current format. * - * @return {string} Message as a string in the current form or `<key>` if key does not exist. + * @return {string} Message as a string in the current form, or `<key>` if key + * does not exist. */ toString: function () { var text; @@ -304,7 +318,7 @@ }, /** - * Changes format to 'parse' and converts message to string + * Change format to 'parse' and convert message to string * * If jqueryMsg is loaded, this parses the message text from wikitext * (where supported) to HTML @@ -319,7 +333,7 @@ }, /** - * Changes format to 'plain' and converts message to string + * Change format to 'plain' and convert message to string * * This substitutes parameters, but otherwise does not change the * message text. @@ -332,12 +346,14 @@ }, /** - * Changes format to 'text' and converts message to string + * Change format to 'text' and convert message to string * * If jqueryMsg is loaded, {{-transformation is done where supported * (such as {{plural:}}, {{gender:}}, {{int:}}). * - * Otherwise, it is equivalent to plain. + * Otherwise, it is equivalent to plain + * + * @return {string} String form of text message */ text: function () { this.format = 'text'; @@ -345,9 +361,9 @@ }, /** - * Changes the format to 'escaped' and converts message to string + * Change the format to 'escaped' and convert message to string * - * This is equivalent to using the 'text' format (see text method), then + * This is equivalent to using the 'text' format (see #text), then * HTML-escaping the output. * * @return {string} String form of html escaped message @@ -358,7 +374,7 @@ }, /** - * Checks if message exists + * Check if a message exists * * @see mw.Map#exists * @return {boolean} @@ -372,7 +388,6 @@ * @class mw */ mw = { - /* Public Members */ /** * Get the current time, measured in milliseconds since January 1, 1970 (UTC). @@ -392,6 +407,24 @@ }() ), /** + * Format a string. Replace $1, $2 ... $N with positional arguments. + * + * Used by Message#parser(). + * + * @since 1.25 + * @param {string} fmt Format string + * @param {Mixed...} parameters Values for $N replacements + * @return {string} Formatted string + */ + format: function ( formatString ) { + 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; + } ); + }, + + /** * Track an analytic event. * * This method provides a generic means for MediaWiki JavaScript code to capture state @@ -413,7 +446,7 @@ }, /** - * Register a handler for subset of analytic events, specified by topic + * Register a handler for subset of analytic events, specified by topic. * * Handlers will be called once for each tracked event, including any events that fired before the * handler was registered; 'this' is set to a plain object with a 'timeStamp' property indicating @@ -423,6 +456,8 @@ * * @param {string} topic Handle events whose name starts with this string prefix * @param {Function} callback Handler to call for each matching tracked event + * @param {string} callback.topic + * @param {Object} [callback.data] */ trackSubscribe: function ( topic, callback ) { var seen = 0; @@ -438,14 +473,14 @@ } ); }, - // Make the Map constructor publicly available. + // Expose Map constructor Map: Map, - // Make the Message constructor publicly available. + // Expose Message constructor Message: Message, /** - * Map of configuration values + * Map of configuration values. * * Check out [the complete list of configuration values](https://www.mediawiki.org/wiki/Manual:Interface/JavaScript#mw.config) * on mediawiki.org. @@ -455,11 +490,14 @@ * * @property {mw.Map} config */ - // Dummy placeholder. Re-assigned in ResourceLoaderStartupModule to an instance of `mw.Map`. + // Dummy placeholder later assigned in ResourceLoaderStartUpModule config: null, /** - * Empty object that plugins can be installed in. + * Empty object for third-party libraries, for cases where you don't + * want to add a new global, or the global is bad and needs containment + * or wrapping. + * * @property */ libs: {}, @@ -478,12 +516,18 @@ legacy: {}, /** - * Localization system + * Store for messages. + * * @property {mw.Map} */ messages: new Map(), - /* Public Methods */ + /** + * Store for templates associated with a module. + * + * @property {mw.Map} + */ + templates: new Map(), /** * Get a message object. @@ -492,11 +536,10 @@ * * @see mw.Message * @param {string} key Key of message to get - * @param {Mixed...} parameters Parameters for the $N replacements in messages. + * @param {Mixed...} parameters Values for $N replacements * @return {mw.Message} */ message: function ( key ) { - // Variadic arguments var parameters = slice.call( arguments, 1 ); return new Message( mw.messages, key, parameters ); }, @@ -508,7 +551,7 @@ * * @see mw.Message * @param {string} key Key of message to get - * @param {Mixed...} parameters Parameters for the $N replacements in messages. + * @param {Mixed...} parameters Values for $N replacements * @return {string} */ msg: function () { @@ -532,7 +575,7 @@ /** * Write a message the console's warning channel. * Also logs a stacktrace for easier debugging. - * Each action is silently ignored if the browser doesn't support it. + * Actions not supported by the browser console are silently ignored. * * @param {string...} msg Messages to output to console */ @@ -553,12 +596,14 @@ * @param {Object} obj Host object of deprecated property * @param {string} key Name of property to create in `obj` * @param {Mixed} val The value this property should return when accessed - * @param {string} [msg] Optional text to include in the deprecation message. + * @param {string} [msg] Optional text to include in the deprecation message */ log.deprecate = !Object.defineProperty ? function ( obj, key, val ) { obj[key] = val; } : function ( obj, key, val, msg ) { msg = 'Use of "' + key + '" is deprecated.' + ( msg ? ( ' ' + msg ) : '' ); + // Support: IE8 + // Can throw on Object.defineProperty. try { Object.defineProperty( obj, key, { configurable: true, @@ -575,7 +620,7 @@ } } ); } catch ( err ) { - // IE8 can throw on Object.defineProperty + // Fallback to creating a copy of the value to the object. obj[key] = val; } }; @@ -584,42 +629,74 @@ }() ), /** - * Client-side module loader which integrates with the MediaWiki ResourceLoader + * Client for ResourceLoader server end point. + * + * This client is in charge of maintaining the module registry and state + * machine, initiating network (batch) requests for loading modules, as + * well as dependency resolution and execution of source code. + * + * For more information, refer to + * <https://www.mediawiki.org/wiki/ResourceLoader/Features> + * * @class mw.loader * @singleton */ loader: ( function () { - /* Private Members */ + /** + * Fired via mw.track on various resource loading errors. + * + * @event resourceloader_exception + * @param {Error|Mixed} e The error that was thrown. Almost always an Error + * object, but in theory module code could manually throw something else, and that + * might also end up here. + * @param {string} [module] Name of the module which caused the error. Omitted if the + * error is not module-related or the module cannot be easily identified due to + * batched handling. + * @param {string} source Source of the error. Possible values: + * + * - style: stylesheet error (only affects old IE where a special style loading method + * is used) + * - load-callback: exception thrown by user callback + * - module-execute: exception thrown by module code + * - store-eval: could not evaluate module code cached in localStorage + * - store-localstorage-init: localStorage or JSON parse error in mw.loader.store.init + * - store-localstorage-json: JSON conversion error in mw.loader.store.set + * - store-localstorage-update: localStorage or JSON conversion error in mw.loader.store.update + */ /** - * Mapping of registered modules + * Fired via mw.track on resource loading error conditions. + * + * @event resourceloader_assert + * @param {string} source Source of the error. Possible values: * - * The jquery module is pre-registered, because it must have already - * been provided for this object to have been built, and in debug mode - * jquery would have been provided through a unique loader request, - * making it impossible to hold back registration of jquery until after - * mediawiki. + * - bug-T59567: failed to cache script due to an Opera function -> string conversion + * bug; see <https://phabricator.wikimedia.org/T59567> for details + */ + + /** + * Mapping of registered modules. * - * For exact details on support for script, style and messages, look at - * mw.loader.implement. + * See #implement for exact details on support for script, style and messages. * * Format: + * * { * 'moduleName': { - * // At registry - * 'version': ############## (unix timestamp), + * // From startup mdoule + * 'version': ############## (unix timestamp) * 'dependencies': ['required.foo', 'bar.also', ...], (or) function () {} - * 'group': 'somegroup', (or) null, - * 'source': 'local', 'someforeignwiki', (or) null - * 'state': 'registered', 'loaded', 'loading', 'ready', 'error' or 'missing' + * 'group': 'somegroup', (or) null + * 'source': 'local', (or) 'anotherwiki' * 'skip': 'return !!window.Example', (or) null + * 'state': 'registered', 'loaded', 'loading', 'ready', 'error', or 'missing' * * // Added during implementation - * 'skipped': true, - * 'script': ..., - * 'style': ..., - * 'messages': { 'key': 'value' }, + * 'skipped': true + * 'script': ... + * 'style': ... + * 'messages': { 'key': 'value' } * } * } * @@ -627,32 +704,37 @@ * @private */ var registry = {}, - // // Mapping of sources, keyed by source-id, values are strings. + // // Format: - // { - // 'sourceId': 'http://foo.bar/w/load.php' - // } + // + // { + // 'sourceId': 'http://example.org/w/load.php' + // } // sources = {}, + // List of modules which will be loaded as when ready batch = [], + // List of modules to be loaded queue = [], + // List of callback functions waiting for modules to be ready to be called jobs = [], + // Selector cache for the marker element. Use getMarker() to get/use the marker! $marker = null, - // Buffer for addEmbeddedCSS. + + // Buffer for #addEmbeddedCSS cssBuffer = '', - // Callbacks for addEmbeddedCSS. - cssCallbacks = $.Callbacks(); - /* Private methods */ + // Callbacks for #addEmbeddedCSS + cssCallbacks = $.Callbacks(); function getMarker() { - // Cached if ( !$marker ) { + // Cache $marker = $( 'meta[name="ResourceLoaderDynamicStyles"]' ); if ( !$marker.length ) { mw.log( 'No <meta name="ResourceLoaderDynamicStyles"> found, inserting dynamically' ); @@ -663,60 +745,35 @@ } /** - * Create a new style tag and add it to the DOM. + * Create a new style element and add it to the DOM. * * @private * @param {string} text CSS text - * @param {HTMLElement|jQuery} [nextnode=document.head] The element where the style tag should be - * inserted before. Otherwise it will be appended to `<head>`. - * @return {HTMLElement} Reference to the created `<style>` element. + * @param {HTMLElement|jQuery} [nextnode=document.head] The element where the style tag + * should be inserted before + * @return {HTMLElement} Reference to the created style element */ function newStyleTag( text, nextnode ) { var s = document.createElement( 'style' ); - // Insert into document before setting cssText (bug 33305) + // Support: IE + // Must attach to document before setting cssText (bug 33305) if ( nextnode ) { - // Must be inserted with native insertBefore, not $.fn.before. - // When using jQuery to insert it, like $nextnode.before( s ), - // then IE6 will throw "Access is denied" when trying to append - // to .cssText later. Some kind of weird security measure. - // http://stackoverflow.com/q/12586482/319266 - // Works: jsfiddle.net/zJzMy/1 - // Fails: jsfiddle.net/uJTQz - // Works again: http://jsfiddle.net/Azr4w/ (diff: the next 3 lines) - if ( nextnode.jquery ) { - nextnode = nextnode.get( 0 ); - } - nextnode.parentNode.insertBefore( s, nextnode ); + $( nextnode ).before( s ); } else { document.getElementsByTagName( 'head' )[0].appendChild( s ); } if ( s.styleSheet ) { - // IE + // Support: IE6-10 + // Old IE ignores appended text nodes, access stylesheet directly. s.styleSheet.cssText = text; } else { - // Other browsers. - // (Safari sometimes borks on non-string values, - // play safe by casting to a string, just in case.) - s.appendChild( document.createTextNode( String( text ) ) ); + // Standard behaviour + s.appendChild( document.createTextNode( text ) ); } return s; } /** - * Checks whether it is safe to add this css to a stylesheet. - * - * @private - * @param {string} cssText - * @return {boolean} False if a new one must be created. - */ - function canExpandStylesheetWith( cssText ) { - // Makes sure that cssText containing `@import` - // rules will end up in a new stylesheet (as those only work when - // placed at the start of a stylesheet; bug 35562). - return cssText.indexOf( '@import' ) === -1; - } - - /** * Add a bit of CSS text to the current browser page. * * The CSS will be appended to an existing ResourceLoader-created `<style>` tag @@ -736,16 +793,18 @@ // Yield once before inserting the <style> tag. There are likely // more calls coming up which we can combine this way. // Appending a stylesheet and waiting for the browser to repaint - // is fairly expensive, this reduces it (bug 45810) + // is fairly expensive, this reduces that (bug 45810) if ( cssText ) { - // Be careful not to extend the buffer with css that needs a new stylesheet - if ( !cssBuffer || canExpandStylesheetWith( cssText ) ) { + // Be careful not to extend the buffer with css that needs a new stylesheet. + // cssText containing `@import` rules needs to go at the start of a buffer, + // since those only work when placed at the start of a stylesheet; bug 35562. + if ( !cssBuffer || cssText.slice( 0, '@import'.length ) !== '@import' ) { // Linebreak for somewhat distinguishable sections // (the rl-cachekey comment separating each) cssBuffer += '\n' + cssText; // TODO: Use requestAnimationFrame in the future which will // perform even better by not injecting styles while the browser - // is paiting. + // is painting. setTimeout( function () { // Can't pass addEmbeddedCSS to setTimeout directly because Firefox // (below version 13) has the non-standard behaviour of passing a @@ -760,8 +819,9 @@ } else if ( cssBuffer ) { cssText = cssBuffer; cssBuffer = ''; + } else { - // This is a delayed call, but buffer is already cleared by + // This is a delayed call, but buffer was already cleared by // another delayed call. return; } @@ -774,21 +834,22 @@ if ( 'documentMode' in document && document.documentMode <= 9 ) { $style = getMarker().prev(); - // Verify that the the element before Marker actually is a + // 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 ) { // There's already a dynamic <style> tag present and - // canExpandStylesheetWith() gave a green light to append more to it. + // we are able to append more to it. styleEl = $style.get( 0 ); + // Support: IE6-10 if ( styleEl.styleSheet ) { try { - styleEl.styleSheet.cssText += cssText; // IE + styleEl.styleSheet.cssText += cssText; } catch ( e ) { - log( 'Stylesheet error', e ); + mw.track( 'resourceloader.exception', { exception: e, source: 'stylesheet' } ); } } else { - styleEl.appendChild( document.createTextNode( String( cssText ) ) ); + styleEl.appendChild( document.createTextNode( cssText ) ); } cssCallbacks.fire().empty(); return; @@ -801,39 +862,57 @@ } /** - * Generates an ISO8601 "basic" string from a UNIX timestamp + * 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(); - function pad( a, b, c ) { - return [a < 10 ? '0' + a : a, b < 10 ? '0' + b : b, c < 10 ? '0' + c : c].join( '' ); - } d.setTime( timestamp * 1000 ); return [ - pad( d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate() ), 'T', - pad( d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ), 'Z' + pad( d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate() ), + 'T', + pad( d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ), + 'Z' ].join( '' ); } /** - * Resolves dependencies and detects circular references. + * Resolve dependencies and detect circular references. * * @private * @param {string} module Name of the top-level module whose dependencies shall be - * resolved and sorted. + * 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. + * 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. + * 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 ( registry[module] === undefined ) { + if ( !hasOwn.call( registry, module ) ) { throw new Error( 'Unknown dependency: ' + module ); } @@ -859,10 +938,10 @@ } } if ( $.inArray( module, resolved ) !== -1 ) { - // Module already resolved; nothing to do. + // Module already resolved; nothing to do return; } - // unresolved is optional, supply it if not passed in + // Create unresolved if not passed in if ( !unresolved ) { unresolved = {}; } @@ -888,81 +967,37 @@ } /** - * Gets a list of module names that a module depends on in their proper dependency + * Get a list of module names that a module depends on in their proper dependency * order. * * @private - * @param {string} module Module name or array of string module names - * @return {Array} list of dependencies, including 'module'. - * @throws {Error} If circular reference is detected + * @param {string[]} module Array of string module names + * @return {Array} List of dependencies, including 'module'. */ - function resolve( module ) { - var m, resolved; - - // Allow calling with an array of module names - if ( $.isArray( module ) ) { - resolved = []; - for ( m = 0; m < module.length; m += 1 ) { - sortDependencies( module[m], resolved ); - } - return resolved; - } - - if ( typeof module === 'string' ) { - resolved = []; + function resolve( modules ) { + var resolved = []; + $.each( modules, function ( idx, module ) { sortDependencies( module, resolved ); - return resolved; - } - - throw new Error( 'Invalid module argument: ' + module ); + } ); + return resolved; } /** - * Narrows a list of module names down to those matching a specific - * state (see comment on top of this scope for a list of valid states). - * One can also filter for 'unregistered', which will return the - * modules names that don't have a registry entry. + * Determine whether all dependencies are in state 'ready', which means we may + * execute the module or job now. * * @private - * @param {string|string[]} states Module states to filter by - * @param {Array} [modules] List of module names to filter (optional, by default the entire - * registry is used) - * @return {Array} List of filtered module names + * @param {Array} module Names of modules to be checked + * @return {boolean} True if all modules are in state 'ready', false otherwise */ - function filter( states, modules ) { - var list, module, s, m; - - // Allow states to be given as a string - if ( typeof states === 'string' ) { - states = [states]; - } - // If called without a list of modules, build and use a list of all modules - list = []; - if ( modules === undefined ) { - modules = []; - for ( module in registry ) { - modules[modules.length] = module; - } - } - // Build a list of modules which are in one of the specified states - for ( s = 0; s < states.length; s += 1 ) { - for ( m = 0; m < modules.length; m += 1 ) { - if ( registry[modules[m]] === undefined ) { - // Module does not exist - if ( states[s] === 'unregistered' ) { - // OK, undefined - list[list.length] = modules[m]; - } - } else { - // Module exists, check state - if ( registry[modules[m]].state === states[s] ) { - // OK, correct state - list[list.length] = modules[m]; - } - } + function allReady( modules ) { + var i; + for ( i = 0; i < modules.length; i++ ) { + if ( mw.loader.getState( modules[i] ) !== 'ready' ) { + return false; } } - return list; + return true; } /** @@ -970,18 +1005,27 @@ * execute the module or job now. * * @private - * @param {Array} dependencies Dependencies (module names) to be checked. - * @return {boolean} True if all dependencies are in state 'ready', false otherwise + * @param {Array} modules Names of modules to be checked + * @return {boolean} True if no modules are in state 'error' or 'missing', false otherwise */ - function allReady( dependencies ) { - return filter( 'ready', dependencies ).length === dependencies.length; + function anyFailed( modules ) { + var i, state; + for ( i = 0; i < modules.length; i++ ) { + state = mw.loader.getState( modules[i] ); + if ( state === 'error' || state === 'missing' ) { + return true; + } + } + return false; } /** - * A module has entered state 'ready', 'error', or 'missing'. Automatically update pending jobs - * and modules that depend upon this module. if the given module failed, propagate the 'error' - * state up the dependency tree; otherwise, execute all jobs/modules that now have all their - * dependencies satisfied. On jobs depending on a failed module, run the error callback, if any. + * A module has entered state 'ready', 'error', or 'missing'. Automatically update + * pending jobs and modules that depend upon this module. If the given module failed, + * propagate the 'error' state up the dependency tree. Otherwise, go ahead an execute + * all jobs/modules now having their dependencies satisfied. + * + * Jobs that depend on a failed module, will have their error callback ran (if any). * * @private * @param {string} module Name of module that entered one of the states 'ready', 'error', or 'missing'. @@ -989,16 +1033,15 @@ function handlePending( module ) { var j, job, hasErrors, m, stateChange; - // Modules. - if ( $.inArray( registry[module].state, ['error', 'missing'] ) !== -1 ) { + 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 ( $.inArray( registry[m].state, ['error', 'missing'] ) === -1 ) { - if ( filter( ['error', 'missing'], registry[m].dependencies ).length > 0 ) { + if ( registry[m].state !== 'error' && registry[m].state !== 'missing' ) { + if ( anyFailed( registry[m].dependencies ) ) { registry[m].state = 'error'; stateChange = true; } @@ -1009,7 +1052,7 @@ // 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 = filter( ['error', 'missing'], jobs[j].dependencies ).length > 0; + hasErrors = anyFailed( jobs[j].dependencies ); if ( hasErrors || allReady( jobs[j].dependencies ) ) { // All dependencies satisfied, or some have errors job = jobs[j]; @@ -1028,7 +1071,7 @@ } catch ( e ) { // A user-defined callback raised an exception. // Swallow it to protect our state machine! - log( 'Exception thrown by user callback', e ); + mw.track( 'resourceloader.exception', { exception: e, module: module, source: 'load-callback' } ); } } } @@ -1091,7 +1134,7 @@ var key, value, media, i, urls, cssHandle, checkCssHandles, cssHandlesRegistered = false; - if ( registry[module] === undefined ) { + 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 ); @@ -1108,7 +1151,8 @@ */ function addLink( media, url ) { var el = document.createElement( 'link' ); - // For IE: Insert in document *before* setting href + // Support: IE + // Insert in document *before* setting href getMarker().before( el ); el.rel = 'stylesheet'; if ( media && media !== 'all' ) { @@ -1153,8 +1197,8 @@ } 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 - log( 'Exception thrown by ' + module, e ); registry[module].state = 'error'; + mw.track( 'resourceloader.exception', { exception: e, module: module, source: 'module-execute' } ); handlePending( module ); } } @@ -1170,6 +1214,11 @@ mw.messages.set( registry[module].messages ); } + // Initialise 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. @@ -1187,7 +1236,7 @@ var check = checkCssHandles; pending++; return function () { - if (check) { + if ( check ) { pending--; check(); check = undefined; // Revoke @@ -1271,8 +1320,6 @@ * Ignored (and defaulted to `true`) if the document-ready event has already occurred. */ function request( dependencies, ready, error, async ) { - var n; - // Allow calling by single module name if ( typeof dependencies === 'string' ) { dependencies = [dependencies]; @@ -1281,33 +1328,33 @@ // Add ready and error callbacks if they were given if ( ready !== undefined || error !== undefined ) { jobs[jobs.length] = { - 'dependencies': filter( - ['registered', 'loading', 'loaded'], - dependencies - ), - 'ready': ready, - 'error': error + dependencies: $.grep( dependencies, function ( module ) { + var state = mw.loader.getState( module ); + return state === 'registered' || state === 'loaded' || state === 'loading'; + } ), + ready: ready, + error: error }; } - // Queue up any dependencies that are registered - dependencies = filter( ['registered'], dependencies ); - for ( n = 0; n < dependencies.length; n += 1 ) { - if ( $.inArray( dependencies[n], queue ) === -1 ) { - queue[queue.length] = dependencies[n]; + $.each( dependencies, function ( idx, module ) { + var state = mw.loader.getState( module ); + if ( state === 'registered' && $.inArray( module, queue ) === -1 ) { + queue.push( module ); if ( async ) { - // Mark this module as async in the registry - registry[dependencies[n]].async = true; + registry[module].async = true; } } - } + } ); - // Work the queue mw.loader.work(); } function sortQuery( o ) { - var sorted = {}, key, a = []; + var key, + sorted = {}, + a = []; + for ( key in o ) { if ( hasOwn.call( o, key ) ) { a.push( key ); @@ -1326,7 +1373,9 @@ * @private */ function buildModulesString( moduleMap ) { - var arr = [], p, prefix; + var p, prefix, + arr = []; + for ( prefix in moduleMap ) { p = prefix === '' ? '' : prefix + '.'; arr.push( p + moduleMap[prefix].join( ',' ) ); @@ -1350,10 +1399,33 @@ currReqBase ); request = sortQuery( request ); - // Append &* to avoid triggering the IE6 extension check + // 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 ); } + /** + * Resolve indexed dependencies. + * + * ResourceLoader uses an optimization to save space which replaces module names in + * dependency lists with the index of that module within the array of module + * registration data if it exists. The benefit is a significant reduction in the data + * size of the startup module. This function changes those dependency lists back to + * arrays of strings. + * + * @param {Array} modules Modules array + */ + 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; + } ); + } + } ); + } + /* Public Members */ return { /** @@ -1389,12 +1461,12 @@ }; // Split module batch by source and by group. splits = {}; - maxQueryLength = mw.config.get( 'wgResourceLoaderMaxQueryLength', -1 ); + maxQueryLength = mw.config.get( 'wgResourceLoaderMaxQueryLength', 2000 ); // 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 ( registry[queue[q]] !== undefined && 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]; @@ -1433,7 +1505,7 @@ // repopulate these modules to the cache. // This means that at most one module will be useless (the one that had // the error) instead of all of them. - log( 'Error while evaluating data from mw.loader.store', err ); + mw.track( 'resourceloader.exception', { exception: err, source: 'store-eval' } ); origBatch = $.grep( origBatch, function ( module ) { return registry[module].state === 'loading'; } ); @@ -1457,10 +1529,10 @@ for ( b = 0; b < batch.length; b += 1 ) { bSource = registry[batch[b]].source; bGroup = registry[batch[b]].group; - if ( splits[bSource] === undefined ) { + if ( !hasOwn.call( splits, bSource ) ) { splits[bSource] = {}; } - if ( splits[bSource][bGroup] === undefined ) { + if ( !hasOwn.call( splits[bSource], bGroup ) ) { splits[bSource][bGroup] = []; } bSourceGroup = splits[bSource][bGroup]; @@ -1513,7 +1585,7 @@ prefix = modules[i].substr( 0, lastDotIndex ); suffix = modules[i].slice( lastDotIndex + 1 ); - bytesAdded = moduleMap[prefix] !== undefined + bytesAdded = hasOwn.call( moduleMap, prefix ) ? suffix.length + 3 // '%2C'.length == 3 : modules[i].length + 3; // '%7C'.length == 3 @@ -1526,8 +1598,9 @@ moduleMap = {}; async = true; l = currReqBaseLength + 9; + mw.track( 'resourceloader.splitRequest', { maxQueryLength: maxQueryLength } ); } - if ( moduleMap[prefix] === undefined ) { + if ( !hasOwn.call( moduleMap, prefix ) ) { moduleMap[prefix] = []; } moduleMap[prefix].push( suffix ); @@ -1569,7 +1642,7 @@ return true; } - if ( sources[id] !== undefined ) { + if ( hasOwn.call( sources, id ) ) { throw new Error( 'source already registered: ' + id ); } @@ -1586,7 +1659,12 @@ * Register a module, letting the system know about it and its * properties. Startup modules contain calls to this function. * - * @param {string} module Module name + * When using multiple module registration by passing an array, dependencies that + * are specified as references to modules within the array will be resolved before + * the modules are registered. + * + * @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|Array|Function} dependencies One string or array of strings of module * names on which this module depends, or a function that returns that array. @@ -1595,16 +1673,17 @@ * @param {string} [skip=null] Script body of the skip function */ register: function ( module, version, dependencies, group, source, skip ) { - var m; + var i, len; // Allow multiple registration if ( typeof module === 'object' ) { - for ( m = 0; m < module.length; m += 1 ) { + resolveIndexedDependencies( module ); + for ( i = 0, len = module.length; i < len; i++ ) { // module is an array of module names - if ( typeof module[m] === 'string' ) { - mw.loader.register( module[m] ); + if ( typeof module[i] === 'string' ) { + mw.loader.register( module[i] ); // module is an array of arrays - } else if ( typeof module[m] === 'object' ) { - mw.loader.register.apply( mw.loader, module[m] ); + } else if ( typeof module[i] === 'object' ) { + mw.loader.register.apply( mw.loader, module[i] ); } } return; @@ -1613,7 +1692,7 @@ if ( typeof module !== 'string' ) { throw new Error( 'module must be a string, not a ' + typeof module ); } - if ( registry[module] !== undefined ) { + if ( hasOwn.call( registry, module ) ) { throw new Error( 'module already registered: ' + module ); } // List the module as registered @@ -1646,7 +1725,7 @@ * @param {string} module Name of module * @param {Function|Array} script Function with module code or Array of URLs to * be used as the src attribute of a new `<script>` tag. - * @param {Object} style Should follow one of the following patterns: + * @param {Object} [style] Should follow one of the following patterns: * * { "css": [css, ..] } * { "url": { <media>: [url, ..] } } @@ -1657,36 +1736,41 @@ * { <media>: [url, ..] } * * The reason css strings are not concatenated anymore is bug 31676. We now check - * whether it's safe to extend the stylesheet (see #canExpandStylesheetWith). + * whether it's safe to extend the stylesheet. * - * @param {Object} msgs List of key/value pairs to be added to mw#messages. + * @param {Object} [msgs] 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 ) { + implement: function ( module, script, style, msgs, templates ) { // Validate input if ( typeof module !== 'string' ) { - throw new Error( 'module must be a string, not a ' + typeof module ); + throw new Error( 'module must be of type string, not ' + typeof module ); } - if ( !$.isFunction( script ) && !$.isArray( script ) ) { - throw new Error( 'script must be a function or an array, not a ' + typeof script ); + if ( script && !$.isFunction( script ) && !$.isArray( script ) ) { + throw new Error( 'script must be of type function or array, not ' + typeof script ); } - if ( !$.isPlainObject( style ) ) { - throw new Error( 'style must be an object, not a ' + typeof style ); + if ( style && !$.isPlainObject( style ) ) { + throw new Error( 'style must be of type object, not ' + typeof style ); } - if ( !$.isPlainObject( msgs ) ) { - throw new Error( 'msgs must be an object, not a ' + typeof msgs ); + if ( msgs && !$.isPlainObject( msgs ) ) { + throw new Error( 'msgs must be of type object, not a ' + typeof msgs ); + } + if ( templates && !$.isPlainObject( templates ) ) { + throw new Error( 'templates must be of type object, not a ' + typeof templates ); } // Automatically register module - if ( registry[module] === undefined ) { + if ( !hasOwn.call( registry, module ) ) { mw.loader.register( module ); } // Check for duplicate implementation - if ( registry[module] !== undefined && 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].script = script || []; + registry[module].style = style || {}; + registry[module].messages = msgs || {}; + registry[module].templates = templates || {}; // The module may already have been marked as erroneous if ( $.inArray( registry[module].state, ['error', 'missing'] ) === -1 ) { registry[module].state = 'loaded'; @@ -1710,6 +1794,7 @@ * @param {Function} [ready] Callback to execute when all dependencies are ready * @param {Function} [error] Callback to execute if one or more dependencies failed * @return {jQuery.Promise} + * @since 1.23 this returns a promise */ using: function ( dependencies, ready, error ) { var deferred = $.Deferred(); @@ -1734,7 +1819,7 @@ if ( allReady( dependencies ) ) { // Run ready immediately deferred.resolve(); - } else if ( filter( ['error', 'missing'], dependencies ).length ) { + } else if ( anyFailed( dependencies ) ) { // Execute error immediately if any dependencies have errors deferred.reject( new Error( 'One or more dependencies failed to load' ), @@ -1761,7 +1846,7 @@ * Defaults to `true` if loading a URL, `false` otherwise. */ load: function ( modules, type, async ) { - var filtered, m, module, l; + var filtered, l; // Validate input if ( typeof modules !== 'object' && typeof modules !== 'string' ) { @@ -1769,16 +1854,16 @@ } // Allow calling with an external url or single dependency as a string if ( typeof modules === 'string' ) { - // Support adding arbitrary external scripts if ( /^(https?:)?\/\//.test( modules ) ) { if ( async === undefined ) { // Assume async for bug 34542 async = true; } if ( type === 'text/css' ) { - // IE7-8 throws security warnings when inserting a <link> tag - // with a protocol-relative URL set though attributes (instead of - // properties) - when on HTTPS. See also bug 41331. + // Support: IE 7-8 + // Use properties instead of attributes as IE throws security + // warnings when inserting a <link> tag with a protocol-relative + // URL set though attributes - when on HTTPS. See bug 41331. l = document.createElement( 'link' ); l.rel = 'stylesheet'; l.href = modules; @@ -1801,26 +1886,18 @@ // Undefined modules are acceptable here in load(), because load() takes // an array of unrelated modules, whereas the modules passed to // using() are related and must all be loaded. - for ( filtered = [], m = 0; m < modules.length; m += 1 ) { - module = registry[modules[m]]; - if ( module !== undefined ) { - if ( $.inArray( module.state, ['error', 'missing'] ) === -1 ) { - filtered[filtered.length] = modules[m]; - } - } - } + filtered = $.grep( modules, function ( module ) { + var state = mw.loader.getState( module ); + return state !== null && state !== 'error' && state !== 'missing'; + } ); if ( filtered.length === 0 ) { return; } // Resolve entire dependency map filtered = resolve( filtered ); - // If all modules are ready, nothing to be done - if ( allReady( filtered ) ) { - return; - } - // If any modules have errors: also quit. - if ( filter( ['error', 'missing'], filtered ).length ) { + // If all modules are ready, or if any modules have errors, nothing to be done. + if ( allReady( filtered ) || anyFailed( filtered ) ) { return; } // Since some modules are not yet ready, queue up a request. @@ -1842,7 +1919,7 @@ } return; } - if ( registry[module] === undefined ) { + if ( !hasOwn.call( registry, module ) ) { mw.loader.register( module ); } if ( $.inArray( state, ['ready', 'error', 'missing'] ) !== -1 @@ -1859,27 +1936,29 @@ /** * Get the version of a module. * - * @param {string} module Name of module to get version for + * @param {string} module Name of module * @return {string|null} The version, or null if the module (or its version) is not * in the registry. */ getVersion: function ( module ) { - if ( registry[module] !== undefined && registry[module].version !== undefined ) { - return formatVersionNumber( registry[module].version ); + if ( !hasOwn.call( registry, module ) || registry[module].version === undefined ) { + return null; } - return null; + return formatVersionNumber( registry[module].version ); }, /** * Get the state of a module. * - * @param {string} module Name of module to get state for + * @param {string} module Name of module + * @return {string|null} The state, or null if the module (or its state) is not + * in the registry. */ getState: function ( module ) { - if ( registry[module] !== undefined && registry[module].state !== undefined ) { - return registry[module].state; + if ( !hasOwn.call( registry, module ) || registry[module].state === undefined ) { + return null; } - return null; + return registry[module].state; }, /** @@ -1944,7 +2023,7 @@ }, /** - * Get a string key on which to vary the module cache. + * Get a key on which to vary the module cache. * @return {string} String of concatenated vary conditions. */ getVary: function () { @@ -1956,13 +2035,13 @@ }, /** - * Get a string key for a specific module. The key format is '[name]@[version]'. + * Get a key for a specific module. The key format is '[name]@[version]'. * * @param {string} module Module name * @return {string|null} Module key or null if module does not exist */ getModuleKey: function ( module ) { - return typeof registry[module] === 'object' ? + return hasOwn.call( registry, module ) ? ( module + '@' + registry[module].version ) : null; }, @@ -1985,8 +2064,15 @@ return; } - if ( !mw.config.get( 'wgResourceLoaderStorageEnabled' ) || mw.config.get( 'debug' ) ) { - // Disabled by configuration, or because debug mode is set + if ( !mw.config.get( 'wgResourceLoaderStorageEnabled' ) ) { + // Disabled by configuration. + // Clear any previous store to free up space. (T66721) + mw.loader.store.clear(); + mw.loader.store.enabled = false; + return; + } + if ( mw.config.get( 'debug' ) ) { + // Disable module store in debug mode mw.loader.store.enabled = false; return; } @@ -2001,7 +2087,7 @@ return; } } catch ( e ) { - log( 'Storage error', e ); + mw.track( 'resourceloader.exception', { exception: e, source: 'store-localstorage-init' } ); } if ( raw === undefined ) { @@ -2057,7 +2143,8 @@ // Unversioned, private, or site-/user-specific ( !descriptor.version || $.inArray( descriptor.group, [ 'private', 'user', 'site' ] ) !== -1 ) || // Partial descriptor - $.inArray( undefined, [ descriptor.script, descriptor.style, descriptor.messages ] ) !== -1 + $.inArray( undefined, [ descriptor.script, descriptor.style, + descriptor.messages, descriptor.templates ] ) !== -1 ) { // Decline to store return false; @@ -2070,16 +2157,17 @@ String( descriptor.script ) : JSON.stringify( descriptor.script ), JSON.stringify( descriptor.style ), - JSON.stringify( descriptor.messages ) + JSON.stringify( descriptor.messages ), + JSON.stringify( descriptor.templates ) ]; - // Attempted workaround for a possible Opera bug (bug 57567). + // 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]; - log( 'Detected malformed function stringification (bug 57567)' ); + mw.track( 'resourceloader.assert', { source: 'bug-T59567' } ); } } catch ( e ) { - log( 'Storage error', e ); + mw.track( 'resourceloader.exception', { exception: e, source: 'store-localstorage-json' } ); return; } @@ -2151,7 +2239,7 @@ data = JSON.stringify( mw.loader.store ); localStorage.setItem( key, data ); } catch ( e ) { - log( 'Storage error', e ); + mw.track( 'resourceloader.exception', { exception: e, source: 'store-localstorage-update' } ); } } @@ -2223,8 +2311,8 @@ * - null or undefined: The short closing form is used, e.g. `<br/>`. * - this.Raw: The value attribute is included without escaping. * - this.Cdata: The value attribute is included, and an exception is - * thrown if it contains an illegal ETAGO delimiter. - * See <http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.3.2>. + * thrown if it contains an illegal ETAGO delimiter. + * See <http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.3.2>. * @return {string} HTML */ element: function ( name, attrs, contents ) { @@ -2387,13 +2475,49 @@ // @deprecated since 1.23 Use $ or jQuery instead mw.log.deprecate( window, '$j', $, 'Use $ or jQuery instead.' ); - // Attach to window and globally alias - window.mw = window.mediaWiki = mw; + /** + * Log a message to window.console, if possible. + * + * Useful to force logging of some errors that are otherwise hard to detect (i.e., this logs + * also in production mode). Gets console references in each invocation instead of caching the + * reference, so that debugging tools loaded later are supported (e.g. Firebug Lite in IE). + * + * @private + * @method log_ + * @param {string} topic Stream name passed by mw.track + * @param {Object} data Data passed by mw.track + * @param {Error} [data.exception] + * @param {string} data.source Error source + * @param {string} [data.module] Name of module which caused the error + */ + function log( topic, data ) { + var msg, + e = data.exception, + source = data.source, + module = data.module, + console = window.console; - // Auto-register from pre-loaded startup scripts - if ( $.isFunction( window.startUp ) ) { - window.startUp(); - window.startUp = undefined; + if ( console && console.log ) { + msg = ( e ? 'Exception' : 'Error' ) + ' in ' + source; + if ( module ) { + msg += ' in module ' + module; + } + msg += ( e ? ':' : '.' ); + console.log( msg ); + + // If we have an exception object, log it to the error channel to trigger a + // proper stacktraces in browsers that support it. No fallback as we have no browsers + // that don't support error(), but do support log(). + if ( e && console.error ) { + console.error( String( e ), e ); + } + } } + // subscribe to error streams + mw.trackSubscribe( 'resourceloader.exception', log ); + mw.trackSubscribe( 'resourceloader.assert', log ); + + // Attach to window and globally alias + window.mw = window.mediaWiki = mw; }( jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.notification.js b/resources/src/mediawiki/mediawiki.notification.js index 1968aa94..132c334f 100644 --- a/resources/src/mediawiki/mediawiki.notification.js +++ b/resources/src/mediawiki/mediawiki.notification.js @@ -12,7 +12,7 @@ /** * A Notification object for 1 message. * - * The "_" in the name is to avoid a bug (http://github.com/senchalabs/jsduck/issues/304). + * The underscore in the name is to avoid a bug <https://github.com/senchalabs/jsduck/issues/304>. * It is not part of the actual class name. * * @class mw.Notification_ diff --git a/resources/src/mediawiki/mediawiki.pager.tablePager.less b/resources/src/mediawiki/mediawiki.pager.tablePager.less index d37aec5b..822c8147 100644 --- a/resources/src/mediawiki/mediawiki.pager.tablePager.less +++ b/resources/src/mediawiki/mediawiki.pager.tablePager.less @@ -37,48 +37,48 @@ .TablePager_nav td.TablePager_nav-first .TablePager_nav-disabled { padding-top: 25px; - /* @embed */ - background: url(images/pager-arrow-disabled-fastforward-rtl.png) center top no-repeat; + background: none center top no-repeat; + .background-image-svg('images/pager-arrow-disabled-fastforward-rtl.svg', 'images/pager-arrow-disabled-fastforward-rtl.png'); } .TablePager_nav td.TablePager_nav-prev .TablePager_nav-disabled { padding-top: 25px; - /* @embed */ - background: url(images/pager-arrow-disabled-forward-rtl.png) center top no-repeat; + background: none center top no-repeat; + .background-image-svg('images/pager-arrow-disabled-forward-rtl.svg', 'images/pager-arrow-disabled-forward-rtl.png'); } .TablePager_nav td.TablePager_nav-next .TablePager_nav-disabled { padding-top: 25px; - /* @embed */ - background: url(images/pager-arrow-disabled-forward-ltr.png) center top no-repeat; + background: none center top no-repeat; + .background-image-svg('images/pager-arrow-disabled-forward-ltr.svg', 'images/pager-arrow-disabled-forward-ltr.png'); } .TablePager_nav td.TablePager_nav-last .TablePager_nav-disabled { padding-top: 25px; - /* @embed */ - background: url(images/pager-arrow-disabled-fastforward-ltr.png) center top no-repeat; + background: none center top no-repeat; + .background-image-svg('images/pager-arrow-disabled-fastforward-ltr.svg', 'images/pager-arrow-disabled-fastforward-ltr.png'); } .TablePager_nav td.TablePager_nav-first .TablePager_nav-enabled { padding-top: 25px; - /* @embed */ - background: url(images/pager-arrow-fastforward-rtl.png) center top no-repeat; + background: none center top no-repeat; + .background-image-svg('images/pager-arrow-fastforward-rtl.svg', 'images/pager-arrow-fastforward-rtl.png'); } .TablePager_nav td.TablePager_nav-prev .TablePager_nav-enabled { padding-top: 25px; - /* @embed */ - background: url(images/pager-arrow-forward-rtl.png) center top no-repeat; + background: none center top no-repeat; + .background-image-svg('images/pager-arrow-forward-rtl.svg', 'images/pager-arrow-forward-rtl.png'); } .TablePager_nav td.TablePager_nav-next .TablePager_nav-enabled { padding-top: 25px; - /* @embed */ - background: url(images/pager-arrow-forward-ltr.png) center top no-repeat; + background: none center top no-repeat; + .background-image-svg('images/pager-arrow-forward-ltr.svg', 'images/pager-arrow-forward-ltr.png'); } .TablePager_nav td.TablePager_nav-last .TablePager_nav-enabled { padding-top: 25px; - /* @embed */ - background: url(images/pager-arrow-fastforward-ltr.png) center top no-repeat; + background: none center top no-repeat; + .background-image-svg('images/pager-arrow-fastforward-ltr.svg', 'images/pager-arrow-fastforward-ltr.png'); } diff --git a/resources/src/mediawiki/mediawiki.searchSuggest.js b/resources/src/mediawiki/mediawiki.searchSuggest.js index a214cb3f..7b7ccf3f 100644 --- a/resources/src/mediawiki/mediawiki.searchSuggest.js +++ b/resources/src/mediawiki/mediawiki.searchSuggest.js @@ -41,10 +41,7 @@ baseHref = $form.attr( 'action' ); baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?'; - linkParams = {}; - $.each( $form.serializeArray(), function ( idx, obj ) { - linkParams[ obj.name ] = obj.value; - } ); + linkParams = $form.serializeObject(); return { textParam: context.data.$textbox.attr( 'name' ), @@ -122,7 +119,7 @@ ]; $( searchboxesSelectors.join( ', ' ) ) .suggestions( { - fetch: function ( query, response ) { + fetch: function ( query, response, maxRows ) { var node = this[0]; api = api || new mw.Api(); @@ -131,6 +128,7 @@ action: 'opensearch', search: query, namespace: 0, + limit: maxRows, suggest: '' } ).done( function ( data ) { response( data[ 1 ] ); diff --git a/resources/src/mediawiki/mediawiki.sectionAnchor.css b/resources/src/mediawiki/mediawiki.sectionAnchor.css new file mode 100644 index 00000000..f8f00221 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.sectionAnchor.css @@ -0,0 +1,3 @@ +.mw-headline-anchor { + display: none; +} diff --git a/resources/src/mediawiki/mediawiki.startUp.js b/resources/src/mediawiki/mediawiki.startUp.js new file mode 100644 index 00000000..028784c2 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.startUp.js @@ -0,0 +1,11 @@ +/*! + * 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.template.js b/resources/src/mediawiki/mediawiki.template.js new file mode 100644 index 00000000..61bbb0d7 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.template.js @@ -0,0 +1,123 @@ +/** + * @class mw.template + * @singleton + */ +( function ( mw, $ ) { + var compiledTemplates = {}, + compilers = {}; + + mw.template = { + /** + * Register a new compiler and template. + * + * @param {string} name of compiler. Should also match with any file extensions of templates that want to use it. + * @param {Function} compiler which must implement a compile function + */ + registerCompiler: function ( name, compiler ) { + if ( !compiler.compile ) { + throw new Error( 'Compiler must implement compile method.' ); + } + compilers[name] = compiler; + }, + + /** + * Get the name of the compiler associated with a template based on its name. + * + * @param {string} templateName Name of template (including file suffix) + * @return {String} Name of compiler + */ + getCompilerName: function ( templateName ) { + var templateParts = templateName.split( '.' ); + + if ( templateParts.length < 2 ) { + throw new Error( 'Unable to identify compiler. Template name must have a suffix.' ); + } + return templateParts[ templateParts.length - 1 ]; + }, + + /** + * Get the compiler for a given compiler name. + * + * @param {string} compilerName Name of the compiler + * @return {Object} The compiler associated with that name + */ + getCompiler: function ( compilerName ) { + var compiler = compilers[ compilerName ]; + if ( !compiler ) { + throw new Error( 'Unknown compiler ' + compilerName ); + } + return compiler; + }, + + /** + * Register a template associated with a module. + * + * Compiles the newly added template based on the suffix in its name. + * + * @param {string} moduleName Name of ResourceLoader module to get the template from + * @param {string} templateName Name of template to add including file extension + * @param {string} templateBody Contents of a template (e.g. html markup) + * @return {Function} Compiled template + */ + add: function ( moduleName, templateName, templateBody ) { + var compiledTemplate, + compilerName = this.getCompilerName( templateName ); + + if ( !compiledTemplates[moduleName] ) { + compiledTemplates[moduleName] = {}; + } + + compiledTemplate = this.compile( templateBody, compilerName ); + compiledTemplates[moduleName][ templateName ] = compiledTemplate; + return compiledTemplate; + }, + + /** + * Retrieve a template by module and template name. + * + * @param {string} moduleName Name of the module to retrieve the template from + * @param {string} templateName Name of template to be retrieved + * @return {Object} Compiled template + */ + get: function ( moduleName, templateName ) { + var moduleTemplates, compiledTemplate; + + // Check if the template has already been compiled, compile it if not + if ( !compiledTemplates[ moduleName ] || !compiledTemplates[ moduleName ][ templateName ] ) { + moduleTemplates = mw.templates.get( moduleName ); + if ( !moduleTemplates || !moduleTemplates[ templateName ] ) { + throw new Error( 'Template ' + templateName + ' not found in module ' + moduleName ); + } + + // Add compiled version + compiledTemplate = this.add( moduleName, templateName, moduleTemplates[ templateName ] ); + } else { + compiledTemplate = compiledTemplates[ moduleName ][ templateName ]; + } + return compiledTemplate; + }, + + /** + * Wrap our template engine of choice. + * + * @param {string} templateBody Template body + * @param {string} compilerName The name of a registered compiler + * @return {Object} Template interface + */ + compile: function ( templateBody, compilerName ) { + return this.getCompiler( compilerName ).compile( templateBody ); + } + }; + + // Register basic html compiler + mw.template.registerCompiler( 'html', { + compile: function ( src ) { + return { + render: function () { + return $( $.parseHTML( $.trim( src ) ) ); + } + }; + } + } ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.template.mustache.js b/resources/src/mediawiki/mediawiki.template.mustache.js new file mode 100644 index 00000000..dcc3842b --- /dev/null +++ b/resources/src/mediawiki/mediawiki.template.mustache.js @@ -0,0 +1,14 @@ +/*global Mustache */ +( function ( mw, $ ) { + // Register mustache compiler + mw.template.registerCompiler( 'mustache', { + compile: function ( src ) { + return { + render: function ( data ) { + return $.parseHTML( Mustache.render( src, data ) ); + } + }; + } + } ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.user.js b/resources/src/mediawiki/mediawiki.user.js index e93707ec..817c856c 100644 --- a/resources/src/mediawiki/mediawiki.user.js +++ b/resources/src/mediawiki/mediawiki.user.js @@ -3,12 +3,9 @@ * @singleton */ ( function ( mw, $ ) { - var user, + var i, deferreds = {}, - // Extend the skeleton mw.user from mediawiki.js - // This is kind of ugly but we're stuck with this for b/c reasons - options = mw.user.options || new mw.Map(), - tokens = mw.user.tokens || new mw.Map(); + byteToHex = []; /** * Get the current user's groups or rights @@ -44,27 +41,65 @@ return deferreds[info].promise(); } - mw.user = user = { - options: options, - tokens: tokens, + // 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 ); + } + + // mw.user with the properties options and tokens gets defined in mediawiki.js. + $.extend( mw.user, { /** - * Generate a random user session ID (32 alpha-numeric characters) + * Generate a random user session ID. * * This information would potentially be stored in a cookie to identify a user during a - * session or series of sessions. Its uniqueness should not be depended on. + * session or series of sessions. Its uniqueness should not be depended on unless the + * browser supports the crypto API. + * + * Known problems with Math.random(): + * Using the Math.random function we have seen sets + * with 1% of non uniques among 200,000 values with Safari providing most of these. + * Given the prevalence of Safari in mobile the percentage of duplicates in + * mobile usages of this code is probably higher. * - * @return {string} Random set of 32 alpha-numeric characters + * Rationale: + * We need about 64 bits to make sure that probability of collision + * on 500 million (5*10^8) is <= 1% + * See https://en.wikipedia.org/wiki/Birthday_problem#Probability_table + * + * @return {string} 64 bit integer in hex format, padded */ generateRandomSessionId: function () { - var i, r, - id = '', - seed = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - for ( i = 0; i < 32; i++ ) { - r = Math.floor( Math.random() * seed.length ); - id += seed.charAt( r ); + /*jshint bitwise:false */ + var rnds, i, r, + hexRnds = new Array( 8 ), + // Support: IE 11 + crypto = window.crypto || window.msCrypto; + + // Based on https://github.com/broofa/node-uuid/blob/bfd9f96127/uuid.js + if ( crypto && crypto.getRandomValues ) { + // Fill an array with 8 random values, each of which is 8 bits. + // Note that Uint8Array is array-like but does not implement Array. + rnds = new Uint8Array( 8 ); + crypto.getRandomValues( rnds ); + } else { + rnds = new Array( 8 ); + for ( i = 0; i < 8; i++ ) { + if ( ( i & 3 ) === 0 ) { + r = Math.random() * 0x100000000; + } + rnds[i] = r >>> ( ( i & 3 ) << 3 ) & 255; + } + } + // Convert from number to hex + for ( i = 0; i < 8; i++ ) { + hexRnds[i] = byteToHex[rnds[i]]; } - return id; + + // Concatenation of two random integers with entrophy n and m + // returns a string with entrophy n+m if those strings are independent + return hexRnds.join( '' ); }, /** @@ -95,15 +130,15 @@ */ getRegistration: function () { var registration = mw.config.get( 'wgUserRegistration' ); - if ( user.isAnon() ) { + if ( mw.user.isAnon() ) { return false; - } else if ( registration === null ) { + } + if ( registration === null ) { // Information may not be available if they signed up before // MW began storing this. return null; - } else { - return new Date( registration ); } + return new Date( registration ); }, /** @@ -112,7 +147,7 @@ * @return {boolean} */ isAnon: function () { - return user.getName() === null; + return mw.user.getName() === null; }, /** @@ -126,7 +161,7 @@ sessionId: function () { var sessionId = $.cookie( 'mediaWiki.user.sessionId' ); if ( sessionId === undefined || sessionId === null ) { - sessionId = user.generateRandomSessionId(); + sessionId = mw.user.generateRandomSessionId(); $.cookie( 'mediaWiki.user.sessionId', sessionId, { expires: null, path: '/' } ); } return sessionId; @@ -140,7 +175,7 @@ * @return {string} User name or random session ID */ id: function () { - return user.getName() || user.sessionId(); + return mw.user.getName() || mw.user.sessionId(); }, /** @@ -239,20 +274,6 @@ getRights: function ( callback ) { return getUserInfo( 'rights' ).done( callback ); } - }; - - /** - * @method name - * @inheritdoc #getName - * @deprecated since 1.20 Use #getName instead - */ - mw.log.deprecate( user, 'name', user.getName, 'Use mw.user.getName instead.' ); - - /** - * @method anonymous - * @inheritdoc #isAnon - * @deprecated since 1.20 Use #isAnon instead - */ - mw.log.deprecate( user, 'anonymous', user.isAnon, 'Use mw.user.isAnon instead.' ); + } ); }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.userSuggest.js b/resources/src/mediawiki/mediawiki.userSuggest.js new file mode 100644 index 00000000..3964f0b2 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.userSuggest.js @@ -0,0 +1,41 @@ +/*! + * Add autocomplete suggestions for names of registered users. + */ +( function ( mw, $ ) { + var api, config; + + config = { + fetch: function ( userInput, response, maxRows ) { + var node = this[0]; + + api = api || new mw.Api(); + + $.data( node, 'request', api.get( { + action: 'query', + 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 ), + aulimit: maxRows + } ).done( function ( data ) { + var users = $.map( data.query.allusers, function ( userObj ) { + return userObj.name; + } ); + response( users ); + } ) ); + }, + cancel: function () { + var node = this[0], + request = $.data( node, 'request' ); + + if ( request ) { + request.abort(); + $.removeData( node, 'request' ); + } + } + }; + + $( function () { + $( '.mw-autocomplete-user' ).suggestions( config ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.util.js b/resources/src/mediawiki/mediawiki.util.js index 26629137..6723e5f9 100644 --- a/resources/src/mediawiki/mediawiki.util.js +++ b/resources/src/mediawiki/mediawiki.util.js @@ -88,7 +88,7 @@ /** * Get the link to a page name (relative to `wgServer`), * - * @param {string} str Page name + * @param {string|null} [str=wgPageName] Page name * @param {Object} [params] A mapping of query parameter names to values, * e.g. `{ action: 'edit' }` * @return {string} Url of the page with name of `str` @@ -151,12 +151,12 @@ * Returns null if not found. * * @param {string} param The parameter name. - * @param {string} [url=document.location.href] URL to search through, defaulting to the current document's URL. + * @param {string} [url=location.href] URL to search through, defaulting to the current browsing location. * @return {Mixed} Parameter value or null. */ getParamValue: function ( param, url ) { if ( url === undefined ) { - url = document.location.href; + url = location.href; } // Get last match, stop at hash var re = new RegExp( '^[^#]*[&?]' + $.escapeRE( param ) + '=([^&#]*)' ), @@ -196,7 +196,7 @@ * p-cactions (Content actions), p-personal (Personal tools), * p-navigation (Navigation), p-tb (Toolbox) * - * The first three paramters are required, the others are optional and + * The first three parameters are required, the others are optional and * may be null. Though providing an id and tooltip is recommended. * * By default the new link will be added to the end of the list. To @@ -228,7 +228,7 @@ addPortletLink: function ( portlet, href, text, id, tooltip, accesskey, nextnode ) { var $item, $link, $portlet, $ul; - // Check if there's atleast 3 arguments to prevent a TypeError + // Check if there's at least 3 arguments to prevent a TypeError if ( arguments.length < 3 ) { return null; } @@ -286,30 +286,38 @@ } if ( tooltip ) { - $link.attr( 'title', tooltip ).updateTooltipAccessKeys(); + $link.attr( 'title', tooltip ); } if ( nextnode ) { + // Case: nextnode is a DOM element (was the only option before MW 1.17, in wikibits.js) + // Case: nextnode is a CSS selector for jQuery if ( nextnode.nodeType || typeof nextnode === 'string' ) { - // nextnode is a DOM element (was the only option before MW 1.17, in wikibits.js) - // or nextnode is a CSS selector for jQuery nextnode = $ul.find( nextnode ); - } else if ( !nextnode.jquery || ( nextnode.length && nextnode[0].parentNode !== $ul[0] ) ) { - // Fallback - $ul.append( $item ); - return $item[0]; + } else if ( !nextnode.jquery ) { + // Error: Invalid nextnode + nextnode = undefined; } - if ( nextnode.length === 1 ) { - // nextnode is a jQuery object that represents exactly one element - nextnode.before( $item ); - return $item[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; } } - // Fallback (this is the default behavior) - $ul.append( $item ); - return $item[0]; + // Case: nextnode is a jQuery-wrapped DOM element + if ( nextnode ) { + nextnode.before( $item ); + } else { + // Fallback (this is the default behavior) + $ul.append( $item ); + } + + // Update tooltip for the access key after inserting into DOM + // to get a localized access key label (bug 67946). + $link.updateTooltipAccessKeys(); + return $item[0]; }, /** @@ -332,7 +340,7 @@ // HTML5 defines a string as valid e-mail address if it matches // the ABNF: - // 1 * ( atext / "." ) "@" ldh-str 1*( "." ldh-str ) + // 1 * ( atext / "." ) "@" ldh-str 1*( "." ldh-str ) // With: // - atext : defined in RFC 5322 section 3.2.3 // - ldh-str : defined in RFC 1034 section 3.5 @@ -353,12 +361,12 @@ rfc5322Atext = 'a-z0-9!#$%&\'*+\\-/=?^_`{|}~'; // Next define the RFC 1034 'ldh-str' - // <domain> ::= <subdomain> | " " - // <subdomain> ::= <label> | <subdomain> "." <label> - // <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ] - // <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str> - // <let-dig-hyp> ::= <let-dig> | "-" - // <let-dig> ::= <letter> | <digit> + // <domain> ::= <subdomain> | " " + // <subdomain> ::= <label> | <subdomain> "." <label> + // <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ] + // <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str> + // <let-dig-hyp> ::= <let-dig> | "-" + // <let-dig> ::= <letter> | <digit> rfc1034LdhStr = 'a-z0-9\\-'; html5EmailRegexp = new RegExp( @@ -435,6 +443,19 @@ return address.search( new RegExp( '^' + RE_IPV6_ADD + block + '$' ) ) !== -1 && address.search( /::/ ) !== -1 && address.search( /::.*::/ ) === -1; + }, + + /** + * Check whether a string is an IP address + * + * @since 1.25 + * @param {string} address String to check + * @param {boolean} allowBlock True if a block of IPs should be allowed + * @return {boolean} + */ + isIPAddress: function ( address, allowBlock ) { + return util.isIPv4Address( address, allowBlock ) || + util.isIPv6Address( address, allowBlock ); } }; |