미디어위키:Gadget-calculator.js

/* On-Wiki calculator script. See Template:Calculator. Created by User:Bawolff. 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  tags with , 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', 'not', 'and', 'or', 'bool', 'ifless', 'iflessorequal', 'ifgreater', 'ifgreaterorequal', 'ifbetween', 'xor', 'index', 'switch' ;
	var allFuncs = mathFuncs.concat(otherFuncs);
	var convertFloat = function ( f ) {
		f = f.replace( /×\s?10/, 'e' );
		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'; }
	var Ident = function(n) {
		this.value = n;
	}
	Ident.prototype.toString = function () { return 'IDENT'; }
	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+)?(?:eE0-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 ( terminalsid ) {
				return 
(this.text.match(terminalsid))	
			}
			return this.text.startsWith( id )
		},
		consume: function(id) {
			if ( terminalsid ) {
				var res = this.text.match(terminalsid);
				this.text = this.text.substring( res0.length );
				return res0;
			}
			if ( this.text.startsWith( id ) ) {
				this.text = this.text.substring(id.length);
				return id;
			}
			throw new Error( "Expected " + id + " near " + this.text.substring(0,15) );
		},
		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 formula. Perhaps you forgot to close a prenthesis." );
			}
			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;
			}
			argsargs.length = this.expression();
			this.consume( 'WS' );
			while ( this.check( ',' ) ) {
				this.consume( ',' );
				this.consume( 'WS' );
				argsargs.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( allFuncsi + '(' ) ) {
					this.consume(allFuncsi + '(');
					var argList = this.argList()
					return new Operator( allFuncsi, 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 to see a term (e.g. an identifier, number, function, opening parenthesis, etc) near " + this.text.substring(0,15));
		},
	};
	// 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 = 3 ? evaluate( ast.args2 ) : NaN;
				}
				return getValueOfElm( res );
			}
			evaledArgs = ast.args.map( function (i) { return evaluate( i, elmList ) } );
			if ( mathFuncs.indexOf(ast.op) !== -1 ) {
				return Mathast.op.apply( Math, evaledArgs );
			}
			if ( 'coalesce' === ast.op ) {
				for ( var k = 0; k = 0 ? then : ielse;
			}
			// I am a bit unsure what the proper thing to do about floating point rounding issues here
			// These will err on the side of returning true given rounding error. People can use ifpositive() if they
			// need precise.
			if ( 'ifless' === ast.op ) {
				if ( evaledArgs.length  evaledArgs1 && !almostEquals( evaledArgs0, evaledArgs1 ) ? then : ielse;
			}
			if ( 'iflessorequal' === ast.op ) {
				if ( evaledArgs.length = evaledArgs1 
almostEquals( evaledArgs0, evaledArgs1 ) ? then : ielse;
			}
			// Should this use almostEquals???
			if ( 'ifbetween' === ast.op ) {
				if ( evaledArgs.length = evaledArgs1 && evaledArgs0 = 1 ? evaledArgsevaledArgs.length-1 : 1;
			}
			if ( 'or' === ast.op ) {
				for ( i = 0; i = 1 ? evaledArgsevaledArgs.length-1 : 0;
			}
			// switch(value,2,valueFor2,5,valueFor5,...,valueIfNoneMatch)
			// select the arg where the test = 2 ? evaledArgs1 : 0;
				var p = Math.pow( 10,  decimals );
				var n = (evaledArgs0 * 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 evaledArgs0*evaledArgs1;
			if ( ast.op === '/' ) return evaledArgs0/evaledArgs1;
			if ( ast.op === '+' ) return evaledArgs0+evaledArgs1;
			if ( ast.op === '-' ) return evaledArgs0-evaledArgs1;
			if ( ast.op === '%' ) return evaledArgs0%evaledArgs1;
			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();
			var i = 0;
			if ( tree.op === 'index' && tree.args.length > 0 ) {
				// Special case - index allows indirect references decided at runtime
				// A future version might handle this more dynamically.
				i++;
				getIdentifiers( tree.args0 ).forEach( function (x) { res.add(x + '*') } );
			}
			for ( ; i  to scope
		// ids just to that group. That way you can use a specific template multiple times on the same page.
		// (Perhaps we should turn it into a ?).
		var containers = Array.from( $content.find( '.calculator-container' ) );
		for ( var i = 0; i ' );
			// For accessibility reasons with codex, we sometimes have to make a label with no for attribute (e.g. radiogroups)
			if ( this.dataset.for !== undefined ) {
				if (  
this.dataset.for.match( /^calculator-field-a-zA-Z_a-zA-Z0-9_*$/ ) 

$content.find( '#' + $.escapeSelector( this.dataset.for ) ).length ) {
					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 );
			}
			if ( this.classList.contains( 'cdx-label__label' ) ) {
				mw.loader.load( '@wikimedia/codex' );
			}
			if ( this.className !== 'calculator-field-label' ) {
				var extraClass = this.dataset.calculatorClass === undefined ?  : ' ' + this.dataset.calculatorClass
				l.attr( 'class', this.className.replace( /(^| )calculator-field-label( |$)/g, ' ' ) + extraClass );
			}
			l.append( this.childNodes );
			return l;
		} );
	}
	var doStats = function () {
		// FIXME This probably needs to be updated to use Prometheus.
		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, parent ) {
		this.parent = parent;
		this.rand = Math.floor(Math.random()*1000000000)
		this.itemList = {};
		this.elmList = {};
		this.backlinks = {};
		this.inProgressRefresh = undefined;
		var that = this;
		if (elms.length > 200) {
			console.log( "Too many calculator widgets on page" );
			return;
		}
		for ( var i in elms ) {
			var elm = elmsi;
			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 ) + 'unnamed';
			}
			var defaultVal = ("" + elm.textContent).trim();
			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 + ". Formula given:" + formula );
				continue;
			}
			if ( elm.className.match( /(^| )cdx-/ ) 
)cdx-/ ) ) ) {
				// The input is using CSS-only codex modules. Unfortunately i think we have to load the whole thing.
				// we are going to have a flash of unstyled content no matter what we do since we are converting elements at load time, so don't worry about that.
				mw.loader.load( '@wikimedia/codex' );
			}
			if ( type !== 'plain' && type !== 'passthru' ) {
				var input = document.createElement( 'input' );
				input.className = 'calculator-field-live';
				if ( elm.className !== 'calculator-field' ) input.className += ' ' + elm.className.replace( /(^| )calculator-field($| )/g, ' ' );
				input.readOnly = readonly
				// If defaultVal is empty, we start the textbox value out as empty instead of NaN.
				if ( defaultVal !==  ) {
					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';
				}
				// Add css class, but only if the gadget is enabled.
				if ( elm.dataset.calculatorClass ) input.className += ' ' + elm.dataset.calculatorClass;
				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. Add rand to make unique between scoping containers
				if ( elm.dataset.calculatorName ) input.name = 'calcgadget-' + this.rand + '-' + elm.dataset.calculatorName;
				if ( elm.dataset.calculatorInputmode ) input.inputMode = elm.dataset.calculatorInputmode;
				if ( elm.dataset.calculatorEnterkeyhint ) input.enterKeyHint = elm.dataset.calculatorEnterkeyhint;
				if ( elm.getAttribute( 'aria-describedby' ) !== null ) input.setAttribute( 'aria-describedby', elm.getAttribute( 'aria-describedby' ) );
				if ( elm.getAttribute( 'aria-labelledby' ) !== null ) input.setAttribute( 'aria-labelledby', elm.getAttribute( 'aria-labelledby' ) );
				if ( elm.getAttribute( 'aria-label' ) !== null ) input.setAttribute( 'aria-label', elm.getAttribute( 'aria-label' ) );
				if ( type === 'number' 
type === 'text' 
type === 'radio' 
type === 'checkbox' 
type === "hidden" 
type === "range" ) {
					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 ( elm.dataset.calculatorClass ) elm.className += ' ' + elm.dataset.calculatorClass;
				if ( formula && type !== 'passthru' ) {
					// 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 's.
					elm.setAttribute( 'role', 'alert' );
				}
				if ( elm.id ===  ) {
					elm.id = id;
				}
			}
			var itemId = id.replace( /^calculator-field-/,  );
			this.itemListitemId = formulaAST;
			this.elmListitemId = elm;
		}
		this.backlinks = buildBacklinks( this.itemList );
		if ( this.parent.dataset.calculatorRefreshOnLoad && this.parent.dataset.calculatorRefreshOnLoad === 'true' ) {
			this.inProgressRefresh = {};
			this.refresh( Object.keys( this.itemList ).filter( function (a) { return !(that.itemLista instanceof Null); } ) );
			this.inProgressRefresh = undefined;
		}
		this.setupButtons()
	}
	CalculatorWidgets.prototype.setupButtons = function() {
		var calc = this;
		$( this.parent ).find( '.calculator-field-button' ).replaceWith( function () {
			var b = $(  );
			if ( this.id ) {
				b.attr( 'id', this.id );
			}
			if ( this.title ) {
				b.attr( 'title', this.title );
			}
			if ( this.style.cssText ) {
				b.attr( 'style', this.style.cssText );
			}
			if ( this.classList.contains( 'cdx-button' ) ) {
				mw.loader.load( '@wikimedia/codex' );
			}
			if ( this.className !== 'calculator-field-button' ) {
				var extraClass = this.dataset.calculatorClass === undefined ?  : ' ' + this.dataset.calculatorClass
				b.attr( 'class', this.className.replace( /(^| )calculator-field-button( |$)/g, ' ' ) + extraClass );
			}
			if ( this.dataset.calculatorAlt ) {
				b.attr( 'aria-label', this.dataset.calculatorAlt );
			}
			if ( this.dataset.calculatorDisabled ) {
				// Would be cool if this was a formula.
				b.attr( 'disabled', true );
			}
			var formula = this.dataset.calculatorFormula;
			if ( formula && formula.length > 2000 ) {
				console.log( "Skipping button with too long formula" );
				formula = undefined;
			}
			if ( typeof this.dataset.calculatorFor === 'string' && typeof formula === 'string' ) {
				var forList = this.dataset.calculatorFor.split( ';' );
				var formulaList = formula.split( ';' );
				for ( var i = 0; i = 0; i-- ) {
			var itemId = topListi;
			if ( this.inProgressRefreshitemId ) {
				console.log( "Loop Detected! Skipping " + itemId );
				continue;
			}
			if ( !this.itemListitemId 
this.itemListitemId instanceof Null ) {
				// This mostly should not happen but might if refresh on page load is set or with radio buttons.
				this.setValueProperties( this.elmListitemId, getValueOfElm( this.elmListitemId ) );
				console.log( "Tried to refresh field " + itemId + " with no formula" );
				continue;
			}
			this.inProgressRefreshitemId = true;
			var res = evaluate( this.itemListitemId, this.elmList );
			var elm = this.elmListitemId;
			this.setValue( elm, res );
		}
	};
	CalculatorWidgets.prototype.setValue = function( elm, res ) {
		this.setValueProperties( elm, res );
		if ( elm.tagName === 'INPUT' ) {
			elm.value = elm.type === "number" 
elm.type === "range" ? res : format( res, elm.dataset );
			if ( elm.type === 'radio' 
elm.type === 'checkbox' ) {
				elm.checked = !isNaN( res ) && !almostEquals( res, 0 );
			}
		} else if ( elm.dataset.calculatorType !== 'passthru' ) {
			// plain type.
			elm.textContent = format( res, elm.dataset );
		}
	}
	mw.hook( 'wikipage.content' ).add( setup );
} )();