User:Enterprisey/script-installer-core.js
Jump to navigation
Jump to search
Note: After saving, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
- Opera: Go to Menu → Settings (Opera → Preferences on a Mac) and then to Privacy & security → Clear browsing data → Cached images and files.
( function () { // An mw.Api object var api; // Keep "common" at beginning var SKINS = [ "common", "monobook", "minerva", "vector", "cologneblue", "timeless" ]; // How many scripts do we need before we show the quick filter? var NUM_SCRIPTS_FOR_SEARCH = 5; // The master import list. Set in buildImportList var imports = {}; // Local scripts, keyed on name; value will be the target var localScriptsByName = {}; // How many scripts are installed? var scriptCount = 0; // Goes on the end of edit summaries var ADVERT = " ([[User:Enterprisey/script-installer|script-installer]])"; /** * Strings, for translation */ var STRINGS = { installSummary: "Installing $1", uninstallSummary: "Uninstalling $1", remoteUrlDesc: "$1, loaded from $2", disableSummary: "Disabling $1", enableSummary: "Enabling $1", normalizeSummary: "Normalizing script installs", panelHeader: "You currently have the following scripts installed" }; /** * Constructs an Import. An Import is a line in a JS file that imports 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". * - "disabled" 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. */ 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; var URL_RGX = /^(?:https?:)?\/\/(.+?)\.org\/w\/index\.php\?.*?title=(.+?(?:&|$))/; var match; if( match = URL_RGX.exec( url ) ) { var title = decodeURIComponent( match[2].replace( /&$/, "" ) ), wiki = match[1]; return new Import( title, wiki, null, target, disabled ); } return new Import( null, null, url, target, disabled ); } Import.fromJs = function ( line, target ) { var IMPORT_RGX = /^\s*(\/\/)?\s*importScript\s*\(\s*(?:"|')(.+?)(?:"|')\s*\)/; var match; if( match = IMPORT_RGX.exec( line ) ) { return Import.ofLocal( match[2], target, !!match[1] ); } var LOADER_RGX = /^\s*(\/\/)?\s*mw\.loader\.load\s*\(\s*(?:"|')(.+?)(?:"|')\s*\)/; if( match = LOADER_RGX.exec( line ) ) { return Import.ofUrl( match[2], target, !!match[1] ); } } Import.prototype.getDescription = function () { switch( this.type ) { case 0: return 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 () { var dis = this.disabled ? "//" : "", url = this.url; switch( this.type ) { case 0: return dis + "importScript('" + this.page + "'); // Backlink: [[" + this.page + "]]"; case 1: url = "//" + this.wiki + ".org/w/index.php?title=" + this.page + "&action=raw&ctype=text/javascript"; /* FALL THROUGH */ case 2: return dis + "mw.loader.load('" + url + "');"; } } /** * Installs the import. */ Import.prototype.install = function () { return api.postWithEditToken( { action: "edit", title: getFullTarget( this.target ), summary: STRINGS.installSummary.replace( "$1", this.getDescription() ) + 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" ); } var toFind; switch( this.type ) { case 0: toFind = quoted( this.page ); break; case 1: toFind = new RegExp( escapeForRegex( this.wiki ) + ".*?" + escapeForRegex( this.page ) ); break; case 2: toFind = quoted( this.url ); break; } var lineNums = [], lines = targetWikitext.split( "\n" ); for( var 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 () { var that = this; return getWikitext( getFullTarget( this.target ) ).then( function ( wikitext ) { var lineNums = that.getLineNums( wikitext ), newWikitext = wikitext.split( "\n" ).filter( function ( _, idx ) { return lineNums.indexOf( idx ) < 0; } ).join( "\n" ); return api.postWithEditToken( { action: "edit", title: getFullTarget( that.target ), summary: STRINGS.uninstallSummary.replace( "$1", that.getDescription() ) + ADVERT, text: newWikitext } ); } ); } /** * Sets whether the given import is disabled, based on the provided * boolean value. */ Import.prototype.setDisabled = function ( disabled ) { var that = this; this.disabled = disabled; return getWikitext( getFullTarget( this.target ) ).then( function ( wikitext ) { var lineNums = that.getLineNums( wikitext ), newWikitextLines = wikitext.split( "\n" ); if( disabled ) { lineNums.forEach( function ( lineNum ) { if( newWikitextLines[lineNum].trim().indexOf( "//" ) != 0 ) { newWikitextLines[lineNum] = "//" + newWikitextLines[lineNum].trim(); } } ); } else { lineNums.forEach( function ( lineNum ) { if( newWikitextLines[lineNum].trim().indexOf( "//" ) == 0 ) { newWikitextLines[lineNum] = newWikitextLines[lineNum].replace( /^\s*\/\/\s*/, "" ); } } ); } var summary = ( disabled ? STRINGS.disableSummary : STRINGS.enableSummary ) .replace( "$1", that.getDescription() ) + 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; var 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( function ( data ) { if( data && data.query && data.query.pages ) { var result = {}; prefixLength = mw.config.get( "wgUserName" ).length + 6; Object.values( data.query.pages ).forEach( function ( moreData ) { result[moreData.title.substring( prefixLength ).slice( 0, -3 )] = moreData.revisions ? moreData.revisions[0].slots.main["*"] : null; } ); return result; } } ); } function buildImportList() { return getAllTargetWikitexts().then( function ( wikitexts ) { Object.keys( wikitexts ).forEach( function ( targetName ) { var targetImports = []; if( wikitexts[ targetName ] ) { var lines = wikitexts[ targetName ].split( "\n" ); var currImport; for( var i = 0; i < lines.length; i++ ) { if( currImport = Import.fromJs( lines[i], targetName ) ) { 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( function ( wikitext ) { var lines = wikitext.split( "\n" ), newLines = Array( lines.length ), currImport; for( var i = 0; i < lines.length; i++ ) { if( currImport = Import.fromJs( lines[i], target ) ) { newLines[i] = currImport.toJs(); } else { newLines[i] = lines[i]; } } return api.postWithEditToken( { action: "edit", title: getFullTarget( target ), summary: STRINGS.normalizeSummary, 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() { var list = $( "<div>" ).attr( "id", "script-installer-panel" ) .append( $( "<header>" ).text( STRINGS.panelHeader ) ); var container = $( "<div>" ).addClass( "container" ).appendTo( list ); // Container for checkboxes container.append( $( "<div>" ) .attr( "class", "checkbox-container" ) .append( $( "<input>" ) .attr( { "id": "siNormalize", "type": "checkbox" } ) .click( function () { $( ".normalize-wrapper" ).toggle( 0 ) } ), $( "<label>" ) .attr( "for", "siNormalize" ) .text( 'Show "normalize" links?' ), $( "<input>" ) .attr( { "id": "siMove", "type": "checkbox" } ) .click( function () { $( ".move-wrapper" ).toggle( 0 ) } ), $( "<label>" ) .attr( "for", "siMove" ) .text( 'Show "move" links?' ) ) ); if( scriptCount > NUM_SCRIPTS_FOR_SEARCH ) { container.append( $( "<div>" ) .attr( "class", "filter-container" ) .append( $( "<label>" ) .attr( "for", "siQuickFilter" ) .text( "Quick filter:" ), $( "<input>" ) .attr( { "id": "siQuickFilter", "type": "text" } ) .on( "input", function () { var filterString = $( this ).val(); if( filterString ) { var 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, function ( targetName, targetImports ) { var fmtTargetName = ( targetName === "common" ? "common (applies to all skins)" : targetName ); if( targetImports.length ) { container.append( $( "<h2>" ).append( fmtTargetName, $( "<span>" ) .addClass( "normalize-wrapper" ) .append( " (", $( "<a>" ) .text( "normalize" ) .click( function () { normalize( targetName ).done( function () { conditionalReload( true ); } ); } ), ")" ) .hide() ), $( "<ul>" ).append( targetImports.map( function ( anImport ) { return $( "<li>" ) .addClass( "script" ) .attr( "name", anImport.getDescription() ) .append( $( "<a>" ) .text( anImport.getDescription() ) .addClass( "script" ) .attr( "href", anImport.getHumanUrl() ), " (", $( "<a>" ) .text( "Uninstall" ) .click( function () { $( this ).text( "Uninstalling..." ); anImport.uninstall().done( function () { conditionalReload( true ); } ); } ), " | ", $( "<a>" ) .text( anImport.disabled ? "Enable" : "Disable" ) .click( function () { $( this ).text( $( this ).text().replace( /e$/, "ing" ) ); anImport.toggleDisabled().done( function () { $( this ).toggleClass( "disabled" ); conditionalReload( true ); } ); } ), $( "<span>" ) .addClass( "move-wrapper" ) .append( " | ", $( "<a>" ) .text( "Move" ) .click( function () { var dest = null; var PROMPT = "Destination? Enter one of: " + SKINS.join( ", " ); do { dest = ( window.prompt( PROMPT ) || "" ).toLowerCase(); } while( dest && SKINS.indexOf( dest ) < 0 ) if( !dest ) return; $( this ).text( "Moving" ); anImport.move( dest ).done( function () { conditionalReload( true ); } ); } ), ) .hide(), ")" ) .toggleClass( "disabled", anImport.disabled ); } ) ) ); } } ); return list; } function buildCurrentPageInstallElement() { var addingInstallLink = false; // will we be adding a legitimate install link? var installElement = $( "<span>" ); // only used if addingInstallLink is set to true var namespaceNumber = mw.config.get( "wgNamespaceNumber" ); var pageName = mw.config.get( "wgPageName" ); // Namespace 2 is User if( namespaceNumber === 2 && pageName.indexOf( "/" ) > 0 ) { var contentModel = mw.config.get( "wgPageContentModel" ); if( contentModel === "javascript" ) { var prefixLength = mw.config.get( "wgUserName" ).length + 6; if( pageName.indexOf( "User:" + mw.config.get( "wgUserName" ) ) === 0 ) { var skinIndex = SKINS.indexOf( pageName.substring( prefixLength ).slice( 0, -3 ) ); if( skinIndex >= 0 ) { return $( "<abbr>" ).text( "Cannot install" ) .attr( "title", "This page is one of your user customization pages, and " + ( ( skinIndex === 0 ) ? "will" : "may" ) + " already run on each page load." ); } } addingInstallLink = true; } else { return $( "<abbr>" ).text( "Cannot install (not JS)" ) .attr( "title", "Page content model is " + contentModel + ", not 'javascript'" ); } } // Namespace 8 is MediaWiki if( namespaceNumber === 8 ) { return $( "<a>" ).text( "Install via preferences" ) .attr( "href", mw.util.getUrl( "Special:Preferences" ) + "#mw-prefsection-gadgets" ); } var editRestriction = mw.config.get( "wgRestrictionEdit" ); if( ( namespaceNumber !== 2 && namespaceNumber !== 8 ) && ( editRestriction.indexOf( "sysop" ) >= 0 || editRestriction.indexOf( "editprotected" ) >= 0 ) ) { installElement.append( " ", $( "<abbr>" ).append( $( "<img>" ).attr( "src", "https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Achtung-yellow.svg/20px-Achtung-yellow.svg.png" ).addClass( "warning" ), "(insecure)" ) .attr( "title", "Installation of non-User, non-MediaWiki"+ " protected pages is temporary and may be removed in the future." ) ); addingInstallLink = true; } if( addingInstallLink ) { var fixedPageName = mw.config.get( "wgPageName" ).replace( /_/g, " " ); installElement.prepend( $( "<a>" ) .attr( "id", "script-installer-main-install" ) .text( localScriptsByName[ fixedPageName ] ? "Uninstall" : "Install" ) .click( makeLocalInstallClickHandler( fixedPageName ) ) ); return installElement; } return $( "<abbr>" ).text( "Cannot install (insecure)" ) .attr( "title", "Page is not User: or MediaWiki: and is unprotected" ); } function showUi() { var fixedPageName = mw.config.get( "wgPageName" ).replace( /_/g, " " ); $( "#firstHeading" ).append( $( "<span>" ) .attr( "id", "script-installer-top-container" ) .append( buildCurrentPageInstallElement(), " | ", $( "<a>" ) .text( "Manage user scripts" ).click( function () { if( !document.getElementById( "script-installer-panel" ) ) { $( "#mw-content-text" ).before( makePanel() ); } else { $( "#script-installer-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( function () { var scriptName = this.id; $( this ).append( " | ", $( "<a>" ) .text( localScriptsByName[ scriptName ] ? "Uninstall" : "Install" ) .click( makeLocalInstallClickHandler( scriptName ) ) ); } ); $( "table.infobox-user-script" ).each( function () { var scriptName = $( this ).find( "th:contains('Source')" ).next().text() || mw.config.get( "wgPageName" ); scriptName = /user:.+?\/.+?.js/i.exec( scriptName )[0]; $( this ).children( "tbody" ).append( $( "<tr>" ).append( $( "<td>" ) .attr( "colspan", "2" ) .addClass( "script-installer-ibx" ) .append( $( "<button>" ) .addClass( "mw-ui-button mw-ui-progressive mw-ui-big" ) .text( localScriptsByName[ scriptName ] ? "Uninstall" : "Install" ) .click( makeLocalInstallClickHandler( scriptName ) ) ) ) ); } ); } function makeLocalInstallClickHandler( scriptName ) { return function () { var $this = $( this ); if( $this.text() === "Install" ) { var okay = window.sciNoConfirm || window.confirm( "Warning! All 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? (Hide this dialog next time with sciNoConfirm=true; in your common.js.)" ); if( okay ) { $( this ).text( "Installing..." ) Import.ofLocal( scriptName, "common" ).install().done( function () { $( this ).text( "Uninstall" ); conditionalReload( false ); }.bind( this ) ); } } else { $( this ).text( "Uninstalling..." ) var uninstalls = uniques( localScriptsByName[ scriptName ] ) .map( function ( target ) { return Import.ofLocal( scriptName, target ).uninstall(); } ) $.when.apply( $, uninstalls ).then( function () { $( this ).text( "Install" ); conditionalReload( false ); }.bind( this ) ); } }; } function addCss() { mw.util.addCSS( "#script-installer-panel li.disabled a.script { "+ "text-decoration: line-through; font-style: italic; }"+ "#script-installer-panel { width:60%; border:solid lightgray 1px; "+ "padding:0; margin-left: auto; "+ "margin-right: auto; margin-bottom: 15px; overflow: auto; "+ "box-shadow: 5px 5px 5px #999; background-color: #fff; z-index:50; }"+ "#script-installer-panel header { background-color:#CAE1FF; display:block;"+ "padding:5px; font-size:1.1em; font-weight:bold; text-align:left; }"+ "#script-installer-panel .checkbox-container input { margin-left: 1.5em; }"+ "#script-installer-panel .filter-container { margin-bottom: -0.75em; }"+ "#script-installer-panel .filter-container label { margin-right: 0.35em; }"+ "#script-installer-panel .container { padding: 0.75em; }"+ "#script-installer-panel .container h2 { margin-top: 0.75em; }"+ "#script-installer-panel a { cursor: pointer; }"+ "#script-installer-main-install { font-weight: bold; }"+ "#script-installer-top-container { bottom: 5px; font-size: 70%; margin-left: 1em }"+ "body.skin-modern #script-installer-top-container a { color: inherit; cursor: pointer }"+ "body.skin-timeless #script-installer-top-container a,body.skin-cologneblue #script-installer-top-container a { cursor: pointer }"+ "#script-installer-top-container img.warning { position: relative; top: -2px; margin-right: 3px }"+ "td.script-installer-ibx { text-align: center }" ); } /******************************************** * * 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( function ( data ) { var pageId = Object.keys( data.query.pages )[0]; if( data.query.pages[pageId].revisions ) { return data.query.pages[pageId].revisions[0].slots.main["*"]; } return ""; } ); } function escapeForRegex( s ) { return s.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' ); } function getFullTarget ( target ) { return "User:" + mw.config.get( "wgUserName" ) + "/" + 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.scriptInstallerAutoReload === undefined ) { window.scriptInstallerAutoReload = true; } var jsPage = mw.config.get( "wgPageName" ).slice( -3 ) === ".js" || mw.config.get( "wgPageContentModel" ) === "javascript"; $.when( $.ready, mw.loader.using( [ "mediawiki.api", "mediawiki.util" ] ) ).then( function () { api = new mw.Api(); addCss(); buildImportList().then( function () { 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" ); } } ); } ); } )();