Remove all use of IIFEs across Minerva codebase

These will make JSDoc easier to configure later and is
better suited to ES6 module format.
The dependency injection pattern can be retained by moving
the import to the top of the file.

Change-Id: I0fe692eb7066e52815ef3d21724c7439d82b2c5f
This commit is contained in:
Ed Sanders 2024-06-26 14:17:23 +01:00 committed by Jdlrobson
parent 906034a3d1
commit 527e018af1
7 changed files with 647 additions and 663 deletions

View file

@ -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"

View file

@ -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;

View file

@ -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
};

View file

@ -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
};

View file

@ -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
}
};

View file

@ -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 = $( '<div>' );
$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 ) {
$( '<p>' ).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 = $( '<div>' );
$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 ) {
$( '<p>' ).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
}
};
}() );
};

View file

@ -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
}
};
}() );
};