diff --git a/jsdoc.json b/jsdoc.json index 1e9177aae..b3770430a 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -2,7 +2,7 @@ "opts": { "destination": "docs/js", "package": "package.json", - "pedantic": true, + "pedantic": false, "readme": "README.md", "recurse": true, "template": "node_modules/jsdoc-wmf-theme" diff --git a/resources/skins.minerva.scripts/AB.js b/resources/skins.minerva.scripts/AB.js index 37a4b0a63..08c567b48 100644 --- a/resources/skins.minerva.scripts/AB.js +++ b/resources/skins.minerva.scripts/AB.js @@ -1,81 +1,79 @@ +const mwExperiments = mw.experiments; /* - * Bucketing wrapper for creating AB-tests. +* Bucketing wrapper for creating AB-tests. +* +* Given a test name, sampling rate, and session ID, provides a class that buckets a user into +* a predefined bucket ("unsampled", "control", or "treatment") and starts an AB-test. +*/ +const bucket = { + UNSAMPLED: 'unsampled', // Old treatment: not sampled and not instrumented. + CONTROL: 'control', // Old treatment: sampled and instrumented. + TREATMENT: 'treatment' // New treatment: sampled and instrumented. +}; + +/** + * Buckets users based on params and exposes an `isSampled` and `getBucket` method. * - * Given a test name, sampling rate, and session ID, provides a class that buckets a user into - * a predefined bucket ("unsampled", "control", or "treatment") and starts an AB-test. + * @param {Object} config Configuration object for AB test. + * @param {string} config.testName + * @param {number} config.samplingRate Sampling rate for the AB-test. + * @param {number} config.sessionId Session ID for user bucketing. + * @constructor */ -( function ( mwExperiments ) { - const bucket = { - UNSAMPLED: 'unsampled', // Old treatment: not sampled and not instrumented. - CONTROL: 'control', // Old treatment: sampled and instrumented. - TREATMENT: 'treatment' // New treatment: sampled and instrumented. +function AB( config ) { + const testName = config.testName; + const samplingRate = config.samplingRate; + const sessionId = config.sessionId; + const test = { + name: testName, + enabled: !!samplingRate, + buckets: { + unsampled: 1 - samplingRate, + control: samplingRate / 2, + treatment: samplingRate / 2 + } }; /** - * Buckets users based on params and exposes an `isSampled` and `getBucket` method. + * Gets the users AB-test bucket. * - * @param {Object} config Configuration object for AB test. - * @param {string} config.testName - * @param {number} config.samplingRate Sampling rate for the AB-test. - * @param {number} config.sessionId Session ID for user bucketing. - * @constructor + * A boolean instead of an enum is usually a code smell. However, the nature of A/B testing + * is to compare an A group's performance to a B group's so a boolean seems natural, even + * in the long term, and preferable to showing bucketing encoding ("unsampled", "control", + * "treatment") to callers which is necessary if getBucket(). The downside is that now two + * functions exist where one would suffice. + * + * @private + * @return {string} AB-test bucket, `bucket.UNSAMPLED` by default, `bucket.CONTROL` or + * `bucket.TREATMENT` buckets otherwise. */ - function AB( config ) { - const testName = config.testName; - const samplingRate = config.samplingRate; - const sessionId = config.sessionId; - const test = { - name: testName, - enabled: !!samplingRate, - buckets: { - unsampled: 1 - samplingRate, - control: samplingRate / 2, - treatment: samplingRate / 2 - } - }; - - /** - * Gets the users AB-test bucket. - * - * A boolean instead of an enum is usually a code smell. However, the nature of A/B testing - * is to compare an A group's performance to a B group's so a boolean seems natural, even - * in the long term, and preferable to showing bucketing encoding ("unsampled", "control", - * "treatment") to callers which is necessary if getBucket(). The downside is that now two - * functions exist where one would suffice. - * - * @private - * @return {string} AB-test bucket, `bucket.UNSAMPLED` by default, `bucket.CONTROL` or - * `bucket.TREATMENT` buckets otherwise. - */ - function getBucket() { - return mwExperiments.getBucket( test, sessionId ); - } - - function isControl() { - return getBucket() === bucket.CONTROL; - } - - function isTreatment() { - return getBucket() === bucket.TREATMENT; - } - - /** - * Checks whether or not a user is in the AB-test, - * - * @private - * @return {boolean} - */ - function isSampled() { - return getBucket() !== bucket.UNSAMPLED; // I.e., `isControl() || isTreatment()` - } - - return { - isControl: isControl, - isTreatment: isTreatment, - isSampled: isSampled - }; + function getBucket() { + return mwExperiments.getBucket( test, sessionId ); } - module.exports = AB; + function isControl() { + return getBucket() === bucket.CONTROL; + } -}( mw.experiments ) ); + function isTreatment() { + return getBucket() === bucket.TREATMENT; + } + + /** + * Checks whether or not a user is in the AB-test, + * + * @private + * @return {boolean} + */ + function isSampled() { + return getBucket() !== bucket.UNSAMPLED; // I.e., `isControl() || isTreatment()` + } + + return { + isControl: isControl, + isTreatment: isTreatment, + isSampled: isSampled + }; +} + +module.exports = AB; diff --git a/resources/skins.minerva.scripts/TitleUtil.js b/resources/skins.minerva.scripts/TitleUtil.js index 998abfa06..ae8d142f2 100644 --- a/resources/skins.minerva.scripts/TitleUtil.js +++ b/resources/skins.minerva.scripts/TitleUtil.js @@ -3,135 +3,133 @@ // of a SiteInfo, it still becomes a public API. If it lives where used, it becomes a copy and paste // implementation where each copy can deviate but deletion is easy. See additional discussion in // T218358 and I95b08e77eece5cd4dae62f6f237d492d6b0fe42b. -( function () { - const UriUtil = require( './UriUtil.js' ); +const UriUtil = require( './UriUtil.js' ); - /** - * Returns the decoded wiki page title referenced by the passed link as a string when parsable. - * The title query parameter is returned, if present. Otherwise, a heuristic is used to attempt - * to extract the title from the path. - * - * The API is the source of truth for page titles. This function should only be used in - * circumstances where the API cannot be consulted. - * - * Assuming the current page is on metawiki, consider the following example links and - * `newFromUri()` outputs: - * - * https://meta.wikimedia.org/wiki/Foo → Foo (path title) - * http://meta.wikimedia.org/wiki/Foo → Foo (mismatching protocol) - * /wiki/Foo → Foo (relative URI) - * /w/index.php?title=Foo → Foo (title query parameter) - * /wiki/Talk:Foo → Talk:Foo (non-main namespace URI) - * /wiki/Foo bar → Foo_bar (name with spaces) - * /wiki/Foo%20bar → Foo_bar (name with percent encoded spaces) - * /wiki/Foo+bar → Foo+bar (name with +) - * /w/index.php?title=Foo%2bbar → Foo+bar (query parameter with +) - * / → null (mismatching article path) - * /wiki/index.php?title=Foo → null (mismatching script path) - * https://archive.org/ → null (mismatching host) - * https://foo.wikimedia.org/ → null (mismatching host) - * https://en.wikipedia.org/wiki/Bar → null (mismatching host) - * - * This function invokes `Uri.isInternal()` to validate that this link is assuredly a local - * wiki link and that the internal usage of both the title query parameter and value of - * wgArticlePath are relevant. - * - * This function doesn't throw. `null` is returned for any unparseable input. - * - * @param {mw.Uri|Object|string} [uri] Passed to Uri. - * @param {Object|boolean} [options] Passed to Uri. - * @param {Object|boolean} [options.validateReadOnlyLink] If true, only links that would show a - * page for reading are considered. E.g., `/wiki/Foo` and `/w/index.php?title=Foo` would - * validate but `/w/index.php?title=Foo&action=bar` would not. - * @return {mw.Title|null} A Title or `null`. - */ - function newFromUri( uri, options ) { - let mwUri; - let title; +/** + * Returns the decoded wiki page title referenced by the passed link as a string when parsable. + * The title query parameter is returned, if present. Otherwise, a heuristic is used to attempt + * to extract the title from the path. + * + * The API is the source of truth for page titles. This function should only be used in + * circumstances where the API cannot be consulted. + * + * Assuming the current page is on metawiki, consider the following example links and + * `newFromUri()` outputs: + * + * https://meta.wikimedia.org/wiki/Foo → Foo (path title) + * http://meta.wikimedia.org/wiki/Foo → Foo (mismatching protocol) + * /wiki/Foo → Foo (relative URI) + * /w/index.php?title=Foo → Foo (title query parameter) + * /wiki/Talk:Foo → Talk:Foo (non-main namespace URI) + * /wiki/Foo bar → Foo_bar (name with spaces) + * /wiki/Foo%20bar → Foo_bar (name with percent encoded spaces) + * /wiki/Foo+bar → Foo+bar (name with +) + * /w/index.php?title=Foo%2bbar → Foo+bar (query parameter with +) + * / → null (mismatching article path) + * /wiki/index.php?title=Foo → null (mismatching script path) + * https://archive.org/ → null (mismatching host) + * https://foo.wikimedia.org/ → null (mismatching host) + * https://en.wikipedia.org/wiki/Bar → null (mismatching host) + * + * This function invokes `Uri.isInternal()` to validate that this link is assuredly a local + * wiki link and that the internal usage of both the title query parameter and value of + * wgArticlePath are relevant. + * + * This function doesn't throw. `null` is returned for any unparseable input. + * + * @param {mw.Uri|Object|string} [uri] Passed to Uri. + * @param {Object|boolean} [options] Passed to Uri. + * @param {Object|boolean} [options.validateReadOnlyLink] If true, only links that would show a + * page for reading are considered. E.g., `/wiki/Foo` and `/w/index.php?title=Foo` would + * validate but `/w/index.php?title=Foo&action=bar` would not. + * @return {mw.Title|null} A Title or `null`. + */ +function newFromUri( uri, options ) { + let mwUri; + let title; + + try { + // uri may or may not be a Uri but the Uri constructor accepts a Uri parameter. + mwUri = new mw.Uri( uri, options ); + } catch ( e ) { + return null; + } + + if ( !UriUtil.isInternal( mwUri ) ) { + return null; + } + + if ( ( options || {} ).validateReadOnlyLink && !isReadOnlyUri( mwUri ) ) { + // An unknown query parameter is used. This may not be a read-only link. + return null; + } + + if ( mwUri.query.title ) { + // True if input starts with wgScriptPath. + // eslint-disable-next-line security/detect-non-literal-regexp + const regExp = new RegExp( '^' + mw.util.escapeRegExp( mw.config.get( 'wgScriptPath' ) ) + '/' ); + + // URL has a nonempty `title` query parameter like `/w/index.php?title=Foo`. The script + // path should match. + const matches = regExp.test( mwUri.path ); + if ( !matches ) { + return null; + } + + // The parameter was already decoded at Uri construction. + title = mwUri.query.title; + } else { + // True if input starts with wgArticlePath and ends with a nonempty page title. The + // first matching group (index 1) is the page title. + // eslint-disable-next-line security/detect-non-literal-regexp + const regExp = new RegExp( '^' + mw.util.escapeRegExp( mw.config.get( 'wgArticlePath' ) ).replace( '\\$1', '(.+)' ) ); + + // No title query parameter is present so the URL may be "pretty" like `/wiki/Foo`. + // `Uri.path` should not contain query parameters or a fragment, as is assumed in + // `Uri.getRelativePath()`. Try to isolate the title. + const matches = regExp.exec( mwUri.path ); + if ( !matches || !matches[ 1 ] ) { + return null; + } try { - // uri may or may not be a Uri but the Uri constructor accepts a Uri parameter. - mwUri = new mw.Uri( uri, options ); + // `Uri.path` was not previously decoded, as is assumed in `Uri.getRelativePath()`, + // and decoding may now fail. Do not use `Uri.decode()` which is designed to be + // paired with `Uri.encode()` and replaces `+` characters with spaces. + title = decodeURIComponent( matches[ 1 ] ); } catch ( e ) { return null; } - - if ( !UriUtil.isInternal( mwUri ) ) { - return null; - } - - if ( ( options || {} ).validateReadOnlyLink && !isReadOnlyUri( mwUri ) ) { - // An unknown query parameter is used. This may not be a read-only link. - return null; - } - - if ( mwUri.query.title ) { - // True if input starts with wgScriptPath. - // eslint-disable-next-line security/detect-non-literal-regexp - const regExp = new RegExp( '^' + mw.util.escapeRegExp( mw.config.get( 'wgScriptPath' ) ) + '/' ); - - // URL has a nonempty `title` query parameter like `/w/index.php?title=Foo`. The script - // path should match. - const matches = regExp.test( mwUri.path ); - if ( !matches ) { - return null; - } - - // The parameter was already decoded at Uri construction. - title = mwUri.query.title; - } else { - // True if input starts with wgArticlePath and ends with a nonempty page title. The - // first matching group (index 1) is the page title. - // eslint-disable-next-line security/detect-non-literal-regexp - const regExp = new RegExp( '^' + mw.util.escapeRegExp( mw.config.get( 'wgArticlePath' ) ).replace( '\\$1', '(.+)' ) ); - - // No title query parameter is present so the URL may be "pretty" like `/wiki/Foo`. - // `Uri.path` should not contain query parameters or a fragment, as is assumed in - // `Uri.getRelativePath()`. Try to isolate the title. - const matches = regExp.exec( mwUri.path ); - if ( !matches || !matches[ 1 ] ) { - return null; - } - - try { - // `Uri.path` was not previously decoded, as is assumed in `Uri.getRelativePath()`, - // and decoding may now fail. Do not use `Uri.decode()` which is designed to be - // paired with `Uri.encode()` and replaces `+` characters with spaces. - title = decodeURIComponent( matches[ 1 ] ); - } catch ( e ) { - return null; - } - } - - // Append the fragment, if present. - title += mwUri.fragment ? '#' + mwUri.fragment : ''; - - return mw.Title.newFromText( title ); } - /** - * Validates that the passed link is for reading. - * - * The following links return true: - * /wiki/Foo - * /w/index.php?title=Foo - * /w/index.php?oldid=123 - * - * The following links return false: - * /w/index.php?title=Foo&action=bar - * - * @private - * @static - * @method isReadOnlyUri - * @param {mw.Uri} uri A Uri to an internal wiki page. - * @return {boolean} True if uri has no query parameters or only known parameters for reading. - */ - function isReadOnlyUri( uri ) { - const length = Object.keys( uri.query ).length; - return length === ( ( 'oldid' in uri.query ? 1 : 0 ) + ( 'title' in uri.query ? 1 : 0 ) ); - } + // Append the fragment, if present. + title += mwUri.fragment ? '#' + mwUri.fragment : ''; - module.exports = { - newFromUri: newFromUri - }; -}() ); + return mw.Title.newFromText( title ); +} + +/** + * Validates that the passed link is for reading. + * + * The following links return true: + * /wiki/Foo + * /w/index.php?title=Foo + * /w/index.php?oldid=123 + * + * The following links return false: + * /w/index.php?title=Foo&action=bar + * + * @private + * @static + * @method isReadOnlyUri + * @param {mw.Uri} uri A Uri to an internal wiki page. + * @return {boolean} True if uri has no query parameters or only known parameters for reading. + */ +function isReadOnlyUri( uri ) { + const length = Object.keys( uri.query ).length; + return length === ( ( 'oldid' in uri.query ? 1 : 0 ) + ( 'title' in uri.query ? 1 : 0 ) ); +} + +module.exports = { + newFromUri: newFromUri +}; diff --git a/resources/skins.minerva.scripts/UriUtil.js b/resources/skins.minerva.scripts/UriUtil.js index f2edfc231..d2d94cc5c 100644 --- a/resources/skins.minerva.scripts/UriUtil.js +++ b/resources/skins.minerva.scripts/UriUtil.js @@ -1,39 +1,37 @@ -( function () { - /** - * Compares the default Uri host, usually `window.location.host`, and `mw.Uri.host`. Equivalence - * tests internal linkage, a mismatch may indicate an external link. Interwiki links are - * considered external. - * - * This function only indicates internal in the sense of being on the same host or not. It has - * no knowledge of [[Link]] vs [Link] links. - * - * On https://meta.wikimedia.org/wiki/Foo, the following links would be considered *internal* - * and return `true`: - * - * https://meta.wikimedia.org/ - * https://meta.wikimedia.org/wiki/Bar - * https://meta.wikimedia.org/w/index.php?title=Bar - * - * Similarly, the following links would be considered *not* internal and return `false`: - * - * https://archive.org/ - * https://foo.wikimedia.org/ - * https://en.wikipedia.org/ - * https://en.wikipedia.org/wiki/Bar - * - * @param {mw.Uri} uri - * @return {boolean} - */ - function isInternal( uri ) { - try { - // mw.Uri can throw exceptions (T264914, T66884) - return uri.host === mw.Uri().host; - } catch ( e ) { - return false; - } +/** + * Compares the default Uri host, usually `window.location.host`, and `mw.Uri.host`. Equivalence + * tests internal linkage, a mismatch may indicate an external link. Interwiki links are + * considered external. + * + * This function only indicates internal in the sense of being on the same host or not. It has + * no knowledge of [[Link]] vs [Link] links. + * + * On https://meta.wikimedia.org/wiki/Foo, the following links would be considered *internal* + * and return `true`: + * + * https://meta.wikimedia.org/ + * https://meta.wikimedia.org/wiki/Bar + * https://meta.wikimedia.org/w/index.php?title=Bar + * + * Similarly, the following links would be considered *not* internal and return `false`: + * + * https://archive.org/ + * https://foo.wikimedia.org/ + * https://en.wikipedia.org/ + * https://en.wikipedia.org/wiki/Bar + * + * @param {mw.Uri} uri + * @return {boolean} + */ +function isInternal( uri ) { + try { + // mw.Uri can throw exceptions (T264914, T66884) + return uri.host === mw.Uri().host; + } catch ( e ) { + return false; } +} - module.exports = { - isInternal: isInternal - }; -}() ); +module.exports = { + isInternal: isInternal +}; diff --git a/resources/skins.minerva.scripts/downloadPageAction.js b/resources/skins.minerva.scripts/downloadPageAction.js index ba1145ec1..b8bdfc179 100644 --- a/resources/skins.minerva.scripts/downloadPageAction.js +++ b/resources/skins.minerva.scripts/downloadPageAction.js @@ -1,185 +1,183 @@ -( function ( track ) { - const MAX_PRINT_TIMEOUT = 3000; - let printSetTimeoutReference = 0; - const mobile = require( 'mobile.startup' ); +const track = mw.track; +const MAX_PRINT_TIMEOUT = 3000; +let printSetTimeoutReference = 0; +const mobile = require( 'mobile.startup' ); - /** - * Helper function to detect iOs - * - * @ignore - * @param {string} userAgent User Agent - * @return {boolean} - */ - function isIos( userAgent ) { - return /ipad|iphone|ipod/i.test( userAgent ); +/** + * Helper function to detect iOs + * + * @ignore + * @param {string} userAgent User Agent + * @return {boolean} + */ +function isIos( userAgent ) { + return /ipad|iphone|ipod/i.test( userAgent ); +} + +/** + * Helper function to retrieve the Android version + * + * @ignore + * @param {string} userAgent User Agent + * @return {number|boolean} Integer version number, or false if not found + */ +function getAndroidVersion( userAgent ) { + const match = userAgent.toLowerCase().match( /android\s(\d\.]*)/ ); + return match ? parseInt( match[ 1 ] ) : false; +} + +/** + * Helper function to retrieve the Chrome/Chromium version + * + * @ignore + * @param {string} userAgent User Agent + * @return {number|boolean} Integer version number, or false if not found + */ +function getChromeVersion( userAgent ) { + const match = userAgent.toLowerCase().match( /chrom(e|ium)\/(\d+)\./ ); + return match ? parseInt( match[ 2 ] ) : false; +} + +/** + * Checks whether DownloadIcon is available for given user agent + * + * @memberof DownloadIcon + * @instance + * @param {Window} windowObj + * @param {Page} page to download + * @param {string} userAgent User agent + * @param {number[]} supportedNamespaces where printing is possible + * @return {boolean} + */ +function isAvailable( windowObj, page, userAgent, supportedNamespaces ) { + const androidVersion = getAndroidVersion( userAgent ); + const chromeVersion = getChromeVersion( userAgent ); + + if ( typeof window.print !== 'function' ) { + // T309591: No window.print support + return false; } - /** - * Helper function to retrieve the Android version - * - * @ignore - * @param {string} userAgent User Agent - * @return {number|boolean} Integer version number, or false if not found - */ - function getAndroidVersion( userAgent ) { - const match = userAgent.toLowerCase().match( /android\s(\d\.]*)/ ); - return match ? parseInt( match[ 1 ] ) : false; + // Download button is restricted to certain namespaces T181152. + // Not shown on missing pages + // Defaults to 0, in case cached JS has been served. + if ( supportedNamespaces.indexOf( page.getNamespaceId() ) === -1 || + page.isMainPage() || page.isMissing ) { + // namespace is not supported or it's a main page + return false; } - /** - * Helper function to retrieve the Chrome/Chromium version - * - * @ignore - * @param {string} userAgent User Agent - * @return {number|boolean} Integer version number, or false if not found - */ - function getChromeVersion( userAgent ) { - const match = userAgent.toLowerCase().match( /chrom(e|ium)\/(\d+)\./ ); - return match ? parseInt( match[ 2 ] ) : false; + if ( isIos( userAgent ) || chromeVersion === false || + windowObj.chrome === undefined + ) { + // we support only chrome/chromium on desktop/android + return false; + } + if ( ( androidVersion && androidVersion < 5 ) || chromeVersion < 41 ) { + return false; + } + return true; +} +/** + * onClick handler for button that invokes print function + * + * @param {HTMLElement} portletItem + * @param {Icon} spinner + * @param {Function} [loadAllImagesInPage] + */ +function onClick( portletItem, spinner, loadAllImagesInPage ) { + const icon = portletItem.querySelector( '.minerva-icon--download' ); + function doPrint() { + printSetTimeoutReference = clearTimeout( printSetTimeoutReference ); + track( 'minerva.downloadAsPDF', { + action: 'callPrint' + } ); + window.print(); + $( icon ).show(); + spinner.$el.hide(); } - /** - * Checks whether DownloadIcon is available for given user agent - * - * @memberof DownloadIcon - * @instance - * @param {Window} windowObj - * @param {Page} page to download - * @param {string} userAgent User agent - * @param {number[]} supportedNamespaces where printing is possible - * @return {boolean} - */ - function isAvailable( windowObj, page, userAgent, supportedNamespaces ) { - const androidVersion = getAndroidVersion( userAgent ); - const chromeVersion = getChromeVersion( userAgent ); - - if ( typeof window.print !== 'function' ) { - // T309591: No window.print support - return false; + function doPrintBeforeTimeout() { + if ( printSetTimeoutReference ) { + doPrint(); } - - // Download button is restricted to certain namespaces T181152. - // Not shown on missing pages - // Defaults to 0, in case cached JS has been served. - if ( supportedNamespaces.indexOf( page.getNamespaceId() ) === -1 || - page.isMainPage() || page.isMissing ) { - // namespace is not supported or it's a main page - return false; - } - - if ( isIos( userAgent ) || chromeVersion === false || - windowObj.chrome === undefined - ) { - // we support only chrome/chromium on desktop/android - return false; - } - if ( ( androidVersion && androidVersion < 5 ) || chromeVersion < 41 ) { - return false; - } - return true; } - /** - * onClick handler for button that invokes print function - * - * @param {HTMLElement} portletItem - * @param {Icon} spinner - * @param {Function} [loadAllImagesInPage] - */ - function onClick( portletItem, spinner, loadAllImagesInPage ) { - const icon = portletItem.querySelector( '.minerva-icon--download' ); - function doPrint() { - printSetTimeoutReference = clearTimeout( printSetTimeoutReference ); - track( 'minerva.downloadAsPDF', { - action: 'callPrint' + // The click handler may be invoked multiple times so if a pending print is occurring + // do nothing. + if ( !printSetTimeoutReference ) { + track( 'minerva.downloadAsPDF', { + action: 'fetchImages' + } ); + $( icon ).hide(); + spinner.$el.show(); + // If all image downloads are taking longer to load then the MAX_PRINT_TIMEOUT + // abort the spinner and print regardless. + printSetTimeoutReference = setTimeout( doPrint, MAX_PRINT_TIMEOUT ); + ( loadAllImagesInPage || mobile.loadAllImagesInPage )() + .then( doPrintBeforeTimeout, doPrintBeforeTimeout ); + } +} + +/** + * Generate a download icon for triggering print functionality if + * printing is available. + * Calling this method has side effects: + * It calls mw.util.addPortletLink and may inject an element into the page. + * + * @param {Page} page + * @param {number[]} supportedNamespaces + * @param {Window} [windowObj] window object + * @param {boolean} [overflowList] Append to overflow list + * @return {jQuery|null} + */ +function downloadPageAction( page, supportedNamespaces, windowObj, overflowList ) { + const spinner = ( overflowList ) ? mobile.spinner( { + label: '', + isIconOnly: false + } ) : mobile.spinner(); + + if ( + isAvailable( + windowObj, page, navigator.userAgent, + supportedNamespaces + ) + ) { + // FIXME: Use p-views when cache has cleared. + const actionID = document.querySelector( '#p-views' ) ? 'p-views' : 'page-actions'; + const portletLink = mw.util.addPortletLink( + overflowList ? 'page-actions-overflow' : actionID, + '#', + mw.msg( 'minerva-download' ), + // id + 'minerva-download', + // tooltip + mw.msg( 'minerva-download' ), + // access key + 'p', + overflowList ? null : document.getElementById( 'page-actions-watch' ) + ); + if ( portletLink ) { + portletLink.addEventListener( 'click', () => { + onClick( portletLink, spinner, mobile.loadAllImagesInPage ); } ); - window.print(); - $( icon ).show(); - spinner.$el.hide(); - } - - function doPrintBeforeTimeout() { - if ( printSetTimeoutReference ) { - doPrint(); + const iconElement = portletLink.querySelector( '.minerva-icon' ); + if ( iconElement ) { + iconElement.classList.add( 'minerva-icon--download' ); } - } - // The click handler may be invoked multiple times so if a pending print is occurring - // do nothing. - if ( !printSetTimeoutReference ) { - track( 'minerva.downloadAsPDF', { - action: 'fetchImages' - } ); - $( icon ).hide(); - spinner.$el.show(); - // If all image downloads are taking longer to load then the MAX_PRINT_TIMEOUT - // abort the spinner and print regardless. - printSetTimeoutReference = setTimeout( doPrint, MAX_PRINT_TIMEOUT ); - ( loadAllImagesInPage || mobile.loadAllImagesInPage )() - .then( doPrintBeforeTimeout, doPrintBeforeTimeout ); - } - } - - /** - * Generate a download icon for triggering print functionality if - * printing is available. - * Calling this method has side effects: - * It calls mw.util.addPortletLink and may inject an element into the page. - * - * @param {Page} page - * @param {number[]} supportedNamespaces - * @param {Window} [windowObj] window object - * @param {boolean} [overflowList] Append to overflow list - * @return {jQuery|null} - */ - function downloadPageAction( page, supportedNamespaces, windowObj, overflowList ) { - const spinner = ( overflowList ) ? mobile.spinner( { - label: '', - isIconOnly: false - } ) : mobile.spinner(); - - if ( - isAvailable( - windowObj, page, navigator.userAgent, - supportedNamespaces - ) - ) { - // FIXME: Use p-views when cache has cleared. - const actionID = document.querySelector( '#p-views' ) ? 'p-views' : 'page-actions'; - const portletLink = mw.util.addPortletLink( - overflowList ? 'page-actions-overflow' : actionID, - '#', - mw.msg( 'minerva-download' ), - // id - 'minerva-download', - // tooltip - mw.msg( 'minerva-download' ), - // access key - 'p', - overflowList ? null : document.getElementById( 'page-actions-watch' ) + spinner.$el.hide().insertBefore( + $( portletLink ).find( '.minerva-icon' ) ); - if ( portletLink ) { - portletLink.addEventListener( 'click', () => { - onClick( portletLink, spinner, mobile.loadAllImagesInPage ); - } ); - const iconElement = portletLink.querySelector( '.minerva-icon' ); - if ( iconElement ) { - iconElement.classList.add( 'minerva-icon--download' ); - } - spinner.$el.hide().insertBefore( - $( portletLink ).find( '.minerva-icon' ) - ); - } - return portletLink; - } else { - return null; } + return portletLink; + } else { + return null; } +} - module.exports = { - downloadPageAction, - test: { - isAvailable, - onClick - } - }; - -}( mw.track ) ); +module.exports = { + downloadPageAction, + test: { + isAvailable, + onClick + } +}; diff --git a/resources/skins.minerva.scripts/page-issues/parser.js b/resources/skins.minerva.scripts/page-issues/parser.js index 9b000338b..bf2839018 100644 --- a/resources/skins.minerva.scripts/page-issues/parser.js +++ b/resources/skins.minerva.scripts/page-issues/parser.js @@ -1,223 +1,220 @@ -( function () { - /** - * @typedef PageIssue - * @property {string} severity A SEVERITY_LEVEL key. - * @property {boolean} grouped True if part of a group of multiple issues, false if singular. - * @property {Icon} icon - */ - /** - * @typedef {Object} IssueSummary - * @property {PageIssue} issue - * @property {jQuery} $el where the issue was extracted from - * @property {string} iconString a string representation of icon. - * This is kept for template compatibility (our views do not yet support composition). - * @property {string} text HTML string. - */ +/** + * @typedef PageIssue + * @property {string} severity A SEVERITY_LEVEL key. + * @property {boolean} grouped True if part of a group of multiple issues, false if singular. + * @property {Icon} icon + */ +/** + * @typedef {Object} IssueSummary + * @property {PageIssue} issue + * @property {jQuery} $el where the issue was extracted from + * @property {string} iconString a string representation of icon. + * This is kept for template compatibility (our views do not yet support composition). + * @property {string} text HTML string. + */ - // Icons are matching the type selector below use a TYPE_* icon. When unmatched, the icon is - // chosen by severity. Their color is always determined by severity, too. - const ICON_NAME = { - // Generic severity icons. - SEVERITY: { - DEFAULT: 'issue-generic', - LOW: 'issue-severity-low', - MEDIUM: 'issue-severity-medium', - HIGH: 'issue-generic' - }, +// Icons are matching the type selector below use a TYPE_* icon. When unmatched, the icon is +// chosen by severity. Their color is always determined by severity, too. +const ICON_NAME = { + // Generic severity icons. + SEVERITY: { + DEFAULT: 'issue-generic', + LOW: 'issue-severity-low', + MEDIUM: 'issue-severity-medium', + HIGH: 'issue-generic' + }, - // Icons customized by type. - TYPE: { - MOVE: 'issue-type-move', - POINT_OF_VIEW: 'issue-type-point-of-view' + // Icons customized by type. + TYPE: { + MOVE: 'issue-type-move', + POINT_OF_VIEW: 'issue-type-point-of-view' + } +}; +const ICON_COLOR = { + DEFAULT: 'defaultColor', + LOW: 'lowColor', + MEDIUM: 'mediumColor', + HIGH: 'highColor' +}; +// How severities order and compare from least to greatest. For the multiple issues +// template, severity should be considered the maximum of all its contained issues. +const SEVERITY_LEVEL = { + DEFAULT: 0, + LOW: 1, + MEDIUM: 2, + HIGH: 3 +}; +// Match the template's color CSS selector to a severity level concept. Derived via the +// Ambox templates and sub-templates for the top five wikis and tested on page issues +// inventory: +// - https://people.wikimedia.org/~jdrewniak/page_issues_inventory +// - https://en.wikipedia.org/wiki/Template:Ambox +// - https://es.wikipedia.org/wiki/Plantilla:Metaplantilla_de_avisos +// - https://ja.wikipedia.org/wiki/Template:Ambox +// - https://ru.wikipedia.org/wiki/Шаблон:Ambox +// - https://it.wikipedia.org/wiki/Template:Avviso +// Severity is the class associated with the color. The ResourceLoader config mimics the +// idea by using severity for color variants. Severity is determined independently of icons. +// These selectors should be migrated to their templates. +const SEVERITY_REGEX = { + // recommended (T206177), en, it + LOW: /mobile-issue-severity-low|ambox-style|avviso-stile/, + // recommended, en, it + MEDIUM: /mobile-issue-severity-medium|ambox-content|avviso-contenuto/, + // recommended, en, en, es / ru, it + HIGH: /mobile-issue-severity-high|ambox-speedy|ambox-delete|ambox-serious|avviso-importante/ + // ..And everything else that doesn't match should be considered DEFAULT. +}; +// As above but used to identify specific templates requiring icon customization. +const TYPE_REGEX = { + // recommended (opt-in) / en, es / ru, it (long term only recommended should be used) + MOVE: /mobile-issue-move|ambox-converted|ambox-move|ambox-merge|avviso-struttura/, + // eslint-disable-next-line security/detect-non-literal-regexp + POINT_OF_VIEW: new RegExp( [ + // recommended (opt-in) + 'mobile-issue-pov', + // FIXME: en classes: plan to remove these provided can get adoption of recommended + 'ambox-Advert', + 'ambox-autobiography', + 'ambox-believerpov', + 'ambox-COI', + 'ambox-coverage', + 'ambox-criticism', + 'ambox-fanpov', + 'ambox-fringe-theories', + 'ambox-geographical-imbalance', + 'ambox-globalize', + 'ambox-npov-language', + 'ambox-POV', + 'ambox-pseudo', + 'ambox-systemic-bias', + 'ambox-unbalanced', + 'ambox-usgovtpov' + ].join( '|' ) ) + // ..And everything else that doesn't match is mapped to a "SEVERITY" type. +}; +const GROUPED_PARENT_REGEX = /mw-collapsible-content/; +// Variants supported by specific types. The "severity icon" supports all severities but the +// type icons only support one each by ResourceLoader. +const TYPE_SEVERITY = { + MOVE: 'DEFAULT', + POINT_OF_VIEW: 'MEDIUM' +}; + +/** + * @param {Element} box + * @return {string} An SEVERITY_SELECTOR key. + */ +function parseSeverity( box ) { + let severity; + const identified = Object.keys( SEVERITY_REGEX ).some( ( key ) => { + const regex = SEVERITY_REGEX[ key ]; + severity = key; + return regex.test( box.className ); + } ); + return identified ? severity : 'DEFAULT'; +} + +/** + * @param {Element} box + * @param {string} severity An SEVERITY_LEVEL key. + * @return {{name: string, severity: string}} An ICON_NAME. + */ +function parseType( box, severity ) { + let identifiedType; + const identified = Object.keys( TYPE_REGEX ).some( ( type ) => { + const regex = TYPE_REGEX[ type ]; + identifiedType = type; + return regex.test( box.className ); + } ); + return { + name: identified ? ICON_NAME.TYPE[ identifiedType ] : ICON_NAME.SEVERITY[ severity ], + severity: identified ? TYPE_SEVERITY[ identifiedType ] : severity + }; +} + +/** + * @param {Element} box + * @return {boolean} True if part of a group of multiple issues, false if singular. + */ +function parseGroup( box ) { + return !!box.parentNode && GROUPED_PARENT_REGEX.test( box.parentNode.className ); +} + +/** + * @param {Element} box + * @param {string} severity An SEVERITY_LEVEL key. + * @return {string} A severity or type ISSUE_ICON. + */ +function iconName( box, severity ) { + const nameSeverity = parseType( box, severity ); + // The icon with color variant as expected by ResourceLoader, + // {iconName}-{severityColorVariant}. + return nameSeverity.name + '-' + ICON_COLOR[ nameSeverity.severity ]; +} + +/** + * @param {string[]} severityLevels an array of SEVERITY_KEY values. + * @return {string} The greatest SEVERITY_LEVEL key. + */ +function maxSeverity( severityLevels ) { + return severityLevels.reduce( ( max, severity ) => SEVERITY_LEVEL[ max ] > SEVERITY_LEVEL[ severity ] ? max : severity, 'DEFAULT' ); +} + +/** + * @param {Element} box + * @return {PageIssue} + */ +function parse( box ) { + const severity = parseSeverity( box ); + const iconElement = document.createElement( 'div' ); + iconElement.classList.add( `minerva-icon--${ iconName( box, severity ) }`, 'minerva-ambox-icon' ); + return { + severity, + grouped: parseGroup( box ), + iconElement + }; +} + +/** + * Extract a summary message from a cleanup template generated element that is + * friendly for mobile display. + * + * @param {Object} $box element to extract the message from + * @return {IssueSummary} + */ +function extract( $box ) { + const SELECTOR = '.mbox-text, .ambox-text'; + const $container = $( '
' ); + + $box.find( SELECTOR ).each( ( _i, el ) => { + const $el = $( el ); + // Clean up talk page boxes + $el.find( 'table, .noprint' ).remove(); + const contents = $el.html(); + + if ( contents ) { + $( '

' ).html( contents ).appendTo( $container ); } + } ); + + const pageIssue = parse( $box.get( 0 ) ); + + return { + issue: pageIssue, + $el: $box, + text: $container.html() }; - const ICON_COLOR = { - DEFAULT: 'defaultColor', - LOW: 'lowColor', - MEDIUM: 'mediumColor', - HIGH: 'highColor' - }; - // How severities order and compare from least to greatest. For the multiple issues - // template, severity should be considered the maximum of all its contained issues. - const SEVERITY_LEVEL = { - DEFAULT: 0, - LOW: 1, - MEDIUM: 2, - HIGH: 3 - }; - // Match the template's color CSS selector to a severity level concept. Derived via the - // Ambox templates and sub-templates for the top five wikis and tested on page issues - // inventory: - // - https://people.wikimedia.org/~jdrewniak/page_issues_inventory - // - https://en.wikipedia.org/wiki/Template:Ambox - // - https://es.wikipedia.org/wiki/Plantilla:Metaplantilla_de_avisos - // - https://ja.wikipedia.org/wiki/Template:Ambox - // - https://ru.wikipedia.org/wiki/Шаблон:Ambox - // - https://it.wikipedia.org/wiki/Template:Avviso - // Severity is the class associated with the color. The ResourceLoader config mimics the - // idea by using severity for color variants. Severity is determined independently of icons. - // These selectors should be migrated to their templates. - const SEVERITY_REGEX = { - // recommended (T206177), en, it - LOW: /mobile-issue-severity-low|ambox-style|avviso-stile/, - // recommended, en, it - MEDIUM: /mobile-issue-severity-medium|ambox-content|avviso-contenuto/, - // recommended, en, en, es / ru, it - HIGH: /mobile-issue-severity-high|ambox-speedy|ambox-delete|ambox-serious|avviso-importante/ - // ..And everything else that doesn't match should be considered DEFAULT. - }; - // As above but used to identify specific templates requiring icon customization. - const TYPE_REGEX = { - // recommended (opt-in) / en, es / ru, it (long term only recommended should be used) - MOVE: /mobile-issue-move|ambox-converted|ambox-move|ambox-merge|avviso-struttura/, - // eslint-disable-next-line security/detect-non-literal-regexp - POINT_OF_VIEW: new RegExp( [ - // recommended (opt-in) - 'mobile-issue-pov', - // FIXME: en classes: plan to remove these provided can get adoption of recommended - 'ambox-Advert', - 'ambox-autobiography', - 'ambox-believerpov', - 'ambox-COI', - 'ambox-coverage', - 'ambox-criticism', - 'ambox-fanpov', - 'ambox-fringe-theories', - 'ambox-geographical-imbalance', - 'ambox-globalize', - 'ambox-npov-language', - 'ambox-POV', - 'ambox-pseudo', - 'ambox-systemic-bias', - 'ambox-unbalanced', - 'ambox-usgovtpov' - ].join( '|' ) ) - // ..And everything else that doesn't match is mapped to a "SEVERITY" type. - }; - const GROUPED_PARENT_REGEX = /mw-collapsible-content/; - // Variants supported by specific types. The "severity icon" supports all severities but the - // type icons only support one each by ResourceLoader. - const TYPE_SEVERITY = { - MOVE: 'DEFAULT', - POINT_OF_VIEW: 'MEDIUM' - }; +} - /** - * @param {Element} box - * @return {string} An SEVERITY_SELECTOR key. - */ - function parseSeverity( box ) { - let severity; - const identified = Object.keys( SEVERITY_REGEX ).some( ( key ) => { - const regex = SEVERITY_REGEX[ key ]; - severity = key; - return regex.test( box.className ); - } ); - return identified ? severity : 'DEFAULT'; +module.exports = { + extract, + parse, + maxSeverity, + iconName, + test: { + parseSeverity, + parseType, + parseGroup } - - /** - * @param {Element} box - * @param {string} severity An SEVERITY_LEVEL key. - * @return {{name: string, severity: string}} An ICON_NAME. - */ - function parseType( box, severity ) { - let identifiedType; - const identified = Object.keys( TYPE_REGEX ).some( ( type ) => { - const regex = TYPE_REGEX[ type ]; - identifiedType = type; - return regex.test( box.className ); - } ); - return { - name: identified ? ICON_NAME.TYPE[ identifiedType ] : ICON_NAME.SEVERITY[ severity ], - severity: identified ? TYPE_SEVERITY[ identifiedType ] : severity - }; - } - - /** - * @param {Element} box - * @return {boolean} True if part of a group of multiple issues, false if singular. - */ - function parseGroup( box ) { - return !!box.parentNode && GROUPED_PARENT_REGEX.test( box.parentNode.className ); - } - - /** - * @param {Element} box - * @param {string} severity An SEVERITY_LEVEL key. - * @return {string} A severity or type ISSUE_ICON. - */ - function iconName( box, severity ) { - const nameSeverity = parseType( box, severity ); - // The icon with color variant as expected by ResourceLoader, - // {iconName}-{severityColorVariant}. - return nameSeverity.name + '-' + ICON_COLOR[ nameSeverity.severity ]; - } - - /** - * @param {string[]} severityLevels an array of SEVERITY_KEY values. - * @return {string} The greatest SEVERITY_LEVEL key. - */ - function maxSeverity( severityLevels ) { - return severityLevels.reduce( ( max, severity ) => SEVERITY_LEVEL[ max ] > SEVERITY_LEVEL[ severity ] ? max : severity, 'DEFAULT' ); - } - - /** - * @param {Element} box - * @return {PageIssue} - */ - function parse( box ) { - const severity = parseSeverity( box ); - const iconElement = document.createElement( 'div' ); - iconElement.classList.add( `minerva-icon--${ iconName( box, severity ) }`, 'minerva-ambox-icon' ); - return { - severity, - grouped: parseGroup( box ), - iconElement - }; - } - - /** - * Extract a summary message from a cleanup template generated element that is - * friendly for mobile display. - * - * @param {Object} $box element to extract the message from - * @return {IssueSummary} - */ - function extract( $box ) { - const SELECTOR = '.mbox-text, .ambox-text'; - const $container = $( '

' ); - - $box.find( SELECTOR ).each( ( _i, el ) => { - const $el = $( el ); - // Clean up talk page boxes - $el.find( 'table, .noprint' ).remove(); - const contents = $el.html(); - - if ( contents ) { - $( '

' ).html( contents ).appendTo( $container ); - } - } ); - - const pageIssue = parse( $box.get( 0 ) ); - - return { - issue: pageIssue, - $el: $box, - text: $container.html() - }; - } - - module.exports = { - extract, - parse, - maxSeverity, - iconName, - test: { - parseSeverity, - parseType, - parseGroup - } - }; - -}() ); +}; diff --git a/resources/skins.minerva.scripts/watchstar.js b/resources/skins.minerva.scripts/watchstar.js index c15a84af1..0515fb37a 100644 --- a/resources/skins.minerva.scripts/watchstar.js +++ b/resources/skins.minerva.scripts/watchstar.js @@ -1,50 +1,45 @@ const watchstar = require( 'mediawiki.page.watch.ajax' ).watchstar; +const WATCHED_ICON_CLASS = 'minerva-icon--unStar-progressive'; +const TEMP_WATCHED_ICON_CLASS = 'minerva-icon--halfStar-progressive'; +const UNWATCHED_ICON_CLASS = 'minerva-icon--star-base20'; -( function () { +/** + * Tweaks the global watchstar handler in core to use the correct classes for Minerva. + * + * @param {jQuery} $icon + */ +function init( $icon ) { + const $watchlink = $icon.find( 'a' ); + watchstar( $watchlink, mw.config.get( 'wgRelevantPageName' ), toggleClasses ); +} - const WATCHED_ICON_CLASS = 'minerva-icon--unStar-progressive'; - const TEMP_WATCHED_ICON_CLASS = 'minerva-icon--halfStar-progressive'; - const UNWATCHED_ICON_CLASS = 'minerva-icon--star-base20'; - - /** - * Tweaks the global watchstar handler in core to use the correct classes for Minerva. - * - * @param {jQuery} $icon - */ - function init( $icon ) { - const $watchlink = $icon.find( 'a' ); - watchstar( $watchlink, mw.config.get( 'wgRelevantPageName' ), toggleClasses ); - } - - /** - * @param {jQuery} $link - * @param {boolean} isWatched - * @param {string} expiry - */ - function toggleClasses( $link, isWatched, expiry ) { - const $icon = $link.find( '.minerva-icon' ); - $icon.removeClass( [ WATCHED_ICON_CLASS, UNWATCHED_ICON_CLASS, TEMP_WATCHED_ICON_CLASS ] ) - .addClass( () => { - let classes = UNWATCHED_ICON_CLASS; - if ( isWatched ) { - if ( expiry !== null && expiry !== undefined && expiry !== 'infinity' ) { - classes = TEMP_WATCHED_ICON_CLASS; - } else { - classes = WATCHED_ICON_CLASS; - } +/** + * @param {jQuery} $link + * @param {boolean} isWatched + * @param {string} expiry + */ +function toggleClasses( $link, isWatched, expiry ) { + const $icon = $link.find( '.minerva-icon' ); + $icon.removeClass( [ WATCHED_ICON_CLASS, UNWATCHED_ICON_CLASS, TEMP_WATCHED_ICON_CLASS ] ) + .addClass( () => { + let classes = UNWATCHED_ICON_CLASS; + if ( isWatched ) { + if ( expiry !== null && expiry !== undefined && expiry !== 'infinity' ) { + classes = TEMP_WATCHED_ICON_CLASS; + } else { + classes = WATCHED_ICON_CLASS; } - return classes; - } ); + } + return classes; + } ); +} + +module.exports = { + init: init, + test: { + toggleClasses, + TEMP_WATCHED_ICON_CLASS, + WATCHED_ICON_CLASS, + UNWATCHED_ICON_CLASS } - - module.exports = { - init: init, - test: { - toggleClasses, - TEMP_WATCHED_ICON_CLASS, - WATCHED_ICON_CLASS, - UNWATCHED_ICON_CLASS - } - }; - -}() ); +};