Jump to content

MediaWiki:Gadget-calculator.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/* On-Wiki calculator script. See [[Template:Calculator]]. Created by [[User:Bawolff]]. Forked from https://mdwiki.org/wiki/MediaWiki:Gadget-calculator.js (released under Creative Commons Attribution-ShareAlike 3.0 and 4.0 International Public License). See original source for attribution history
 *
 * This script is designed with security in mind. Possible security risks:
 *  * Having a formula that executes JS
 *  ** To prevent this we do not use eval(). Instead we parse the formula with our own parser into an abstract tree that we evaluate by walking through it
 *  ** Form submission & DOM clobbering - we prefix the name (and id) attribute of all fields to prevent conflicts
 *  ** Style injection - we take the style attribute from an existing element that was sanitized by MW. We do not take style from a data attribute.
 *  ** Client-side DoS - Formulas aren't evaluated without user interaction. Formulas have a max length. Max number of widgets per page. Ultimately, easy to revert slow formulas just like any other vandalism.
 *
 * Essentially the code works by replacing certain <span> tags with <input>, parsing a custom formula language, setting up a dependency graph based on identifiers, and re-evaluating formulas on change.
 */
(function () {

	var mathFuncs = [ 'abs', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'ceil', 'cos', 'cosh', 'exp', 'floor', 'hypot',
		'log', 'log10', 'log2', 'max', 'min', 'pow', 'random', 'sign', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'trunc' ];
	var otherFuncs = [ 'ifzero', 'coalesce', 'iffinite', 'ifnan', 'ifpositive', 'ifequal', 'round', 'jsround' ];
	var allFuncs = mathFuncs.concat(otherFuncs);

	var convertFloat = function ( f ) {
		return f.replace( /[×⁰¹²³⁴⁵⁶⁷⁸⁹⁻]/g, function (m) {
			return ({'⁰': 0,'¹': 1,'²': 2,'³': 3,'⁴': 4,'⁵': 5,'⁶': 6,'⁷': 7,'⁸': 8,'⁹': 9, '⁻': "-", "×": "e"})[m];
		} );
	};

	// Start parser code.
	var Numb = function(n) {
		if ( typeof n === 'number' ) {
			this.value = n;
		}
		this.value = parseFloat(n);
	}
	Numb.prototype.toString = function () { return 'Number<' + this.value + '>'; }

	var Ident = function(n) {
		this.value = n;
	}
	Ident.prototype.toString = function () { return 'IDENT<' + this.value + '>'; }

	var Operator = function(val, args) {
		this.op = val;
		this.args = args;
	}
	
	var Null = function() { }

	var Parser = function( text ) {
		this.text = text;
	};

	var terminals = {
		'IDENT': /^[a-zA-Z_][a-zA-Z0-9_]*/,
		'NUMBER': /^-?[0-9]+(?:\.[0-9]+)?(?:[eE][0-9]+|×10⁻?[⁰¹²³⁴⁵⁶⁷⁸⁹]+)*/,
		'WS': /^\s*/,
		'PLUSMINUS': /^[+-]/,
		'pi': /^(?:pi|π)(?![A-z_0-9-])/i,
		'epsilon': /^EPSILON(?![A-z_0-9-])/,
		'Infinity': /^Infinity(?![A-z_0-9-])/i,
		'-Infinity': /^Infinity(?![A-z_0-9-])/i,
		'NaN': /^NaN(?![A-z_0-9-])/i,
		'MULTIPLYDIV': /^[*\/%×÷]/i,
	};

	Parser.prototype = {
		check: function(id) {
			if ( terminals[id] ) {
				return !!(this.text.match(terminals[id]))	
			}
			return this.text.startsWith( id )
		},
		consume: function(id) {
			if ( terminals[id] ) {
				var res = this.text.match(terminals[id]);
				this.text = this.text.substring( res[0].length );
				return res[0];
			}
			if ( this.text.startsWith( id ) ) {
				this.text = this.text.substring(id.length);
				return id;
			}
			throw new Error( "Expected " + id);
		},

		parse: function () {
			if ( this.text === undefined || this.text === '' ) return new Null();
			this.consume( 'WS' );
			res = this.expression();
			if( this.text.length !== 0 ) {
				throw new Error( "Unexpected end of text" );
			}
			return res;
		},

		expression: function () {
			var text2, res, res2;
			res = this.term();
			this.consume( 'WS' );
			while ( this.check( "PLUSMINUS" )) {
				var op = this.consume( "PLUSMINUS" );
				res2 = this.term();
				res = new Operator( op, [ res, res2 ] );
			}
			return res;
		},

		argList: function () {
			var args = [];
			this.consume( 'WS' );
			if ( this.check( ')' ) ) {
				this.consume( ')' );
				return args;
			}
			args[args.length] = this.expression();
			this.consume( 'WS' );

			while ( this.check( ',' ) ) {
				this.consume( ',' );
				this.consume( 'WS' );
				args[args.length] = this.expression();
			}
			this.consume( 'WS' );
			this.consume( ')' );
			return args;
		},
		term: function () {
			var text2, res, res2;
			res = this.factor();
			this.consume( 'WS' );
			while ( this.check( "MULTIPLYDIV" )) {
				var op = this.consume( "MULTIPLYDIV" );
				if ( op === '×' ) op = '*';
				if ( op === '÷' ) op = '/';
				res2 = this.term();
				res = new Operator( op, [ res, res2 ] );
			}
			return res;
		},
		factor: function () {
			if ( this.check( 'pi' ) ) {
				this.consume( 'pi' );
				return new Numb( Math.PI )
			}
			if ( this.check( 'Infinity' ) ) {
				this.consume( 'Infinity'  );
				return new Numb( Infinity );
			}
			if ( this.check( '-Infinity' ) ) {
				this.consume( '-Infinity'  );
				return new Numb( -Infinity );
			}
			if ( this.check( 'NaN' ) ) {
				this.consume( 'NaN'  );
				return new Numb( NaN );
			}
			if ( this.check( 'epsilon' ) ) {
				this.consume( 'epsilon' );
				return new Numb( Number.EPSILON );
			}

			for ( var i in allFuncs ) {
				if ( this.check( allFuncs[i] + '(' ) ) {
					this.consume(allFuncs[i] + '(');
					var argList = this.argList()
					return new Operator( allFuncs[i], argList );
				}
			}

			if ( this.check( 'IDENT' ) ) {
				return new Ident( this.consume( 'IDENT' ) );
			}
			if ( this.check( 'NUMBER' ) ) {
				return new Numb( convertFloat( this.consume( 'NUMBER' ) ) );
			}

			if ( this.check( '(' ) ) {
				this.consume( '(' );
				res = this.expression();
				this.consume( ')' );
				return res;
			}
			throw new Error( "Expected term");
		},
	};
	// End parser code.

	// Based on https://floating-point-gui.de/errors/comparison/
	var almostEquals = function( a, b ) {
		var absA = Math.abs(a);
		var absB = Math.abs(b);
		var diff = Math.abs(a-b);
		var epsilon = Number.EPSILON; /// Not sure if this is a good value for epsilon
		var minNormal = Math.pow( 2, -1022 );
		if ( a===b) {
			return true;
		}
		// Min normal of double = 2^-1022
		if ( a==0 || b==0 || absA+absB < minNormal ) {
			return diff < epsilon * minNormal;
		}

		return diff / Math.min((absA + absB), Number.MAX_VALUE) < epsilon;
	}

	// Evaluate the value of an AST at runtime.
	var evaluate = function( ast, elmList ) {
		if ( ast instanceof Numb ) {
			return ast.value;
		}
		if ( ast instanceof Ident ) {
			var elm = elmList[ast.value];
			if ( elm.tagName === 'INPUT' ) {
				if ( elm.type === 'radio' || elm.type === 'checkbox' ) {
					return elm.checked ? 1 : 0;
				}
				return parseFloat( elm.value );
			} else {
				return parseFloat( convertFloat( elm.textContent ) );
			}
		}
		if ( ast instanceof Operator ) {
			evaledArgs = ast.args.map( function (i) { return evaluate( i, elmList ) } );
			if ( mathFuncs.indexOf(ast.op) !== -1 ) {
				return Math[ast.op].apply( Math, evaledArgs );
			}
			if ( 'coalesce' === ast.op ) {
				for ( var k = 0; k < evaledArgs.length; k++ ) {
					if ( !isNaN( evaledArgs[k] ) ) {
						return evaledArgs[k];
					}
				}
				return NaN;
			}
			if ( 'ifzero' === ast.op ) {
				if ( evaledArgs.length !== 3 ) {
					return NaN;
				}
				return almostEquals( evaledArgs[0], 0 ) ? evaledArgs[1] : evaledArgs[2];
			}
			if ( 'ifequal' === ast.op ) {
				if ( evaledArgs.length !== 4 ) {
					return NaN;
				}
				return almostEquals( evaledArgs[0], evaledArgs[1] ) ? evaledArgs[2] : evaledArgs[3];
			}
			if ( 'iffinite' === ast.op ) {
				if ( evaledArgs.length !== 3 ) {
					return NaN;
				}
				return isFinite( evaledArgs[0] ) ? evaledArgs[1] : evaledArgs[2];
			}
			if ( 'ifnan' === ast.op ) {
				if ( evaledArgs.length !== 3 ) {
					return NaN;
				}
				return isNaN( evaledArgs[0] ) ? evaledArgs[1] : evaledArgs[2];
			}
			if ( 'ifpositive' === ast.op ) {
				if ( evaledArgs.length !== 3 ) {
					return NaN;
				}
				return evaledArgs[0] >= 0 ? evaledArgs[1] : evaledArgs[2]
			}

			// js Math.round is weird. Do our own version. Based on https://stackoverflow.com/questions/11832914/how-to-round-to-at-most-2-decimal-places-if-necessary
			// This does round-half away from zero, with floating point error correction.
			if ( 'round' === ast.op ) {
				var decimals = evaledArgs.length >= 2 ? evaledArgs[2] : 0;
				var p = Math.pow( 10,  decimals );
				var n = (evaledArgs[0] * p) * (1 + Number.EPSILON);
				return Math.round(n)/p;
			}

			// In case anyone wants normal js round.
			if ( ast.op === 'jsround' ) {
				return Math.round.apply( Math, evaledArgs );
			}

			if ( evaledArgs.length !== 2 ) {
				throw new Error( "Unexpected number of args for " + ast.op );
			}
			if ( ast.op === '*' ) return evaledArgs[0]*evaledArgs[1];
			if ( ast.op === '/' ) return evaledArgs[0]/evaledArgs[1];
			if ( ast.op === '+' ) return evaledArgs[0]+evaledArgs[1];
			if ( ast.op === '-' ) return evaledArgs[0]-evaledArgs[1];
			if ( ast.op === '%' ) return evaledArgs[0]%evaledArgs[1];
			throw new Error( "Unrecognized operator " + ast.op );
		}
		return NaN;
	}

	// Start dependency graph code

	var getIdentifiers = function( tree ) {
		if ( tree instanceof Ident ) {
			return new Set([tree.value]);
		}
		if ( tree instanceof Operator ) {
			var res = new Set([]);
			for ( var i = 0; i < tree.args.length; i++ ) {
				getIdentifiers( tree.args[i] ).forEach( function (x) { res.add(x) } );
			}
			return res;
		}
		return new Set();
	}

	var buildBacklinks = function (items) {
		var backlinks = {};
		for ( var item in items ) {
			var idents = getIdentifiers( items[item] );
			// Set does not do for..in loops, and this version MW forbids for..of
			idents.forEach( function (ident) {
				if ( !backlinks[ident] ) {
						backlinks[ident] = [];
				}
				backlinks[ident].push( item );
			} );
		}
		return backlinks;
	};

	// End dependency graph code

	// Start code that does setup and HTML interaction.

	var setup = function ( $content ) {
		var elms = Array.from( $content.find( '.calculator-field' ) );
		new CalculatorWidgets( elms );

		$content.find( '.calculator-field-label[data-for]').replaceWith( function() {
			var l = $( '<label>' );
			if ( !this.dataset.for || !this.dataset.for.match( /^calculator-field-[a-zA-Z_][a-zA-Z0-9_]*$/ ) || !$content.find( '#' + $.escapeSelector( this.dataset.for ) ) ) {
				return this;
			}
			l.attr( 'for', this.dataset.for );
			if ( this.id ) {
				l.attr( 'id', this.id );
			}
			if ( this.title ) {
				l.attr( 'title', this.title );
			}
			if ( this.style.cssText ) {
				l.attr( 'style', this.style.cssText );
			}
			l.html( this.innerHTML );
			return l;
		} );
	}

	var doStats = function () {
		if ( window.calculatorStatsAlreadyDone !== true ) {
			window.calculatorStatsAlreadyDone = true;
			mw.track( 'counter.gadget_calculator._all' );
			mw.track( 'counter.gadget_calculator.' + mw.config.get( 'wgDBname' ) + '_all' );
			var statName = mw.config.get( 'wgDBname' ) + '_' + mw.config.get( 'wgPageName' );
			statName = encodeURIComponent( statName );
			// Symbols don't seem to work.
			statName = statName.replace( /[^a-zA-Z0-9_]/g, '_' );
			mw.track( 'counter.gadget_calculator.' + statName );
		}
	}

	var CalculatorWidgets = function( elms ) {
		this.itemList = {};
		this.elmList = {};
		this.backlinks = {};
		this.inProgressRefresh = undefined;

		if (elms.length > 200) {
			console.log( "Too many calculator widgets on page" );
			return;
		}
		for ( var i in elms ) {
			var elm = elms[i];
			var formula = elm.dataset.calculatorFormula;
			if ( formula && formula.length > 2000 ) {
				console.log( "Skipping element with too long formula" );
				continue;
			}
			var type = elm.dataset.calculatorType;
			var readonly = !!elm.dataset.calculatorReadonly;
			var id = elm.id;
			if ( !id ) {
				id = 'calculator-field-auto' + Math.floor( Math.random()*10000000 );
			}
			var defaultVal = elm.textContent;
			if ( type === undefined || !id.match( /^calculator-field-[a-zA-Z_][a-zA-Z0-9_]*$/ ) ) {
				console.log( "Skipping " + id );
				continue;
			}

			try {
				var formulaAST = (new Parser( formula )).parse();
			} catch( e ) {
				console.log( "Error parsing formula of " + id + ": " + e.message );
				continue;
			}
			if ( type !== 'plain' ) {
				var input = document.createElement( 'input' );
				input.className = 'calculator-field-live';
				input.readOnly = readonly
				input.value = parseFloat(defaultVal);
				input.style.cssText = elm.style.cssText; // This should be safe because elm's css was sanitized by MW
				if ( elm.dataset.calculatorSize ) {
					var size = parseInt( elm.dataset.calculatorSize );
					input.size = size
					// Browsers are pretty inconsistent so also set as css
					// Firefox shows a number selector that seems to always be 20px wide regardless of font.
					// Chrome shows the selector only on hover.
					input.style.width = type === 'number' ? "calc( " + size + 'ch' + ' + 20px)': size + 'ch';
				}
				if ( elm.dataset.calculatorSize ) input.size = elm.dataset.calculatorSize
				if ( elm.dataset.calculatorMax ) input.max = elm.dataset.calculatorMax
				if ( elm.dataset.calculatorMin ) input.min = elm.dataset.calculatorMin
				if ( elm.dataset.calculatorPlaceholder ) input.placeholder = elm.dataset.calculatorPlaceholder
				if ( elm.dataset.calculatorStep ) input.step = elm.dataset.calculatorStep
				if ( elm.dataset.calculatorPrecision ) input.dataset.calculatorPrecision = elm.dataset.calculatorPrecision
				if ( elm.dataset.calculatorExponentialPrecision ) input.dataset.calculatorExponentialPrecision = elm.dataset.calculatorExponentialPrecision
				if ( elm.dataset.calculatorDecimals ) input.dataset.calculatorDecimals = elm.dataset.calculatorDecimals
				if ( elm.dataset.calculatorNanText ) input.dataset.calculatorNanText = elm.dataset.calculatorNanText
				// Name is primarily for radio groups. Prefix to prevent dom clobbering or in case it ends up in a form somehow.
				if ( elm.dataset.calculatorName ) input.name = 'calcgadget-' + elm.dataset.calculatorName
				if ( type === 'number' || type === 'text' || type === 'radio' || type === 'checkbox' ) {
					input.type = type;
				}
				if ( type === 'radio' || type === 'checkbox' ) {
					input.onchange = this.changeHandler.bind(this); // some browsers dont fire oninput for checkboxrs/radio
					if ( !isNaN( defaultVal ) && !almostEquals( defaultVal, 0 ) ) {
						input.checked = true;
					}
				}
				input.id = id;
				elm.replaceWith( input );
				elm = input;
				input.oninput = this.changeHandler.bind(this);
			} else {
				elm.classList.remove( 'calculator-field' );
				elm.classList.add( 'calculator-field-live' );
				if ( formula ) {
					// Tell screen reader to announce changes right away.
					// unclear if this is too assertive. It is also unclear if we should also do this for <input>'s.
					elm.setAttribute( 'role', 'alert' );
				}
			}
			var itemId = id.replace( /^calculator-field-/, '' );
			this.itemList[itemId] = formulaAST;
			this.elmList[itemId] = elm;
		}
		this.backlinks = buildBacklinks( this.itemList );
	}

	CalculatorWidgets.prototype.changeHandler = function(e) {
		this.inProgressRefresh = {};
		doStats();
		var itemId = e.target.id.replace( /^calculator-field-/, '' );
		this.inProgressRefresh[itemId] = true;
		for ( var i in this.backlinks[itemId] ) {
			this.refresh( this.backlinks[itemId][i] );
		}
		if ( e.target.type === 'radio' ) {
			// when a radio element gets checked, others get unchecked.
			// Note: This is a bit sketchy if multiple wikipage contents are added with overlapping names
			// It should work in the live preview case, since hopefully older elements with conflicting
			// names would no longer be live, but might not work fully correctly in the general case.
			var otherElms = document.getElementsByName( e.target.name );
			for ( var l = 0; l < otherElms.length; l++ ) {
				if ( otherElms[l].id === e.target.id ) {
					continue;
				}
				var oElmId = otherElms[l].id.replace( /^calculator-field-/, '' );
				if ( this.backlinks[oElmId] ) {
					for ( var k in this.backlinks[oElmId] ) {
						this.refresh( this.backlinks[oElmId][k] );
					}
				}
			}
		}
		this.inProgressRefresh = undefined;
	}

	var format = function ( n, options ) {
		var res = n.toString();
		if ( typeof n !== "number" ) {
			return res;
		}
		if ( isNaN( n ) && options.calculatorNanText ) {
			return options.calculatorNanText;
		}
		if ( !isNaN( parseInt( options.calculatorDecimals ) ) ) {
			res = n.toFixed( parseInt( options.calculatorDecimals ) );
		}
		if ( !isNaN( parseInt( options.calculatorPrecision ) ) ) {
			res = n.toPrecision( parseInt( options.calculatorPrecision ) );
		}
		if ( !isNaN( parseInt( options.calculatorExponentialPrecision ) ) ) {
			res = n.toExponential( parseInt( options.calculatorExponentialPrecision ) );
		}

		res = res.replace( /e([+-])([0-9]+)$/, function (m, sign, exp) {
			var tmp = "×10";
			if ( sign === '-' ) {
				tmp += '⁻';
			}
			tmp += exp.replace( /[0-9]/g, function (m) {
				return [ '⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹' ][m];
			} );
			return tmp;
		} );
		return res;
	}

	CalculatorWidgets.prototype.refresh = function (itemId) {
		if ( !this.itemList[itemId] || this.itemList[itemId] instanceof Null ) {
			// This should not happen.
			console.log( "Tried to refresh field with no formula" );
			return;
		}
		if ( this.inProgressRefresh[itemId] ) {
			console.log( "Loop Detected! Skipping " + itemId );
			return;
		}
		this.inProgressRefresh[itemId] = true;
		var res = evaluate( this.itemList[itemId], this.elmList );
		var elm = this.elmList[itemId];
		if ( elm.tagName === 'INPUT' ) {
			elm.value = elm.type === "number" ? res : format( res, elm.dataset );
			if ( elm.type === 'radio' || elm.type === 'checkbox' ) {
				elm.checked = !isNaN( res ) && !almostEquals( res, 0 );
			}
		} else {
			elm.textContent = format( res, elm.dataset );
		}
		for ( var i in this.backlinks[itemId] ) {
			this.refresh( this.backlinks[itemId][i] );
		}
	}	

	mw.hook( 'wikipage.content' ).add( setup );
	
} )();