MediaWiki:Gadget-script-installer-core.js

( function () {

// An mw.Api object

let api;

// Keep "common" at beginning

const SKINS = [ 'common', 'monobook', 'minerva', 'vector', 'vector-2022', 'timeless' ];

// How many scripts do we need before we show the quick filter?

const NUM_SCRIPTS_FOR_SEARCH = 5;

// The primary import list, keyed by target. (A "target" is a user JS subpage

// where the script is imported, like "common" or "vector".) Set in buildImportList

const imports = {};

// Local scripts, keyed on name; value will be the target. Set in buildImportList.

const localScriptsByName = {};

// How many scripts are installed?

let scriptCount = 0;

// Goes on the end of edit summaries

const ADVERT = ' (script-installer)';

/**

* Strings, for translation

*/

const STRINGS = {

skinCommon: 'common (applies to all skins)',

backlink: 'Backlink:',

installSummary: 'Installing $1',

installLinkText: 'Install',

installProgressMsg: 'Installing...',

uninstallSummary: 'Uninstalling $1',

uninstallLinkText: 'Uninstall',

uninstallProgressMsg: 'Uninstalling...',

disableSummary: 'Disabling $1',

disableLinkText: 'Disable',

disableProgressMsg: 'Disabling...',

enableSummary: 'Enabling $1',

enableLinkText: 'Enable',

enableProgressMsg: 'Enabling...',

moveLinkText: 'Move',

moveProgressMsg: 'Moving...',

movePrompt: 'Destination? Enter one of:', // followed by the names of skins

normalizeSummary: 'Normalizing script installs',

remoteUrlDesc: '$1, loaded from $2',

panelHeader: 'You currently have the following scripts installed (find more at WP:USL)',

cannotInstall: 'Cannot install',

cannotInstallSkin: 'This page is one of your user customization pages, and may (will, if common.js) already run on each page load.',

cannotInstallContentModel: "Page content model is $1, not 'javascript'",

insecure: '(insecure)', // used at the end of some messages

notJavaScript: 'not JavaScript',

installViaPreferences: 'Install via preferences',

showNormalizeLinks: 'Show "normalize" links?',

normalize: 'normalize',

showMoveLinks: 'Show "move" links?',

quickFilter: 'Quick filter:',

tempWarning: 'Installation of non-User, non-MediaWiki protected pages is temporary and may be removed in the future.',

badPageError: 'Page is not User: or MediaWiki: and is unprotected',

manageUserScripts: 'Manage user scripts',

bigSecurityWarning: "Warning!$1\n\nAll user scripts could contain malicious content capable of compromising your account. Installing a script means it could be changed by others; make sure you trust its author. If you're unsure whether a script is safe, check at the technical village pump. Install this script?",

securityWarningSection: ' About to install $1.'

};

const USER_NAMESPACE_NAME = mw.config.get( 'wgFormattedNamespaces' )[ 2 ];

/**

* Constructs an Import. An Import is a line in a JS file that imports a

* user script. Properties:

*

* EXACTLY one of "page" or "url" are null for every Import. This

* constructor should not be used directly; use the factory

* functions (Import.ofLocal, Import.ofUrl, Import.fromJs) instead.

*

* this.type = 0 if local, 1 if remotely loaded, and 2 if URL.

*

* @param page a page name, such as "User:Foo/Bar.js".

* @param wiki a wiki from which the script is loaded, such as

* "en.wikipedia" If null, the script is local, on the user's

* wiki.

* @param url a URL that can be passed into mw.loader.load.

* @param target the title of the user subpage where the script is,

* without the .js ending: for example, "common".

* @param disabled whether this import is commented out.

*/

function Import( page, wiki, url, target, disabled ) {

this.page = page;

this.wiki = wiki;

this.url = url;

this.target = target;

this.disabled = disabled;

this.type = this.url ? 2 : ( this.wiki ? 1 : 0 );

}

Import.ofLocal = function ( page, target, disabled ) {

if ( disabled === undefined ) {

disabled = false;

}

return new Import( page, null, null, target, disabled );

};

/**

* URL to Import. Assumes wgScriptPath is "/w"

*/

Import.ofUrl = function ( url, target, disabled ) {

if ( disabled === undefined ) {

disabled = false;

}

const URL_RGX = /^(?:https?:)?\/\/(.+?)\.org\/w\/index\.php\?.*?title=(.+?(?:&|$))/;

const match = URL_RGX.exec( url );

if ( match ) {

const title = decodeURIComponent( match[ 2 ].replace( /&$/, '' ) ),

wiki = decodeURIComponent( match[ 1 ] );

return new Import( title, wiki, null, target, disabled );

}

return new Import( null, null, url, target, disabled );

};

Import.fromJs = function ( line, target ) {

const IMPORT_RGX = /^\s*(\/\/)?\s*importScript\s*\(\s*(?:"|')(.+?)(?:"|')\s*\)/;

let match = IMPORT_RGX.exec( line );

if ( match ) {

return Import.ofLocal( unescapeForJsString( match[ 2 ] ), target, !!match[ 1 ] );

}

const LOADER_RGX = /^\s*(\/\/)?\s*mw\.loader\.load\s*\(\s*(?:"|')(.+?)(?:"|')\s*\)/;

match = LOADER_RGX.exec( line );

if ( match ) {

return Import.ofUrl( unescapeForJsString( match[ 2 ] ), target, !!match[ 1 ] );

}

};

Import.prototype.getDescription = function ( useWikitext ) {

switch ( this.type ) {

case 0: return useWikitext ? ( '' + this.page + '' ) : this.page;

case 1: return STRINGS.remoteUrlDesc.replace( '$1', this.page ).replace( '$2', this.wiki );

case 2: return this.url;

}

};

/**

* Human-readable (NOT necessarily suitable for ResourceLoader) URL.

*/

Import.prototype.getHumanUrl = function () {

switch ( this.type ) {

case 0: return '/wiki/' + encodeURI( this.page );

case 1: return '//' + this.wiki + '.org/wiki/' + encodeURI( this.page );

case 2: return this.url;

}

};

Import.prototype.toJs = function () {

const dis = this.disabled ? '//' : '';

let url = this.url;

switch ( this.type ) {

case 0: return dis + "importScript('" + escapeForJsString( this.page ) + "'); // " + STRINGS.backlink + ' ' + escapeForJsComment( this.page ) + '';

case 1: url = '//' + encodeURIComponent( this.wiki ) + '.org/w/index.php?title=' +

encodeURIComponent( this.page ) + '&action=raw&ctype=text/javascript';

/* FALL THROUGH */

case 2: return dis + "mw.loader.load('" + escapeForJsString( url ) + "');";

}

};

/**

* Installs the import.

*/

Import.prototype.install = function () {

return api.postWithEditToken( {

action: 'edit',

title: getFullTarget( this.target ),

summary: STRINGS.installSummary.replace( '$1', this.getDescription( /* useWikitext */ true ) ) + ADVERT,

appendtext: '\n' + this.toJs()

} );

};

/**

* Get all line numbers from the target page that mention

* the specified script.

*/

Import.prototype.getLineNums = function ( targetWikitext ) {

function quoted( s ) {

return new RegExp( "(['\"])" + escapeForRegex( s ) + '\\1' );

}

let toFind;

switch ( this.type ) {

case 0:

toFind = quoted( escapeForJsString( this.page ) );

break;

case 1:

toFind = new RegExp( escapeForRegex( encodeURIComponent( this.wiki ) ) + '.*?' +

escapeForRegex( encodeURIComponent( this.page ) ) );

break;

case 2:

toFind = quoted( escapeForJsString( this.url ) );

break;

}

const lineNums = [], lines = targetWikitext.split( '\n' );

for ( let i = 0; i < lines.length; i++ ) {

if ( toFind.test( lines[ i ] ) ) {

lineNums.push( i );

}

}

return lineNums;

};

/**

* Uninstalls the given import. That is, delete all lines from the

* target page that import the specified script.

*/

Import.prototype.uninstall = function () {

const that = this;

return getWikitext( getFullTarget( this.target ) ).then( ( wikitext ) => {

const lineNums = that.getLineNums( wikitext ),

newWikitext = wikitext.split( '\n' ).filter( ( _, idx ) => lineNums.indexOf( idx ) < 0 ).join( '\n' );

return api.postWithEditToken( {

action: 'edit',

title: getFullTarget( that.target ),

summary: STRINGS.uninstallSummary.replace( '$1', that.getDescription( /* useWikitext */ true ) ) + ADVERT,

text: newWikitext

} );

} );

};

/**

* Sets whether the given import is disabled, based on the provided

* boolean value.

*/

Import.prototype.setDisabled = function ( disabled ) {

const that = this;

this.disabled = disabled;

return getWikitext( getFullTarget( this.target ) ).then( ( wikitext ) => {

const lineNums = that.getLineNums( wikitext ),

newWikitextLines = wikitext.split( '\n' );

if ( disabled ) {

lineNums.forEach( ( lineNum ) => {

if ( newWikitextLines[ lineNum ].trim().indexOf( '//' ) !== 0 ) {

newWikitextLines[ lineNum ] = '//' + newWikitextLines[ lineNum ].trim();

}

} );

} else {

lineNums.forEach( ( lineNum ) => {

if ( newWikitextLines[ lineNum ].trim().indexOf( '//' ) === 0 ) {

newWikitextLines[ lineNum ] = newWikitextLines[ lineNum ].replace( /^\s*\/\/\s*/, '' );

}

} );

}

const summary = ( disabled ? STRINGS.disableSummary : STRINGS.enableSummary )

.replace( '$1', that.getDescription( /* useWikitext */ true ) ) + ADVERT;

return api.postWithEditToken( {

action: 'edit',

title: getFullTarget( that.target ),

summary: summary,

text: newWikitextLines.join( '\n' )

} );

} );

};

Import.prototype.toggleDisabled = function () {

this.disabled = !this.disabled;

return this.setDisabled( this.disabled );

};

/**

* Move this import to another file.

*/

Import.prototype.move = function ( newTarget ) {

if ( this.target === newTarget ) {

return;

}

const old = new Import( this.page, this.wiki, this.url, this.target, this.disabled );

this.target = newTarget;

return $.when( old.uninstall(), this.install() );

};

function getAllTargetWikitexts() {

return $.getJSON(

mw.util.wikiScript( 'api' ),

{

format: 'json',

action: 'query',

prop: 'revisions',

rvprop: 'content',

rvslots: 'main',

titles: SKINS.map( getFullTarget ).join( '|' )

}

).then( ( data ) => {

if ( data && data.query && data.query.pages ) {

const result = {};

Object.values( data.query.pages ).forEach( ( moreData ) => {

const nameWithoutExtension = new mw.Title( moreData.title ).getNameText();

const targetName = nameWithoutExtension.substring( nameWithoutExtension.indexOf( '/' ) + 1 );

result[ targetName ] = moreData.revisions ? moreData.revisions[ 0 ].slots.main[ '*' ] : null;

} );

return result;

}

} );

}

function buildImportList() {

return getAllTargetWikitexts().then( ( wikitexts ) => {

Object.keys( wikitexts ).forEach( ( targetName ) => {

const targetImports = [];

if ( wikitexts[ targetName ] ) {

const lines = wikitexts[ targetName ].split( '\n' );

let currImport;

for ( let i = 0; i < lines.length; i++ ) {

currImport = Import.fromJs( lines[ i ], targetName );

if ( currImport ) {

targetImports.push( currImport );

scriptCount++;

if ( currImport.type === 0 ) {

if ( !localScriptsByName[ currImport.page ] ) {

localScriptsByName[ currImport.page ] = [];

}

localScriptsByName[ currImport.page ].push( currImport.target );

}

}

}

}

imports[ targetName ] = targetImports;

} );

} );

}

/**

* "Normalizes" (standardizes the format of) lines in the given

* config page.

*/

function normalize( target ) {

return getWikitext( getFullTarget( target ) ).then( ( wikitext ) => {

const lines = wikitext.split( '\n' ),

newLines = Array( lines.length );

let currImport;

for ( let i = 0; i < lines.length; i++ ) {

currImport = Import.fromJs( lines[ i ], target );

if ( currImport ) {

newLines[ i ] = currImport.toJs();

} else {

newLines[ i ] = lines[ i ];

}

}

return api.postWithEditToken( {

action: 'edit',

title: getFullTarget( target ),

summary: STRINGS.normalizeSummary + ADVERT,

text: newLines.join( '\n' )

} );

} );

}

function conditionalReload( openPanel ) {

if ( window.scriptInstallerAutoReload ) {

if ( openPanel ) {

document.cookie = 'open_script_installer=yes';

}

window.location.reload( true );

}

}

/********************************************

*

* UI code

*

********************************************/

function makePanel() {

const $list = $( '

' ).attr( 'id', 'script-installer-panel' )

.append( $( '

' ).text( STRINGS.panelHeader ) );

const $container = $( '

' ).addClass( 'container' ).appendTo( $list );

// Container for checkboxes

$container.append( $( '

' )

.attr( 'class', 'checkbox-container' )

.append(

$( '' )

.attr( { id: 'siNormalize', type: 'checkbox' } )

.on( 'click', () => {

$( '.normalize-wrapper' ).toggle( 0 );

} ),

$( '

.attr( 'for', 'siNormalize' )

.text( STRINGS.showNormalizeLinks ),

$( '' )

.attr( { id: 'siMove', type: 'checkbox' } )

.on( 'click', () => {

$( '.move-wrapper' ).toggle( 0 );

} ),

$( '

.attr( 'for', 'siMove' )

.text( STRINGS.showMoveLinks ) ) );

if ( scriptCount > NUM_SCRIPTS_FOR_SEARCH ) {

$container.append( $( '

' )

.attr( 'class', 'filter-container' )

.append(

$( '

.attr( 'for', 'siQuickFilter' )

.text( STRINGS.quickFilter ),

$( '' )

.attr( { id: 'siQuickFilter', type: 'text' } )

.on( 'input', function () {

const filterString = $( this ).val();

if ( filterString ) {

const sel = "#script-installer-panel li[name*='" +

$.escapeSelector( $( this ).val() ) + "']";

$( '#script-installer-panel li.script' ).toggle( false );

$( sel ).toggle( true );

} else {

$( '#script-installer-panel li.script' ).toggle( true );

}

} )

) );

// Now, get the checkboxes out of the way

$container.find( '.checkbox-container' )

.css( 'float', 'right' );

}

$.each( imports, ( targetName, targetImports ) => {

const fmtTargetName = ( targetName === 'common' ?

STRINGS.skinCommon :

targetName );

if ( targetImports.length ) {

$container.append(

$( '

' ).append(

fmtTargetName,

$( '' )

.addClass( 'normalize-wrapper' )

.append(

' (',

$( '' )

.text( STRINGS.normalize )

.on( 'click', () => {

normalize( targetName ).done( () => {

conditionalReload( true );

} );

} ),

')' )

.hide() ),

$( '