User:Lucas Werkmeister/m3api-ApiSandbox-helper.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, 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: Press Ctrl-F5.
/**
 * m3api-ApiSandbox-helper
 * =======================
 *
 * See [[User:Lucas Werkmeister/m3api-ApiSandbox-helper]]
 * for information and usage instructions.
 * (Just remove the .js from the URL of this page.)
 */

( function m3api_ApiSandbox_helper_iife() {

	// paramater types
	/** A plain string. (Includes several API types without special handling, e.g. timestamp.) */
	var TYPE_STRING = 'string';
	/** A set of strings. */
	var TYPE_STRING_SET = 'string|set';
	/** A number (integer or limit in the API). May fall back to a string if necessary. */
	var TYPE_NUMBER = 'number';
	/** A set of numbers. */
	var TYPE_NUMBER_SET = 'number|set';
	/** A namespace (number with comment indicating namespace). */
	var TYPE_NAMESPACE = 'namespace';
	/** A set of namespaces. */
	var TYPE_NAMESPACE_SET = 'namespace|set';
	/** A boolean. */
	var TYPE_BOOLEAN = 'boolean';

	/**
	 * @var {Object.<string,Map|Promise>}
	 * For a given action, either a Map of param name to TYPE_* constant,
	 * or a Promise that will resolve when information for that action will be available.
	 */
	var paramTypesPerAction = {};

	/**
	 * Set of all the actions (modules) that must be POSTed.
	 */
	var postActions = new Set();

	/**
	 * Parse a paraminfo API response.
	 *
	 * On the initial call, make paramTypes the empty set,
	 * currentModules a set containing only a single action/module,
	 * and prefix the empty string.
	 * The main result will be in paramTypes,
	 * the return value indicates whether the action/module requires POST.
	 *
	 * @param {Object} response The API response.
	 * @param {Map} paramTypes Map to which param types should be added.
	 * @param {Set} currentModules Modules whose param names should be inspected.
	 * @param {string} prefix Prefix for all param names currently inspected.
	 * @param {boolean} Whether any of the currentModules must be POSTed.
	 */
	function parseParaminfo( response, paramTypes, currentModules, prefix ) {
		var mustBePosted = false;
		response.paraminfo.modules.forEach( function ( module ) {
			if ( !currentModules.has( module.name ) ) {
				return;
			}
			if ( module.mustbeposted ) {
				mustBePosted = true;
			}
			var modulePrefix = prefix + ( module.prefix || '' );
			module.parameters.forEach( function ( parameter ) {
				var paramName = modulePrefix + parameter.name;
				var multi = !!parameter.multi;
				if ( parameter.type === 'boolean' ) {
					paramTypes.set( paramName, TYPE_BOOLEAN );
				} else if ( parameter.type === 'integer' || parameter.type === 'limit' ) {
					paramTypes.set( paramName, multi ? TYPE_NUMBER_SET : TYPE_NUMBER );
				} else if ( parameter.type === 'namespace' ) {
					paramTypes.set( paramName, multi ? TYPE_NAMESPACE_SET : TYPE_NAMESPACE );
				} else {
					paramTypes.set( paramName, multi ? TYPE_STRING_SET : TYPE_STRING );
				}

				if ( !parameter.submodules ) {
					return;
				}
				var submoduleNames = Object.keys( parameter.submodules );
				var currentModules = new Set();
				submoduleNames.forEach( currentModules.add, currentModules );
				submoduleNames.forEach( function ( submoduleName ) {
					parseParaminfo(
						response,
						paramTypes,
						currentModules,
						modulePrefix + ( parameter.submoduleparamprefix || '' )
					);
				} );
			} );
		} );
		return mustBePosted;
	}

	/**
	 * @var {Map|Promise|null}
	 * A Map of namespace ID to local name,
	 * or a Promise that will resolve when namespace information will be available,
	 * or null if the request has not been made yet.
	 */
	var namespaces = null;

	/**
	 * Parse namespaces out of an API response.
	 * Parameters should include query+siteinfo+namespaces,
	 * query+allmessages+ammessages=blanknamespace,
	 * and formatversion=2.
	 *
	 * @param {Object} response The API response.
	 * @return {Map} Map of namespace ID to local name.
	 */
	function parseNamespaces( response ) {
		var namespaces = new Map();
		for ( var id in response.query.namespaces ) {
			namespaces.set( id, response.query.namespaces[ id ].name );
		}
		if ( !namespaces.get( '0' ) ) {
			var message = response.query.allmessages[ 0 ];
			if ( message.normalizedname !== 'blanknamespace' ) {
				throw new Error( 'Unexpected message in response: ' + mesage.normalizedname );
			}
			namespaces.set( '0', message.content );
		}
		return namespaces;
	}

	/**
	 * RegExp for param values that are (sets of) numbers.
	 * Does not guarantee that the numbers are representable in JavaScript.
	 */
	var numberSetRegExp = /^(?:(?:\x1f(?:[1-9][0-9]*|0))+|(?:[1-9][0-9]*|0)(?:\|(?:[1-9][0-9]*|0))*)$/;

	/**
	 * Try to guess the type of a param from its name and value,
	 * in case paraminfo is not available yet.
	 *
	 * @param {string} paramName
	 * @param {*} paramValue
	 * @return {string} TYPE_* constant
	 */
	function guessType( paramName, paramValue ) {
		if ( paramValue === 1 ) {
			return TYPE_BOOLEAN;
		}
		paramValue = String( paramValue );
		var multi = paramValue[ 0 ] === '\x1f' || paramValue.indexOf( '|' ) !== -1;
		if ( numberSetRegExp.test( paramValue ) ) {
			return multi ? TYPE_NUMBER_SET : TYPE_NUMBER;
		}
		if ( paramName === 'namespace' ) {
			return multi ? TYPE_NAMESPACE_SET : TYPE_NAMESPACE;
		}
		return multi ? TYPE_STRING_SET : TYPE_STRING;
	}

	/**
	 * Format the given string as code (string literal, single-quoted).
	 *
	 * @param {string} string
	 * @return {string}
	 */
	function formatString( string ) {
		return "'" + JSON.stringify( string ).slice( 1, -1 ) + "'";
	}

	/**
	 * Conservative RegExp for JavaScript identifiers
	 * (ASCII only, since param names are probably ASCII).
	 */
	var identifierRegExp = /^(?:[$_a-zA-Z][$_a-zA-Z0-9]*)$/;
	/**
	 * RegExp for JavaScript reserved words
	 * (including await and yield, since we want to generate code
	 * that works in async functions and/or generator functions).
	 */
	var reservedWordRegExp = /^(?:await|break|case|catch|const|continue|debugger|default|delete|do|else|enum|export|extends|false|finally|for|function|if|import|in|instanceof|new|null|return|super|switch|this|throw|true|try|typeof|var|void|while|with|yield)$/;

	/**
	 * Whether the given param name can be used in code unquoted.
	 *
	 * @param {string} paramName
	 * @return {boolean}
	 */
	function canUnquoteParamName( paramName ) {
		return identifierRegExp.test( paramName ) &&
			!reservedWordRegExp.test( paramName );
	}

	/**
	 * Format the given param name for code.
	 *
	 * @param {string} paramName
	 * @return {string}
	 */
	function formatParamName( paramName ) {
		if ( canUnquoteParamName( paramName ) ) {
			return paramName;
		} else {
			return formatString( paramName );
		}
	}

	/**
	 * Split the given param value for a set.
	 *
	 * @param {string} paramValue
	 * @return {string[]}
	 */
	function splitSetParamValue( paramValue ) {
		if ( paramValue[ 0 ] === '\x1f' ) {
			return paramValue.slice( 1 ).split( '\x1f' );
		} else {
			return paramValue.split( '|' );
		}
	}

	/**
	 * Format the given param value as code for a string.
	 *
	 * @param {string} paramValue
	 * @return {string} Code, with trailing comma.
	 */
	function formatParamValueString( paramValue ) {
		return formatString( paramValue ) + ',';
	}

	/**
	 * Format the given param value as code for a set of strings.
	 *
	 * @param {string} paramValue
	 * @return {string} Code, no leading indentation,
	 * following lines assume one tab indentation,
	 * with trailing comma.
	 */
	function formatParamValueStringSet( paramValue ) {
		var values = splitSetParamValue( paramValue );

		// try single line
		var code = 'set( ';
		for ( var i = 0; i < values.length; i++ ) {
			if ( i > 0 ) {
				code += ', ';
			}
			code += formatString( values[ i ] );
		}
		code += ' ),';
		if ( code.length <= 76 ) { // assume tab width 4
			return code;
		}

		// too long, switch to one string per line
		code = 'set(\n';
		for ( var j = 0; j < values.length; j++ ) {
			code += '\t\t' + formatString( values[ j ] ) + ',\n';
		}
		code += '\t),';
		return code;
	}

	/**
	 * Format the given number as code,
	 * taking into account possible loss of precision.
	 *
	 * @param {string} number
	 * @return {string}
	 */
	function formatNumber( number ) {
		if ( parseInt( number, 10 ).toString( 10 ) === number ) {
			return number;
		} else {
			// doesn’t fit into a JS number,
			// or just isn’t a number (e.g. "max" for limit params)
			return formatString( number );
		}
	}

	/**
	 * Format the given param value as code for a number.
	 *
	 * @param {string} paramValue
	 * @return {string} Code, with trailing comma.
	 */
	function formatParamValueNumber( paramValue ) {
		return formatNumber( paramValue ) + ',';
	}

	/**
	 * Format the given param value as code for a set of numbers.
	 *
	 * @param {string} paramValue
	 * @return {string} Code, no leading indentation,
	 * following lines assume one tab indentation,
	 * with trailing comma.
	 */
	function formatParamValueNumberSet( paramValue ) {
		var values = splitSetParamValue( paramValue );

		// try single line
		var code = 'set( ';
		for ( var i = 0; i < values.length; i++ ) {
			if ( i > 0 ) {
				code += ', ';
			}
			code += formatNumber( values[ i ] );
		}
		code += ' ),';
		if ( code.length <= 76 ) { // assume tab width 4
			return code;
		}

		// too long, switch to one number per line
		code = 'set(\n';
		for ( var j = 0; j < values.length; j++ ) {
			code += '\t\t' + formatNumber( values[ j ] ) + ',\n';
		}
		code += '\t),';
		return code;
	}

	/**
	 * Format the given param value as code for a namespace.
	 *
	 * @param {string} paramValue
	 * @return {string} Code, with trailing comma and possibly comment.
	 */
	function formatParamValueNamespace( paramValue ) {
		var namespace = namespaces instanceof Map ?
			namespaces.get( paramValue ) :
			undefined;
		var code = formatNumber( paramValue ) + ',';
		if ( namespace !== undefined ) {
			code += ' // ' + namespace;
		}
		return code;
	}

	/**
	 * Format the given param value as code for a set of namespaces.
	 *
	 * @param {string} paramValue
	 * @return {string} Code, no leading indentation,
	 * following lines assume one tab indentation,
	 * with trailing comma.
	 */
	function formatParamValueNamespaceSet( paramValue ) {
		if ( !( namespaces instanceof Map ) ) {
			return formatParamValueNumberSet( paramValue );
		}

		var values = splitSetParamValue( paramValue );
		var code = 'set(\n';
		for ( var i = 0; i < values.length; i++ ) {
			code += '\t\t' + formatParamValueNamespace( values[ i ] ) + '\n';
		}
		code += '\t),';
		return code;
	}

	/**
	 * Format the given param value as code for a boolean.
	 *
	 * @param {*} paramValue
	 * @return {string} Code, with trailing comma.
	 */
	function formatParamValueBoolean( paramValue ) {
		return 'true,'; // any value means true, we can’t tell if it’s absent
	}

	/**
	 * Format the given param value as code,
	 * depending on its type as indicated by the action and param name.
	 *
	 * @param {string} action The action param value.
	 * @param {string} paramName The param name.
	 * @param {*} paramValue The param value.
	 * @return {string} Code, no leading indentation,
	 * following lines assume one tab indentation,
	 * with trailing comma.
	 */
	function formatParamValue( action, paramName, paramValue ) {
		var type;
		if ( paramTypesPerAction[ action ] instanceof Map ) {
			type = paramTypesPerAction[ action ].get( paramName );
		}
		if ( type === undefined ) {
			type = guessType( paramName, paramValue );
		}

		switch ( type ) {
		case TYPE_STRING: return formatParamValueString( paramValue );
		case TYPE_STRING_SET: return formatParamValueStringSet( paramValue );
		case TYPE_NUMBER: return formatParamValueNumber( paramValue );
		case TYPE_NUMBER_SET: return formatParamValueNumberSet( paramValue );
		case TYPE_NAMESPACE: return formatParamValueNamespace( paramValue );
		case TYPE_NAMESPACE_SET: return formatParamValueNamespaceSet( paramValue );
		case TYPE_BOOLEAN: return formatParamValueBoolean( paramValue );
		default: throw new Error( 'Unknown param type: ' + type );
		}
	}

	/**
	 * Format the given param name and value for the given action
	 * into one or more full lines of code,
	 * indented (one tab) and with trailing comma and line break.
	 *
	 * @param {string} action The action param value, for determining the param type.
	 * @param {string} paramName The param name.
	 * @param {*} paramValue The param value.
	 * @return {string}
	 */
	function formatParam( action, paramName, paramValue ) {
		return '\t' + formatParamName( paramName ) +
			': ' + formatParamValue( action, paramName, paramValue ) + '\n';
	}

	/**
	 * Param names that we want to make default params.
	 */
	var defaultParams = new Set();
	// IE11 supports new Set() but not new Set( [ ... ] ), so add elements afterwards:
	[
		'maxlag',
		'responselanginfo',
		'origin',
		'uselang',
		'errorformat',
		'errorlang',
		'errorsuselocal',
		'formatversion',
	].forEach( defaultParams.add, defaultParams );

	/**
	 * Whether to make the param with the given name
	 * a default (constructor) param or a regular (request) param.
	 *
	 * @param {string} paramName
	 * @return {boolean}
	 */
	function makeDefaultParam( paramName ) {
		return defaultParams.has( paramName );
	}

	/**
	 * RegExp for standard wgServer values.
	 */
	var standardServerRegExp = /^(?:https:)?\/\//;
	/**
	 * Standard wgScriptPath value.
	 */
	var standardScriptPath = '/w';

	/**
	 * Format an API URL for the Session constructor.
	 * Use just the domain if possible, otherwise the full URL.
	 *
	 * @param {string} wgServer The wgServer config.
	 * @param {string} wgScriptPath The wgScriptPath config.
	 * @param {Location} location The location global,
	 * needed if the wgServer is protocol-relative.
	 * @return {string}
	 */
	function formatApiUrl( wgServer, wgScriptPath, location ) {
		if ( wgScriptPath === standardScriptPath ) {
			var serverMatch = standardServerRegExp.exec( wgServer );
			if ( serverMatch !== null ) {
				var domain = wgServer.slice( serverMatch[ 0 ].length );
				return domain;
			}
		}
		var origin = wgServer.slice( 0, 2 ) === '//' ?
			location.protocol + wgServer :
			wgServer;
		return origin + wgScriptPath + '/api.php';
	}

	/**
	 * Format a request into a full m3api code snippet.
	 *
	 * @param {Object} params
	 * @return {string}
	 */
	function formatRequest( params ) {
		var action = params.action,
			defaultParams = {},
			requestParams = {};
		for ( var paramName in params ) {
			if ( paramName === 'format' ) {
				continue;
			}
			var paramValue = params[ paramName ];
			if ( makeDefaultParam( paramName ) ) {
				defaultParams[ paramName ] = paramValue;
			} else {
				requestParams[ paramName ] = paramValue;
			}
		}

		var apiUrl = formatApiUrl(
			mw.config.get( 'wgServer' ),
			mw.config.get( 'wgScriptPath' ),
			location
		);
		var code = 'const session = new Session( ' + formatString( apiUrl ) + ', {\n';

		var suggestedDefaultParams = {
			formatversion: 'formatversion: 2',
			errorformat: "errorformat: 'plaintext'",
		};
		for ( var defaultParamName in defaultParams ) {
			code += formatParam( action, defaultParamName, defaultParams[ defaultParamName ] );
			delete suggestedDefaultParams[ defaultParamName ];
		}

		for ( var suggestedDefaultParamName in suggestedDefaultParams ) {
			code += '\t// (suggestion) ' + suggestedDefaultParams[ suggestedDefaultParamName ] + ',\n';
		}

		code += '}, {\n' +
			'\tuserAgent: \'m3api-ApiSandbox-helper\', // change this :)\n' +
			'} );\n';

		code += 'const response = await session.request( {\n';
		for ( var requestParamName in requestParams ) {
			code += formatParam( action, requestParamName, requestParams[ requestParamName ] );
		}
		if ( postActions.has( action ) ) {
			code += '}, {\n' +
				"\tmethod: 'POST',\n";
		}
		code += '} );';

		return code;
	}

	mw.hook( 'apisandbox.formatRequest' ).add( function m3api_ApiSandbox_helper( items, displayParams, rawParams ) {
		// generate initial code
		var code = formatRequest( displayParams );
		var copyTextLayout = new mw.widgets.CopyTextLayout( {
			label: $( '<a>' )
				.attr( 'href', 'https://www.npmjs.com/package/m3api' )
				.attr( 'target', '_blank' )
				.addClass( 'external' )
				.text( 'm3api' )
				.add( document.createTextNode( ' code:' ) ),
			copyText: code,
			multiline: true,
			textInput: {
				classes: [ 'mw-apisandbox-textInputCode' ],
				rows: Math.min( 15, code.split( '\n' ).length ),
			},
		} );
		var menuOptionWidget = new OO.ui.MenuOptionWidget( {
			label: 'm3api code',
			data: copyTextLayout,
		} );
		items.push( menuOptionWidget );

		// look up paraminfo and/or namespaces if we don’t have them yet

		var action = displayParams.action;
		if ( !paramTypesPerAction[ action ] ) {
			paramTypesPerAction[ action ] = new mw.Api().get( {
				action: 'paraminfo',
				modules: [ action, action + '+**' ],
				formatversion: 2,
			} ).then( function ( response ) {
				var paramTypes = new Map();
				var currentModules = new Set();
				currentModules.add( action );
				var mustBePosted = parseParaminfo( response, paramTypes, currentModules, '' );
				paramTypesPerAction[ action ] = paramTypes;
				if ( mustBePosted ) {
					postActions.add( action );
				}
			} );
		}
		var repeatPromise = null;
		if ( typeof paramTypesPerAction[ action ].then === 'function' ) {
			repeatPromise = paramTypesPerAction[ action ];
		}

		if ( !namespaces ) {
			namespaces = new mw.Api().get( {
				action: 'query',
				meta: [ 'siteinfo', 'allmessages' ],
				siprop: [ 'namespaces' ],
				ammessages: [ 'blanknamespace' ],
				amlang: [ mw.config.get( 'wgContentLanguage' ) ],
				amenableparser: true,
				formatversion: 2,
			} ).then( function ( response ) {
				namespaces = parseNamespaces( response );
			} );
		}
		if ( typeof namespaces.then === 'function' ) {
			repeatPromise = repeatPromise ?
				$.when( repeatPromise, namespaces ) :
				namespaces;
		}

		// regenerate code after we have paraminfo and namespaces
		if ( repeatPromise !== null ) {
			repeatPromise.then( function () {
				var code = formatRequest( displayParams );
				copyTextLayout.textInput.setValue( code );
				copyTextLayout.textInput.$input.attr(
					'rows', Math.min( 15, code.split( '\n' ).length ) );
			} );
		}
	} );

} )();