Thành viên:Mifield/common/x-script-installer/main.js

Bách khoa toàn thư mở Wikipedia

Chú ý: Sau khi lưu thay đổi trang, bạn phải xóa bộ nhớ đệm của trình duyệt để nhìn thấy các thay đổi. Google Chrome, Firefox, Internet ExplorerSafari: Giữ phím ⇧ Shift và nhấn nút Reload/Tải lại trên thanh công cụ của trình duyệt. Để biết chi tiết và hướng dẫn cho các trình duyệt khác, xem Trợ giúp:Xóa bộ nhớ đệm.

// "use strict";

mw.loader.getScript( mw.libs.xsi.utils.script.path( "./lib.js" ) ).then( () => {

alert( "main.js loaded!" )

	// An mw.Api object
	let api;
	// Keep "common" at beginning
	const SKIN = [ "common", "monobook", "minerva", "vector", "vector-2022", "timeless" ];

	// How many scripts do we need before we show the quick filter?
	let NUM_SCRIPTS_FOR_SEARCH = 5;

	// The master import list, keyed by target. (A "target" is a user J subpage
	// where the script is imported, like "common" or "vector".) Set in buildImportList
	let scripts = {};

	// Local scripts, keyed on name; value will be the target. Set in buildImportList.
	let localScriptsByName = {};

	// How many scripts are installed?
	let scriptCount = 0;

	// Goes on the end of edit summaries
	const EDIT_SUMMARY_POSTFIX = " ([[User:Mifield/common/script-installer|script-installer (forked)]])";

	// const EMPTY_STR = "";

	const	xsi =    mw.libs.xsi,
			CHAR =   xsi.CHAR,
			PREFAB = xsi.PREFAB,
			REGEX =  xsi.REGEX,
			STR =    xsi.STR,
			TAG =    xsi.TAG,
			TEXT =   xsi.TEXT;
	
	const UNSPECIFIED = Symbol.for( "function parameter is not specified" );
	
	/* const	traverseObject =    xsi.utils.traverseObject,
			objectFromKeyMaps = xsi.utils.objectFromKeyMaps,
			to =  traverseObject,
			okm = objectFromKeyMaps; */

	const USERSPACE_LOCAL_NAME = mw.config.get( "wgFormattedNamespaces" )[2];

	/**
	 * Constructs an Import. An Import is a line in a J file that will import a
	 * user script. Properties:
	 *
	 *  - "page" is a page name, such as "User:Foo/Bar.js".
	 *  - "wiki" is a wiki from which the script is loaded, such as
	 *  "en.wikipedia". If null, the script is local, on the user's
	 *  wiki.
	 *  - "url" is a URL that can be passed into mw.loader.load.
	 *  - "target" is the title of the user subpage where the script is,
	 *  without the .js ending: for example, "common".
	 *  - "xsi-stopped" is whether this import is commented out.
	 *  - "type" is 0 if local, 1 if remotely loaded, and 2 if URL.
	 *
	 * 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.
	 */

	// class Import {}

	function Import( page, wiki, url, target, stopped ) {
		this.page = page;
		this.wiki = wiki;
		this.url = url;
		this.target = target;
		this.stopped = stopped;
		this.type = this.url ? 2 : ( this.wiki ? 1 : 0 );
		// alert(this.type + " " + this.page + " " + this.wiki + " " + this.url )
	}

	Import.ofLocal = ( page, target, stopped ) => {
		if ( stopped === undefined ) stopped = false;
		return new Import( page, null, null, target, stopped );
	};

	/** URL to Import. Assumes wgScriptPath is "/w" */
	Import.ofUrl = ( url, target, stopped ) => {
		if ( stopped === undefined ) stopped = false;
		let match = REGEX.url.exec( url );
		if ( match ) {
			let title = decodeURIComponent( match[2].replace( /&$/, STR.empty ) ),
				wiki = decodeURIComponent( match[1] );
			return new Import( title, wiki, null, target, stopped );
		}
		return new Import( null, null, url, target, stopped );
	};

	Import.fromJs = ( line, target ) => {
		let match = REGEX.import.exec( line );
		if ( match ) {
			return Import.ofLocal( unescapeForJsString( match[2] ), target, !!match[1] );
		}

		match = REGEX.loader.exec( line );
		if ( match ) {
			return Import.ofUrl( unescapeForJsString( match[2] ), target, !!match[1] );
		}
	};

	Import.prototype.getDescription = ( useWikitext ) => {
		switch( this.type ) {
			case 0: return useWikitext ? ( STR.wrap.brack( this.page, 2 ) ) : this.page;
			case 1: return TEXT.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 = () => {
		switch( this.type ) {
			case 0: return "/wiki/" + encodeURI( this.page );
			case 1: return STR.slashes( 2 ) + this.wiki + ".org/wiki/" + encodeURI( this.page );
			case 2: return this.url;
		}
	};

	Import.prototype.toJs = () => {
		let dis = this.stopped ? STR.slashes( 2 ) : STR.empty,
			url = this.url;
		switch( this.type ) {
			case 0: return dis + "importScript('" + escapeForJsString( this.page ) + "'); // Backlink: [[" + escapeForJsComment( this.page ) + "]]";
			case 1: url = STR.slashes( 2 ) + 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 ) + "');";
		}
	};

	/**
	 * Import the script.
	 */
	Import.prototype.import = () => {
		return api.postWithEditToken( {
			action: "edit",
			title: getFullTarget( this.target ),
			summary: TEXT.action.import.summary.replace( "$1", this.getDescription( /* useWikitext */ true ) ) + EDIT_SUMMARY_POSTFIX,
			appendtext: CHAR.NL + this.toJs()
		} );
	};

	/**
	 * Get all line numbers from the target page that mention
	 * the specified script.
	 */
	Import.prototype.getLineNums = ( targetWikitext ) => {
		function quoted( string ) {
			return new RegExp( `(['"])${ escapeForRegex( string ) }\\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;
		}
		let lineNums = [], lines = targetWikitext.split( CHAR.NL );
		for ( let i = 0; i < lines.length; i++ ) {
			if ( toFind.test( lines[i] ) ) {
				lineNums.push( i );
			}
		}
		return lineNums;
	};

	/**
	 * "forget"s the given import. That is, delete all lines from the
	 * target page that import the specified script.
	 */
	Import.prototype.forget = () => {
		let that = this;
		return getWikitext( getFullTarget( this.target ) ).then( ( wikitext ) => {
			let lineNums = that.getLineNums( wikitext ),
				newWikitext = wikitext.split( CHAR.NL ).filter( ( _, idx ) => {
					return lineNums.indexOf( idx ) < 0;
				} ).join( CHAR.NL );
			return api.postWithEditToken( {
				action: "edit",
				title: getFullTarget( that.target ),
				summary: TEXT.action.forget.summary.replace( "$1", that.getDescription( /* useWikitext */ true ) ) + EDIT_SUMMARY_POSTFIX,
				text: newWikitext
			} );
		} );
	};

	/**
	 * Sets whether the given import is stopped, based on the provided
	 * boolean value.
	 */
	Import.prototype.setStopped = ( stopped ) => {
		let that = this;
		this.stopped = stopped;
		return getWikitext( getFullTarget( this.target ) ).then( ( wikitext ) => {
			let lineNums = that.getLineNums( wikitext ),
				newWikitextLines = wikitext.split( CHAR.NL );

			if ( stopped ) {
				lineNums.forEach( ( lineNum ) => {
					if ( newWikitextLines[lineNum].trim().indexOf( STR.slashes( 2 ) ) != 0 ) {
						newWikitextLines[lineNum] = STR.slashes( 2 ) + newWikitextLines[lineNum].trim();
					}
				} );
			} else {
				lineNums.forEach( ( lineNum ) => {
					if ( newWikitextLines[lineNum].trim().indexOf( STR.slashes( 2 ) ) == 0 ) {
						newWikitextLines[lineNum] = newWikitextLines[lineNum].replace( /^\s*\/\/\s*/, STR.empty );
					}
				} );
			}

			const summary = ( stopped ? TEXT.action.stop.summary : TEXT.action.start.summary )
					.replace( "$1", that.getDescription( /* useWikitext */ true ) ) + EDIT_SUMMARY_POSTFIX;
			return api.postWithEditToken( {
				action: "edit",
				title: getFullTarget( that.target ),
				summary: summary,
				text: newWikitextLines.join( CHAR.NL )
			} );
		} );
	};

	Import.prototype.toggleStopped = () => {
		this.stopped = !this.stopped;
		return this.setStopped( this.stopped );
	};

	/**
	 * Move this import to another file.
	 */
	Import.prototype.move = ( newTarget ) => {
		if ( this.target === newTarget ) return;
		const old = new Import( this.page, this.wiki, this.url, this.target, this.stopped );
		this.target = newTarget;
		return $.when( old.forget(), this.import() );
	};

	function getAllTargetWikitexts() {
		return $.getJSON(
			mw.util.wikiScript( "api" ),
			{
				format: "json",
				action: "query",
				prop: "revisions",
				rvprop: "content",
				rvslots: "main",
				titles: SKIN.map( getFullTarget ).join( CHAR.pipe )
			}
		).then( ( data ) => {
			if ( data && data.query && data.query.pages ) {
				let result = {};
					prefixLength = mw.config.get( "wgUserName" ).length + 6;
				Object.values( data.query.pages ).forEach( ( moreData ) => {
					const nameWithoutExtension = new mw.Title( moreData.title ).getNameText();
					const targetName = nameWithoutExtension.substring( nameWithoutExtension.indexOf( CHAR.slash ) + 1 );
					result[targetName] = moreData.revisions ? moreData.revisions[0].slots.main[CHAR.asterisk] : null;
				} );
				return result;
			}
		} );
	}

	function buildImportList() {
		return getAllTargetWikitexts().then( ( wikitexts ) => {
			Object.keys( wikitexts ).forEach( ( targetName ) => {
				let targetImports = [];
				if ( wikitexts[ targetName ] ) {
					let lines = wikitexts[ targetName ].split( CHAR.NL );
					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 );
							}
						}
					}
				}
				scripts[ targetName ] = targetImports;
			} );
		} );
	}


	/*
	 * "Normalizes" (standardizes the format of) lines in the given
	 * config page.
	 */
	function normalize( target ) {
		return getWikitext( getFullTarget( target ) ).then( ( wikitext ) => {
			let lines = wikitext.split( CHAR.NL ),
				newLines = Array( lines.length ),
				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: TEXT.action.normalize.summary,
				text: newLines.join( CHAR.NL )
			} );
		} );
	}

	function conditionalReload( openPanel ) {
		if ( window.xScriptInstallerAutoReload ) {
			if ( openPanel ) document.cookie = "open_script_installer=yes";
			window.location.reload( true );
		}
	}

	/********************************************
	 *
	 * UI code
	 *
	 ********************************************/
	function makePanel() {
		let list = $( TAG.div ).attr( "id", "xsi-panel" )
			.append( $( TAG.header ).text( TEXT.panelHeader ) );
		let container = $( TAG.div ).addClass( "xsi-container" ).appendTo( list );

		// Container for checkboxes
		container.append( $( TAG.div )
			.attr( "class", "xsi-checkbox-container" )
			.append(
				$( TAG.input )
					.attr( { id: "xsi-toggle-normalize", type: "checkbox" } )
					.click( () => {
						$( ".xsi-toggle-normalize-wrapper" ).toggle( 0 );
					} ),
				$( TAG.label )
					.attr( "for", "xsi-toggle-normalize" )
					.text( TEXT.action.toggle.normalize ),
				$( TAG.input )
					.attr( { id: "xsi-toggle-move", type: "checkbox" } )
					.click( () => {
						$( ".xsi-toggle-move-wrapper" ).toggle( 0 );
					} ),
				$( TAG.label )
					.attr( "for", "xsi-toggle-move" )
					.text( TEXT.action.toggle.move ) ) );
		if ( scriptCount > NUM_SCRIPTS_FOR_SEARCH ) {
			container.append( $( TAG.div )
				.attr( "class", "xsi-filter-container" )
				.append(
					$( TAG.label )
						.attr( "for", "xsi-toggle-filter" )
						.text( TEXT.quickFilter ),
					$( TAG.input )
						.attr( { id: "xsi-toggle-filter", type: "text" } )
						.on( "input", () => {
							let filterString = $( this ).val();
							if ( filterString ) {
								let selector = "#xsi-panel li[name*='" +
										$.escapeSelector( $( this ).val() ) + "']";
								$( "#xsi-panel li.script" ).toggle( false );
								$( selector ).toggle( true );
							} else {
								$( "#xsi-panel li.script" ).toggle( true );
							}
						} )
				) );

			// Now, get the checkboxes out of the way
			container.find( ".xsi-checkbox-container" )
				.css( "float", "right" );
		}
		$.each( scripts, ( targetName, targetImports ) => {
			let fmtTargetName = ( targetName === "common"
				? "common (applies to all skins)"
				: targetName );
				if ( targetImports.length ) {
				container.append(
					$( TAG.h2 ).append(
						fmtTargetName,
						$( TAG.span )
						.addClass( "xsi-toggle-normalize-wrapper" )
						.append(
							" (",
							$( TAG.a )
								.text( "normalize" )
								.click( () => {
									normalize( targetName ).done( () => {
										conditionalReload( true );
									} );
								 } ),
							")" )
							.hide() ),
						$( TAG.ul ).append(
							targetImports.map( ( anImport ) => {
								return $( TAG.li )
									.addClass( "xsi-script" )
									.attr( "name", anImport.getDescription() )
									.append(
										$( TAG.a )
											.text( anImport.getDescription() )
											.addClass( "xsi-script" )
											.attr( "href", anImport.getHumanUrl() ),
										" (",
										$( TAG.a )
											.text( TEXT.action.forget.verb )
											.click( () => {
												$( this ).text( TEXT.action.forget.acting );
												anImport.forget().done( () => {
													conditionalReload( true );
												} );
											} ),
										PREFAB.pipe(),
										$( TAG.a )
											.text( anImport.stopped ? TEXT.action.start.verb : TEXT.action.stop.verb )
											.click( () => {
												$( this ).text( anImport.stopped ? TEXT.action.start.acting : TEXT.action.stop.acting );
												anImport.toggleStopped().done( () => {
													$( this ).toggleClass( "xsi-stopped" );
													conditionalReload( true );
												} );
											} ),
										$( TAG.span )
											.addClass( "xsi-toggle-move-wrapper" )
											.append(
											PREFAB.pipe(),
											$( TAG.a )
												.text( TEXT.action.move.verb )
												.click( () => {
													let dest = null;
													let PROMPT = TEXT.prompt.move + CHAR.space + SKIN.join( ", " );
													do {
														dest = ( window.prompt( PROMPT ) || STR.empty ).toLowerCase();
													} while ( dest && SKIN.indexOf( dest ) < 0 );
													if ( !dest ) return;
													$( this ).text( TEXT.action.move.acting );
													anImport.move( dest ).done( () => {
														conditionalReload( true );
													} );
												} )
											)
											.hide(),
										")" )
								.toggleClass( "xsi-stopped", anImport.stopped );
								} ) ) );
				}
		} );
		return list;
	}

	function buildCurrentPageInstallElement() {
		let addingInstallLink = false; // will we be adding a legitimate install link?
		let importElement = $( TAG.span ); // only used if addingInstallLink is set to true

		let namespaceNumber = mw.config.get( "wgNamespaceNumber" );
		let pageName = mw.config.get( "wgPageName" );

		// Namespace 2 is User
		if ( namespaceNumber === 2 &&
				pageName.indexOf( CHAR.slash ) > 0 ) {
			let contentModel = mw.config.get( "wgPageContentModel" );
			if ( contentModel === "javascript" ) {
				let prefixLength = mw.config.get( "wgUserName" ).length + 6;
				if ( pageName.indexOf( USERSPACE_LOCAL_NAME + CHAR.colon + mw.config.get( "wgUserName" ) ) === 0 ) {
					let skinIndex = SKIN.indexOf( pageName.substring( prefixLength ).slice( 0, -3 ) );
					if ( skinIndex >= 0 ) {
						return $( TAG.abbr ).text( TEXT.error.import.base)
								.attr( "title", TEXT.error.import.builtin );
					}
				}
				addingInstallLink = true;
			} else {
				return $( TAG.abbr ).text( TEXT.error.import.base + " (" + TEXT.error.import.notJavaScript + ")" )
						.attr( "title", TEXT.error.import.contentModel.replace( "$1", contentModel ) );
			}
		}

		// Namespace 8 is MediaWiki
		if ( namespaceNumber === 8 ) {
			return $( TAG.a ).text( TEXT.action.controlInPrefs.verb )
					.attr( "href", mw.util.getUrl( "Special:Preferences" ) + "#mw-prefsection-gadgets" );
		}

		let editRestriction = mw.config.get( "wgRestrictionEdit" ) || [];
		if ( ( namespaceNumber !== 2 && namespaceNumber !== 8 ) &&
			( editRestriction.indexOf( "sysop" ) >= 0 ||
				editRestriction.indexOf( "editprotected" ) >= 0 ) ) {
			importElement.append( CHAR.space,
				$( TAG.abbr ).append(
					$( TAG.img ).attr( "src", "https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Achtung-yellow.svg/20px-Achtung-yellow.svg.png" ).addClass( "warning" ),
					TEXT.warning.insecure )
				.attr( "title", TEXT.warning.namespace ) );
			addingInstallLink = true;
		}

		if ( addingInstallLink ) {
			let fixedPageName = mw.config.get( "wgPageName" ).replace( /_/g, CHAR.space );
			importElement.prepend( $( TAG.a )
					.attr( "id", "script-installer-main-install" )
					.text( localScriptsByName[ fixedPageName ] ? TEXT.action.forget.verb : TEXT.action.import.verb )
					.click( makeLocalInstallClickHandler( fixedPageName ) ) );

			// If the script is installed but stopped, allow the user to enable it
			let allScriptsInTarget = scripts[ localScriptsByName[ fixedPageName ] ];
			let importObj = allScriptsInTarget && allScriptsInTarget.find( ( anImport ) => { return anImport.page === fixedPageName; } );
			if ( importObj && importObj.stopped ) {
				importElement.append( PREFAB.pipe(),
					$( TAG.a )
						.attr( "id", "script-installer-main-enable" )
						.text( TEXT.action.start.verb )
						.click( () => {
							$( this ).text( TEXT.action.start.acting );
							importObj.setStopped( false ).done( () => {
								conditionalReload( false );
							} );
						} ) );
			}
			return importElement;
		}

		return $( TAG.abbr ).text( TEXT.error.import.base + CHAR.space + TEXT.warning.insecure )
				.attr( "title", TEXT.error.namespace );
	}

	function showUi() {
		let fixedPageName = mw.config.get( "wgPageName" ).replace( /_/g, CHAR.space );
		$( "#firstHeading" ).append( $( TAG.span )
			.attr( "id", "script-installer-top-container" )
			.append(
				buildCurrentPageInstallElement(),
				PREFAB.pipe(),
				$( TAG.a )
					.text( TEXT.action.manage.verb ).click( () => {
						if ( !document.getElementById( "xsi-panel" ) ) {
							$( "#mw-content-text" ).before( makePanel() );
						} else {
							$( "#xsi-panel" ).remove();
						}
					 } ) ) );
	}

	function attachInstallLinks() {
		// At the end of each {{Userscript}} transclusion, there is
		// <span id='User:Foo/Bar.js' class='scriptInstallerLink'></span>
		$( "span.scriptInstallerLink" ).each( () => {
			let scriptName = this.id;
			$( this ).append( PREFAB.pipe(), $( TAG.a )
					.text( localScriptsByName[ scriptName ] ? TEXT.action.forget.verb : TEXT.action.import.verb )
					.click( makeLocalInstallClickHandler( scriptName ) ) );
		} );

		$( "table.infobox-user-script" ).each( () => {
			let scriptName = $( this ).find( "th:contains('Source')" ).next().text() ||
					mw.config.get( "wgPageName" );
			scriptName = /user:.+?\/.+?.js/i.exec( scriptName )[0];
			$( this ).children( "tbody" ).append( $( TAG.tr ).append( $( TAG.td )
					.attr( "colspan", "2" )
					.addClass( "script-installer-ibx" )
					.append( $( TAG.button )
						.addClass( "mw-ui-button mw-ui-progressive mw-ui-big" )
						.text( localScriptsByName[ scriptName ] ? TEXT.action.forget.verb : TEXT.action.import.verb )
						.click( makeLocalInstallClickHandler( scriptName ) ) ) ) );
		} );
	}

	function makeLocalInstallClickHandler( scriptName ) {
		return () => {
			let $this = $( this );
			if ( $this.text() === TEXT.action.import.verb ) {
				let warning = TEXT.warning.risk;
				if ( scriptName.indexOf( CHAR.slash ) >= 0 ) {
					warning = warning.replace( '$1', TEXT.warning.trustAuthor.replace( '$1', scriptName.substring( 0, scriptName.indexOf( CHAR.slash ) ) ) );
				} else {
					warning = warning.replace( '$1', '' );
				}
				let okay = window.xScriptInstallerSuppressWarnings || window.confirm( warning );
				if ( okay ) {
					$( this ).text( TEXT.action.import.acting );
					Import.ofLocal( scriptName, window.xScriptInstallerInstallTarget ).import().done( ( () => {
						$( this ).text( TEXT.action.forget.verb );
						conditionalReload( false );
					} ).bind( this ) );
				}
			} else {
				$( this ).text( TEXT.action.forget.acting );
				let uninstalls = xsi.utils.uniques( localScriptsByName[ scriptName ] )
						.map( ( target ) => { return Import.ofLocal( scriptName, target ).forget(); } );
				$.when.apply( $, uninstalls ).then( ( () => {
					$( this ).text( TEXT.action.import.verb );
					conditionalReload( false );
				} ).bind( this ) );
			}
		 };
	}

	/********************************************
	 *
	 * Utility functions
	 *
	 ********************************************/

	/**
	 * Gets the wikitext of a page with the given title (namespace required).
	 */
	function getWikitext( title ) {
		return $.getJSON(
			mw.util.wikiScript( "api" ),
			{
				format: "json",
				action: "query",
				prop: "revisions",
				rvprop: "content",
				rvslots: "main",
				rvlimit: 1,
				titles: title
			}
		).then( ( data ) => {
			let pageId = Object.keys( data.query.pages )[0];
			if ( data.query.pages[pageId].revisions ) {
				return data.query.pages[pageId].revisions[0].slots.main[CHAR.asterisk];
			}
			return STR.empty;
		} );
	}

	function escapeForRegex( s ) {
		return s.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
	}

	/**
	* Escape a string for use in a JavaScript string literal.
	* This function is adapted from
	* https://github.com/joliss/js-string-escape/blob/6887a69003555edf5c6caaa75f2592228558c595/index.js
	* (released under the MIT licence).
	*/
	function escapeForJsString( s ) {
		return s.replace( /["'\\\n\r\u2028\u2029]/g, ( character ) => {
			// Escape all characters not included in SingleStringCharacters and
			// DoubleStringCharacters on
			// http://www.ecma-international.org/ecma-262/5.1/#sec-7.8.4
			switch ( character ) {
				case CHAR.quote.double:
				case CHAR.quote.single:
				case CHAR.backslash:
					return CHAR.backslash + character;
				// Four possible LineTerminator characters need to be escaped:
				case CHAR.NL: return CHAR.ESCAPED.NL;
				case CHAR.CR: return CHAR.ESCAPED.CR;
				case CHAR.LS: return CHAR.ESCAPED.LS;
				case CHAR.PS: return CHAR.ESCAPED.PS;
			}
		} );
	}

	/**
	* Escape a string for use in an inline JavaScript comment (comments that
	* start with two slashes "//").
	* This function is adapted from
	* https://github.com/joliss/js-string-escape/blob/6887a69003555edf5c6caaa75f2592228558c595/index.js
	* (released under the MIT licence).
	*/
	function escapeForJsComment( s ) {
		return s.replace( /[\n\r\u2028\u2029]/g, ( character ) => {
			switch ( character ) {
				// Escape possible LineTerminator characters
				case CHAR.NL: return CHAR.ESCAPED.NL;
				case CHAR.CR: return CHAR.ESCAPED.CR;
				case CHAR.LS: return CHAR.ESCAPED.LS;
				case CHAR.PS: return CHAR.ESCAPED.PS;
			}
		} );
	}

	/**
	* Unescape a JavaScript string literal.
	*
	* This is the inverse of escapeForJsString.
	*/
	function unescapeForJsString( string ) {
		return string.replace( /\\"|\\'|\\\\|\\n|\\r|\\u2028|\\u2029/g, ( substring ) => {
			switch ( substring ) {
				case CHAR.ESCAPED.quote.double:    return CHAR.quote.double;
				case CHAR.ESCAPED.quote.single:    return CHAR.quote.single;
				case CHAR.ESCAPED.backslash:       return CHAR.backslash;
				case CHAR.ESCAPED.CR:              return CHAR.CR;
				case CHAR.ESCAPED.NL:              return CHAR.NL;
				case CHAR.ESCAPED.LS:              return CHAR.LS;
				case CHAR.ESCAPED.PS:              return CHAR.PS;
			}
		} );
	}

	function getFullTarget ( target ) {
		return STR.join(
			USERSPACE_LOCAL_NAME,
			CHAR.colon,
			mw.config.get( "wgUserName" ),
			CHAR.slash,
			target,
			".js"
		);
	}

	// From https://stackoverflow.com/a/10192255
	function uniques( array ){
		return array.filter( function( el, index, arr ) {
			return index === arr.indexOf( el );
		});
	}

	if ( window.xScriptInstallerAutoReload === undefined ) {
		window.xScriptInstallerAutoReload = true;
	}

	if ( window.xScriptInstallerInstallTarget === undefined ) {
		window.xScriptInstallerInstallTarget = "common"; // by default, install things to the user's common.js
	}

	let jsPage = mw.config.get( "wgPageName" ).slice( -3 ) === ".js" ||
		mw.config.get( "wgPageContentModel" ) === "javascript";
	$.when(
		$.ready,
		mw.loader.using( [ "mediawiki.api", "mediawiki.util" ] )
	).then( () => {
		api = new mw.Api();
		importStylesheet("User:Mifield/common/script-installer/style.css");
		buildImportList().then( () => {
			attachInstallLinks();
			if ( jsPage ) showUi();

			// Auto-open the panel if we set the cookie to do so (see `conditionalReload()`)
			if ( document.cookie.indexOf( "open_script_installer=yes" ) >= 0 ) {
				document.cookie = "open_script_installer=; expires=Thu, 01 Jan 1970 00:00:01 GMT";
				$( "#script-installer-top-container a:contains('Manage')" ).trigger( "click" );
			}
		} );
	} );
}, ( error ) => { alert( error ); } );