MediaWiki:Gadget-Calculator.js: Difference between revisions
Appearance
Tags: Replaced Undo Reverted |
No edit summary Tag: Manual revert |
||
| Line 1: | Line 1: | ||
mw.loader.load( 'https:// | /** | ||
* On-Wiki calculator script. See [[Template:Calculator]]. Created by [[User:Bawolff]] | |||
* License: Creative Commons Attribution-ShareAlike 3.0 and 4.0 International Public License | |||
* Canonical source: https://mdwiki.org/wiki/MediaWiki:Gadget-calculator.js | |||
* | |||
* 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. | |||
* * Prototype pollution - We use objects with null prototypes and also reject fields named __proto__ just in case. | |||
* | |||
* 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', 'cbrt', 'ceil', 'cos', 'cosh', 'exp', 'expm1', 'floor', 'hypot', | |||
'log', 'log10', 'log2', 'log1p', 'max', 'min', 'pow', 'random', 'sign', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'trunc', 'clz32' ]; | |||
var otherFuncs = [ | |||
'ifzero', 'coalesce', 'iffinite', 'ifnan', 'ifpositive', 'ifequal', 'round', 'jsround', 'not', 'and', 'or', 'bool', 'ifless', 'iflessorequal', 'ifgreater', 'ifgreaterorequal', 'ifbetween', 'xor', | |||
'index', 'switch', 'radiogroup', 'bitand', 'bitor', 'bitxor', 'bitnot', 'bitleftshift', 'bitlogicrightshift', 'bitarithrightshift', 'if', 'getclick', 'timer', 'timeriterations', 'timertime' | |||
]; | |||
var allFuncs = mathFuncs.concat(otherFuncs); | |||
var convertFloat = function ( f ) { | |||
if ( typeof f === 'number' ) { | |||
return f; | |||
} | |||
f = f.replace( /×\s?10/, 'e' ); | |||
return parseFloat( 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, | |||
'true': /^(?:true)(?![A-z_0-9-])/, | |||
'false': /^(?:false)(?![A-z_0-9-])/, | |||
'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 + " 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 parenthesis or are using an invalid function." ); | |||
} | |||
return res; | |||
}, | |||
expression: function () { | |||
var text2, res, res2; | |||
res = this.term(); | |||
this.consume( 'WS' ); | |||
while ( this.check( "PLUSMINUS" )) { | |||
var op = this.consume( "PLUSMINUS" ); | |||
this.consume( 'WS' ); | |||
res2 = this.term(); | |||
this.consume( 'WS' ); | |||
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 = '/'; | |||
this.consume( 'WS' ); | |||
res2 = this.factor(); | |||
this.consume( 'WS' ); | |||
res = new Operator( op, [ res, res2 ] ); | |||
} | |||
return res; | |||
}, | |||
factor: function () { | |||
this.consume( 'WS' ); | |||
var res; | |||
if ( this.check( 'pi' ) ) { | |||
this.consume( 'pi' ); | |||
return new Numb( Math.PI ); | |||
} | |||
if ( this.check( 'true' ) ) { | |||
this.consume( 'true' ); | |||
return new Numb( 1 ); | |||
} | |||
if ( this.check( 'false' ) ) { | |||
this.consume( 'false' ); | |||
return new Numb( 0 ); | |||
} | |||
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( '(' ); | |||
this.consume( 'WS' ); | |||
res = this.expression(); | |||
this.consume( 'WS' ); | |||
this.consume( ')' ); | |||
return res; | |||
} | |||
// unary minus | |||
if ( this.check( '-' ) ) { | |||
this.consume( '-' ); | |||
this.consume( 'WS' ); | |||
if ( this.check( '-' ) ) { | |||
throw new Error( "Double unary minus without parenthesis not allowed near " + this.text.substring(0,15)); | |||
} | |||
res = this.factor(); | |||
return new Operator( '*', [new Numb(-1), 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 < minNormal ) { | |||
return diff < epsilon * minNormal; | |||
} | |||
return diff / Math.min((absA + absB), Number.MAX_VALUE) < epsilon; | |||
}; | |||
// elm: Element to get value of | |||
// Mapping to process value with (usually this.mappingInput[element item id]) | |||
var getValueOfElm = function (elm, mapping) { | |||
var val; | |||
if ( elm.hasAttribute( 'data-calculator-real-value' ) ) { | |||
// We have a non-formatted value set. Use that and skip any mappings. | |||
return parseFloat( elm.getAttribute( 'data-calculator-real-value' ) ); | |||
} else if ( elm.tagName === 'INPUT' || elm.tagName === 'SELECT' ) { | |||
if ( elm.type === 'radio' || elm.type === 'checkbox' ) { | |||
// Radio/checkboxes are not allowed to have mappings | |||
return elm.checked ? convertFloat( elm.value ) : 0; | |||
} | |||
val = elm.value; | |||
} else { | |||
val = elm.textContent; | |||
} | |||
if ( typeof val === 'string' ) { | |||
val = val.trim(); | |||
var mapVals = [ val, val.toLowerCase() ]; | |||
for ( var k = 0; k < mapVals.length; k++ ) { | |||
if (mapping && typeof mapping[mapVals[k]] === 'number' ) { | |||
return mapping[mapVals[k]]; | |||
} else if ( mapping && [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[mapVals[k]] ) !== -1 ) { | |||
return parseFloat( mapping[mapVals[k]] ); | |||
} | |||
} | |||
} | |||
return convertFloat( val ); | |||
}; | |||
// Evaluate the value of an AST at runtime. | |||
var evaluate = function( ast ) { | |||
var that = this; | |||
var elmList = this.elmList; | |||
var i, then, ielse, res, elm; | |||
if ( ast instanceof Numb ) { | |||
return ast.value; | |||
} | |||
if ( ast instanceof Ident ) { | |||
elm = elmList[ast.value]; | |||
if ( elm === undefined ) { | |||
console.log( "Calculator: Reference to '" + ast.value + "' but there is no field by that name" ); | |||
return NaN; | |||
} | |||
return getValueOfElm( elm, this.mappingInput[elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] ); | |||
} | |||
if ( ast instanceof Operator ) { | |||
// Special case, index() does not directly eval first arg | |||
// index() is like an array index operator (sort of) | |||
// It evaluates its second argument and concats that to the first identifier | |||
if ( 'index' === ast.op ) { | |||
if ( ast.args.length < 2 || !( ast.args[0] instanceof Ident ) ) { | |||
return NaN; | |||
} | |||
var indexValue = Math.floor(this.evaluate( ast.args[1] )); | |||
if ( !Number.isSafeInteger( indexValue ) || indexValue < 0 ) { | |||
return NaN; | |||
} | |||
res = elmList[ast.args[0].value + indexValue]; | |||
if ( res === undefined ) { | |||
return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : NaN; | |||
} | |||
return getValueOfElm( res, this.mappingInput[res.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] ); | |||
} | |||
// Get the value of the checked element of a radio group or NaN if none checked | |||
// Unlike most things, first args identifies the radio group name and not an id. | |||
if ( ast.op === 'radiogroup' ) { | |||
if ( ast.args.length < 1 || !( ast.args[0] instanceof Ident ) ) { | |||
return NaN; | |||
} | |||
var radioName = ast.args[0].value; | |||
elm = this.parent.querySelector( 'input:checked[type=radio][name=' + CSS.escape( 'calcgadget-' + this.rand + '-' + radioName ) + ']' ); | |||
if ( !elm ) { | |||
return NaN; | |||
} | |||
return this.evaluate( new Ident( elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) ) ); | |||
} | |||
if ( ast.op === 'getclick' ) { | |||
if ( ast.args.length < 1 || !( ast.args[0] instanceof Ident ) ) { | |||
return NaN; | |||
} | |||
switch( ast.args[0].value.toLowerCase() ) { | |||
case 'x': | |||
return this.clickX; | |||
case 'prevx': | |||
return this.clickPrevX; | |||
case 'prevpercentx': | |||
return this.clickPrevPercentX; | |||
case 'startpercentx': | |||
return this.clickStartPercentX; | |||
case 'startx': | |||
return this.clickStartX; | |||
case 'percentx': | |||
return this.clickPercentX; | |||
case 'y': | |||
return this.clickY; | |||
case 'percenty': | |||
return this.clickPercentY; | |||
case 'prevy': | |||
return this.clickPrevY; | |||
case 'prevpercenty': | |||
return this.clickPrevPercentY; | |||
case 'starty': | |||
return this.clickStartY; | |||
case 'startpercenty': | |||
return this.clickStartPercentY; | |||
case 'inprogress': | |||
return this.clickInProgress; | |||
case 'type': | |||
// 1 = start, 2 - move, 3 = done, 4 = cancel | |||
return this.clickType; | |||
default: | |||
return NaN; | |||
} | |||
} | |||
// Check if a specific timer is running. | |||
// timer() returns 0 or 1 if any timer | |||
// timer( timerName) checks for that sepcific timer | |||
// timer(timerName, trueValue, falseValue ) returns specific value. | |||
if ( ast.op === 'timer' ) { | |||
if (!this.repeatInfo) { | |||
return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : 0; | |||
} | |||
if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) { | |||
return ast.args.length >= 2 ? this.evaluate( ast.args[1] ) : 1; | |||
} | |||
return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : 0; | |||
} | |||
if ( ast.op === 'timeriterations' ) { | |||
if (!this.repeatInfo) { | |||
return NaN; | |||
} | |||
if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) { | |||
return this.repeatInfo.iterationsSoFar; | |||
} | |||
return NaN; | |||
} | |||
if ( ast.op === 'timertime' ) { | |||
if (!this.repeatInfo) { | |||
return NaN; | |||
} | |||
if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) { | |||
return (Date.now() - this.repeatInfo.timeStart)/1000; | |||
} | |||
return NaN; | |||
} | |||
// Start of functions that evaluate their arguments. | |||
evaledArgs = ast.args.map( function (i) { return that.evaluate( i ) } ); | |||
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 < 1 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 2 ? 1 : evaledArgs[1]; | |||
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2]; | |||
return almostEquals( evaledArgs[0], 0 ) ? then : ielse; | |||
} | |||
// Opposite of ifzero | |||
if ( 'if' === ast.op ) { | |||
if ( evaledArgs.length < 1 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 2 ? 1 : evaledArgs[1]; | |||
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2]; | |||
return !almostEquals( evaledArgs[0], 0 ) ? then : ielse; | |||
} | |||
if ( 'ifequal' === ast.op ) { | |||
if ( evaledArgs.length < 2 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 3 ? 1 : evaledArgs[2]; | |||
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3]; | |||
return almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse; | |||
} | |||
if ( 'iffinite' === ast.op ) { | |||
if ( evaledArgs.length < 1 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 2 ? 1 : evaledArgs[1]; | |||
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2]; | |||
return isFinite( evaledArgs[0] ) ? then : ielse; | |||
} | |||
if ( 'ifnan' === ast.op ) { | |||
if ( evaledArgs.length < 1 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 2 ? 1 : evaledArgs[1]; | |||
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2]; | |||
return isNaN( evaledArgs[0] ) ? then : ielse; | |||
} | |||
if ( 'ifpositive' === ast.op ) { | |||
if ( evaledArgs.length < 1 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 2 ? 1 : evaledArgs[1]; | |||
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2]; | |||
return evaledArgs[0] >= 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 < 2 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 3 ? 1 : evaledArgs[2]; | |||
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3]; | |||
return evaledArgs[0] < evaledArgs[1] && !almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse; | |||
} | |||
if ( 'ifgreater' === ast.op ) { | |||
if ( evaledArgs.length < 2 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 3 ? 1 : evaledArgs[2]; | |||
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3]; | |||
return evaledArgs[0] > evaledArgs[1] && !almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse; | |||
} | |||
if ( 'iflessorequal' === ast.op ) { | |||
if ( evaledArgs.length < 2 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 3 ? 1 : evaledArgs[2]; | |||
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3]; | |||
return evaledArgs[0] <= evaledArgs[1] || almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse; | |||
} | |||
if ( 'ifgreaterorequal' === ast.op ) { | |||
if ( evaledArgs.length < 2 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 3 ? 1 : evaledArgs[2]; | |||
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3]; | |||
return evaledArgs[0] >= evaledArgs[1] || almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse; | |||
} | |||
// Should this use almostEquals??? | |||
if ( 'ifbetween' === ast.op ) { | |||
if ( evaledArgs.length < 3 ) { | |||
return NaN; | |||
} | |||
then = evaledArgs.length < 4 ? 1 : evaledArgs[3]; | |||
ielse = evaledArgs.length < 5 ? 0 : evaledArgs[4]; | |||
return evaledArgs[0] >= evaledArgs[1] && evaledArgs[0] <= evaledArgs[2] ? then : ielse; | |||
} | |||
if ( 'bool' === ast.op ) { | |||
if ( evaledArgs.length !== 1 ) { | |||
return NaN; | |||
} | |||
return isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ? 0 : 1; | |||
} | |||
if ( 'not' === ast.op ) { | |||
if ( evaledArgs.length !== 1 ) { | |||
return NaN; | |||
} | |||
return isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ? 1 : 0; | |||
} | |||
if ( 'xor' === ast.op ) { | |||
if ( evaledArgs.length !== 2 ) { | |||
return NaN; | |||
} | |||
if ( | |||
( ( isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ) && !( isNaN( evaledArgs[1] ) || almostEquals( evaledArgs[1], 0.0 ) ) ) || | |||
( ( isNaN( evaledArgs[1] ) || almostEquals( evaledArgs[1], 0.0 ) ) && !( isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ) ) | |||
) { | |||
return 1; | |||
} else { | |||
return 0; | |||
} | |||
} | |||
// Short circuit like in lua | |||
if ( 'and' === ast.op ) { | |||
for ( i = 0; i < evaledArgs.length; i++ ) { | |||
if ( isNaN( evaledArgs[i] ) || almostEquals( evaledArgs[i], 0.0 ) ) { | |||
return evaledArgs[i]; | |||
} | |||
} | |||
return evaledArgs.length >= 1 ? evaledArgs[evaledArgs.length-1] : 1; | |||
} | |||
if ( 'or' === ast.op ) { | |||
for ( i = 0; i < evaledArgs.length; i++ ) { | |||
if ( !isNaN( evaledArgs[i] ) && !almostEquals( evaledArgs[i], 0.0 ) ) { | |||
return evaledArgs[i]; | |||
} | |||
} | |||
return evaledArgs.length >= 1 ? evaledArgs[evaledArgs.length-1] : 0; | |||
} | |||
// Bitwise operators (numbers treated like 32bit integer). The binary ones can take any number of args. | |||
if ( 'bitand' === ast.op ) { | |||
if ( evaledArgs.length < 1 ) { | |||
return NaN; | |||
} | |||
res = evaledArgs[0]; | |||
for ( i = 1; i < evaledArgs.length; i++ ) { | |||
res = res & evaledArgs[i]; | |||
} | |||
return res; | |||
} | |||
if ( 'bitor' === ast.op ) { | |||
if ( evaledArgs.length < 1 ) { | |||
return NaN; | |||
} | |||
res = evaledArgs[0]; | |||
for ( i = 1; i < evaledArgs.length; i++ ) { | |||
res = res | evaledArgs[i]; | |||
} | |||
return res; | |||
} | |||
if ( 'bitxor' === ast.op ) { | |||
if ( evaledArgs.length < 1 ) { | |||
return NaN; | |||
} | |||
res = evaledArgs[0]; | |||
for ( i = 1; i < evaledArgs.length; i++ ) { | |||
res = res ^ evaledArgs[i]; | |||
} | |||
return res; | |||
} | |||
// Bitwise operators that don't take infinite amount of ops. | |||
if ( 'bitnot' === ast.op ) { | |||
if ( evaledArgs.length < 1 ) { | |||
return NaN; | |||
} | |||
return ~evaledArgs[0]; | |||
} | |||
if ( 'bitleftshift' === ast.op ) { | |||
if ( evaledArgs.length < 2 ) { | |||
return NaN; | |||
} | |||
return evaledArgs[0] << evaledArgs[1]; | |||
} | |||
if ( 'bitlogicrightshift' === ast.op ) { | |||
if ( evaledArgs.length < 2 ) { | |||
return NaN; | |||
} | |||
return evaledArgs[0] >>> evaledArgs[1]; | |||
} | |||
if ( 'bitarithrightshift' === ast.op ) { | |||
if ( evaledArgs.length < 2 ) { | |||
return NaN; | |||
} | |||
return evaledArgs[0] >> evaledArgs[1]; | |||
} | |||
// switch(value,2,valueFor2,5,valueFor5,...,valueIfNoneMatch) | |||
// select the arg where the test <= value. | |||
if ( 'switch' === ast.op ) { | |||
if ( evaledArgs.length < 2 ) { | |||
return NaN; | |||
} | |||
var defaultVal = NaN; | |||
if ( evaledArgs.length % 2 === 0 ) { | |||
// Last arg is default if even number of args. | |||
defaultVal = evaledArgs[evaledArgs.length-1]; | |||
} | |||
for ( i = 1; i < evaledArgs.length-1; i+=2 ) { | |||
if ( evaledArgs[0] <= evaledArgs[i] || almostEquals( evaledArgs[i], evaledArgs[0] ) ) { | |||
return evaledArgs[i+1]; | |||
} | |||
} | |||
return defaultVal; | |||
} | |||
// 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[1] : 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([]); | |||
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.args[0] ).forEach( function (x) { res.add(x + '*') } ); | |||
} else if ( (tree.op === 'radiogroup' ) && tree.args.length > 0 && tree.args[0] instanceof Ident ) { | |||
i++; | |||
res.add( '--radio-' + tree.args[0].value ); | |||
} else if ( (tree.op === 'timer' ) ) { | |||
if ( tree.args.length > 0 && tree.args[0] instanceof Ident ) { | |||
i++; | |||
res.add( '--timer-active-' + tree.args[0].value ); | |||
} else { | |||
res.add( '--timer-active' ); | |||
} | |||
} else if ( tree.op === 'timertime' || tree.op === 'timeriterations' ) { | |||
res.add( '--timer-iteration' ); | |||
if ( tree.args.length > 0 && tree.args[0] instanceof Ident ) { | |||
res.add( '--timer-iteration-' + tree.args[0].value ); | |||
} else { | |||
res.add( '--timer-iteration' ); | |||
} | |||
return res; | |||
} else if ( tree.op === 'getclick' ) { | |||
res.add( '--click' ); | |||
return res; | |||
} | |||
for ( ; i < tree.args.length; i++ ) { | |||
getIdentifiers( tree.args[i] ).forEach( function (x) { res.add(x) } ); | |||
} | |||
return res; | |||
} | |||
return new Set(); | |||
}; | |||
// e.g. if a=foo+1 then backlinks['foo'] = ['a',...] | |||
var buildBacklinks = function (items, radioGroups) { | |||
var backlinks = Object.create(null); | |||
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 ); | |||
} ); | |||
} | |||
// We use identifiers starting with -- as special purpose | |||
// Each radio item should depend on each other, since if one gets checked the others get unchecked. | |||
var identPrefix = '--radio-'; | |||
for ( var groupName in radioGroups ) { | |||
if ( backlinks[identPrefix + groupName] == undefined ) { | |||
backlinks[identPrefix + groupName] = []; | |||
} | |||
for ( var i = 0; i < radioGroups[groupName].length; i++ ) { | |||
backlinks[identPrefix + groupName].push( radioGroups[groupName][i] ); | |||
if ( backlinks[radioGroups[groupName][i]] == undefined ) { | |||
backlinks[radioGroups[groupName][i]] = []; | |||
} | |||
backlinks[radioGroups[groupName][i]].push( identPrefix + groupName ); | |||
} | |||
} | |||
return backlinks; | |||
}; | |||
// End dependency graph code | |||
// Start code that does setup and HTML interaction. | |||
var setup = function ( $content ) { | |||
// We allow users to group calculator widgets inside a <div class="calculator-container"> 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 <fieldset>?). | |||
var containers = Array.from( $content.find( '.calculator-container' ) ); | |||
for ( var i = 0; i < containers.length; i++ ) { | |||
var calc = new CalculatorWidgets( Array.from( containers[i].getElementsByClassName( 'calculator-field' ) ), containers[i] ); | |||
} | |||
// Anything not in a container is scoped to the page. | |||
var elms = Array.from( $content.find( '.calculator-field' ) ); | |||
new CalculatorWidgets( elms, $content[0] ); | |||
$content.find( '.calculator-field-label').replaceWith( function() { | |||
var l = $( '<label>' ); | |||
// 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( /^(?:calcdisambig-\d+-)?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 () { | |||
if ( window.calculatorStatsAlreadyDone !== true ) { | |||
window.calculatorStatsAlreadyDone = true; | |||
mw.track( 'stats.mediawiki_gadget_calculator_total' ); | |||
mw.track( 'stats.mediawiki_gadget_calculator_wiki_total', 1, { wiki: mw.config.get( 'wgDBname' ) } ); | |||
var pageName = encodeURIComponent( mw.config.get( 'wgTitle' ).replace( / /g, '_' ) ); | |||
// alphanumeric and underscore only. | |||
pageName = pageName.replace( /[^a-zA-Z0-9_]/g, '_' ); | |||
mw.track( 'stats.mediawiki_gadget_calculator_page_total', 1, { wiki: mw.config.get( 'wgDBname' ), page: pageName, NS: mw.config.get( 'wgNamespaceNumber' ) } ); | |||
} | |||
}; | |||
var createSelect = function( elm ) { | |||
var sel = document.createElement( 'select' ); | |||
var mapping; | |||
try { | |||
mapping = JSON.parse( elm.dataset.calculatorMapping ); | |||
} catch(e) { | |||
console.log( "Calculator: Error processing mapping of " + elm.id + ". " + e.message ); | |||
return sel; | |||
} | |||
if ( typeof mapping !== 'object' ) { | |||
console.log( "Calculator: Error processing mapping of " + elm.id + ". unexpected type" ); | |||
return sel; | |||
} | |||
var opt; | |||
for ( var i in mapping ) { | |||
if ( typeof mapping[i] === 'object' ) { | |||
var optgroup = document.createElement( 'optgroup' ); | |||
optgroup.label = i; | |||
sel.appendChild( optgroup ); | |||
// Note: optgroup cannot be nested. | |||
for ( var j in mapping[i] ) { | |||
if ( typeof mapping[i][j] === 'number' || [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i][j] ) !== -1 ) { | |||
opt = document.createElement( 'option' ); | |||
opt.value = mapping[i][j]; | |||
opt.appendChild( document.createTextNode( j ) ); | |||
optgroup.appendChild( opt ); | |||
} | |||
} | |||
} else if ( typeof mapping[i] === 'number' || [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i] ) !== -1 ) { | |||
opt = document.createElement( 'option' ); | |||
opt.value = mapping[i]; | |||
opt.appendChild( document.createTextNode( i ) ); | |||
sel.appendChild( opt ); | |||
} | |||
} | |||
return sel; | |||
}; | |||
var CalculatorWidgets = function( elms, parent ) { | |||
this.parent = parent; | |||
this.rand = Math.floor(Math.random()*1000000000); | |||
this.itemList = Object.create(null); | |||
this.elmList = Object.create(null); | |||
this.backlinks = Object.create(null); | |||
this.inProgressRefresh = undefined; | |||
this.clickX = this.clickY = this.clickPercentX = this.clickPercentY = NaN; | |||
this.clickPrevX = this.clickPrevY = this.clickPrevPercentX = this.clickPrevPercentY = NaN; | |||
this.clickStartX = this.clickStartY = this.clickStartPercentX = this.clickStartPercentY = NaN; | |||
this.clickType = NaN; | |||
this.clickInProgress = 0; | |||
this.repeatInfo = null; | |||
var that = this; | |||
var radioGroups = Object.create(null); | |||
this.buttonInfo = Object.create(null); | |||
this.mappingInput = Object.create(null); | |||
this.mappingOutput = Object.create(null); | |||
this.repeatInfo = null; | |||
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.toString(); | |||
if ( id.match( /^(?:calcdisambig-\d+-)?calculator-field-__proto__$/ ) || elm.dataset.calculatorName === '__proto__' ) { | |||
// be paranoid | |||
throw new Error( "Invalid calculator id: " + id ); | |||
} | |||
if ( !id ) { | |||
id = 'calculator-field-auto' + Math.floor( Math.random()*10000000 ) + 'unnamed'; | |||
} | |||
if ( this.parent.classList.contains( 'calculator-container' ) && document.getElementById( id ) !== elm ) { | |||
// There is a duplicate id, and we are not at the top level. | |||
// This is not ideal, as you can't reference it using other attributes (e.g. aria-labelledby, aria-describedby) or link targets, but at least we won't label the wrong thing. | |||
var labelsToUpdate = this.parent.querySelectorAll( ":scope .calculator-field-label[data-for=\"" + CSS.escape( id ) + "\"]" ); | |||
id = 'calcdisambig-' + this.rand + '-' + id; | |||
for ( var l = 0; l < labelsToUpdate.length; l++ ) { | |||
labelsToUpdate[l].dataset.for = id; | |||
} | |||
} | |||
var defaultVal = ("" + elm.textContent).trim(); | |||
if ( type === undefined || !id.match( /^(?:calcdisambig-\d+-)?calculator-field-[a-zA-Z_][a-zA-Z0-9_]*$/ ) ) { | |||
console.log( "Skipping " + id ); | |||
continue; | |||
} | |||
var formulaAST; | |||
try { | |||
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-/ ) || ( elm.dataset.calculatorClass && elm.dataset.calculatorClass.match( /(^| )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 = type === 'select' ? createSelect( elm ) : 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 = convertFloat(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; | |||
if ( type === 'radio' ) { | |||
// 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 ( radioGroups[elm.dataset.calculatorName] === undefined ) { | |||
radioGroups[elm.dataset.calculatorName] = []; | |||
} | |||
radioGroups[elm.dataset.calculatorName][radioGroups[elm.dataset.calculatorName].length] = id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ); | |||
} | |||
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 ( elm.getAttribute( 'aria-owns' ) !== null ) input.setAttribute( 'aria-owns', elm.getAttribute( 'aria-owns' ) ); | |||
if ( elm.getAttribute( 'aria-flowto' ) !== null ) input.setAttribute( 'aria-flowto', elm.getAttribute( 'aria-flowto' ) ); | |||
if ( elm.getAttribute( 'role' ) !== null ) input.setAttribute( 'role', elm.getAttribute( 'role' ) ); | |||
// To support only setting the role if gadget is enabled | |||
if ( elm.dataset.calculatorAriaRole ) input.setAttribute( 'role', elm.dataset.calculatorAriaRole ); | |||
if ( ['true', 'false'].indexOf( elm.dataset.calculatorAriaAtomic ) !== -1 ) input.setAttribute( 'aria-atomic', elm.dataset.calculatorAriaAtomic ); | |||
if ( elm.dataset.calculatorAriaRelevant ) input.setAttribute( 'aria-relevant', elm.dataset.calculatorAriaRelevant ); | |||
if ( ['off', 'polite', 'assertive'].indexOf( elm.dataset.calculatorAriaLive ) !== -1 ) { | |||
input.setAttribute( 'aria-live', elm.dataset.calculatorAriaLive ); | |||
} else { | |||
// We treat invalid values as set nothing, which inherits from parent which is different than setting off. This allows user to override | |||
// We also set a default of polite for cells with a formula | |||
if ( formula ) { | |||
input.setAttribute( 'aria-live', 'polite' ); | |||
} | |||
} | |||
if ( type === 'number' || type === 'text' || type === 'radio' || type === 'checkbox' || type === "hidden" || type === "range" ) { | |||
input.type = type; | |||
} | |||
if ( type === 'select' ) { | |||
if ( elm.dataset.calculatorValue ) { | |||
// Allow people to override the default value for select with attribute, as the value might not be a good non-js fallback. | |||
input.value = elm.dataset.calculatorValue; | |||
} | |||
} | |||
if ( type === 'radio' || type === 'checkbox' ) { | |||
input.onchange = this.changeHandler.bind(this); // some browsers dont fire oninput for checkboxes/radio | |||
if ( elm.dataset.calculatorChecked === 'true' || elm.dataset.calculatorChecked === '1' ) { | |||
input.checked = true; | |||
} else if ( elm.dataset.calculatorChecked === 'false' || elm.dataset.calculatorChecked === '0' ) { | |||
input.checked = false; | |||
} else if ( !isNaN( defaultVal ) && !almostEquals( defaultVal, 0 ) ) { | |||
// If all else fails, use the default value to decide if it is checked | |||
input.checked = true; | |||
} | |||
// Allow user to set a different checked value other than 1. This will be the value when checked by human, but not neccesarily if by formula | |||
// We use a specific data-calculator-value if set and true, otherwise we use the defaultVal (text firstChild) if true, and 1 as a last resort. | |||
var checkValue = convertFloat( elm.dataset.calculatorValue || '' ); | |||
if ( !isNaN( checkValue ) && !almostEquals( checkValue, 0 ) ) { | |||
input.value = checkValue; | |||
input.dataset.calculatorValue = checkValue; | |||
} else if ( !isNaN( defaultVal ) && !almostEquals( defaultVal, 0 ) ) { | |||
input.value = defaultVal; | |||
input.dataset.calculatorValue = defaultVal; | |||
} else { | |||
input.value = 1; | |||
input.dataset.calculatorValue = 1; | |||
} | |||
} | |||
if ( elm.dataset.calculatorMapping && type === 'text') { | |||
this.processMapping( elm.dataset.calculatorMapping, id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) ); | |||
} | |||
input.id = id; | |||
elm.replaceWith( input ); | |||
elm = input; | |||
input.addEventListener( 'input', 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 ( ['off', 'polite', 'assertive' ].indexOf( elm.dataset.calculatorAriaLive ) !== -1 ) { | |||
elm.setAttribute( 'aria-live', elm.dataset.calculatorAriaLive ); | |||
} else if ( formula && type !== 'passthru' && !elm.dataset.calculatorAriaLive ) { | |||
// Announce plain outputs by default, but allow user to override. Treat invalid value as do not set, since not setting is slightly different then the value off. | |||
elm.setAttribute( 'aria-live', 'polite' ); | |||
} | |||
if ( ['true', 'false'].indexOf( elm.dataset.calculatorAriaAtomic ) !== -1 ) elm.setAttribute( 'aria-atomic', elm.dataset.calculatorAriaAtomic ); | |||
// Support either directly setting role or using data attribute, in case people only want to set the role in the JS case. | |||
if ( elm.dataset.calculatorAriaRole ) elm.setAttribute( 'role', elm.dataset.calculatorAriaRole ); | |||
if ( elm.dataset.calculatorAriaRelevant ) elm.setAttribute( 'aria-relevant', elm.dataset.calculatorAriaRelevant ); | |||
if ( elm.id === '' ) { | |||
elm.id = id; | |||
} | |||
if ( elm.dataset.calculatorMapping && type === 'plain') { | |||
this.processMapping( elm.dataset.calculatorMapping, id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) ); | |||
} | |||
} | |||
var itemId = id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ); | |||
this.itemList[itemId] = formulaAST; | |||
this.elmList[itemId] = elm; | |||
} | |||
this.backlinks = buildBacklinks( this.itemList, radioGroups ); | |||
if ( this.parent.dataset.calculatorRefreshOnLoad && this.parent.dataset.calculatorRefreshOnLoad === 'true' ) { | |||
this.inProgressRefresh = Object.create(null); | |||
var unsetBusy = this.setBusy(); | |||
this.refresh( Object.keys( this.itemList ).filter( function (a) { return !(that.itemList[a] instanceof Null); } ) ); | |||
this.inProgressRefresh = undefined; | |||
unsetBusy(); | |||
} | |||
this.setupButtons(); | |||
}; | |||
// MappingText is a json blob of {"some string": number} | |||
// Used to map between numbers <-> strings during input/output. | |||
CalculatorWidgets.prototype.processMapping = function ( mappingText, itemId ) { | |||
var mapping; | |||
try { | |||
mapping = JSON.parse( mappingText ); | |||
} catch(e) { | |||
console.log( "Calculator: Error processing mapping of " + itemId + ". " + e.message ); | |||
return; | |||
} | |||
if ( typeof mapping !== 'object' ) { | |||
console.log( "Calculator: Error processing mapping of " + itemId + ". unexpected type" ); | |||
return; | |||
} | |||
this.mappingInput[itemId] = mapping; | |||
var mapOut = Object.create(null); | |||
for (var i in mapping) { | |||
if ( typeof mapping[i] === 'number' || [ 'default', 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i] ) !== -1 ) { | |||
mapOut[mapping[i]] = i; | |||
} | |||
} | |||
this.mappingOutput[itemId] = mapOut; | |||
}; | |||
CalculatorWidgets.prototype.stopRepeat = function () { | |||
if ( this.repeatInfo ) { | |||
if ( this.repeatInfo.cancelIdentifier ) { | |||
// If until param is immediately true, the interval may not have been set yet. | |||
window.clearInterval( this.repeatInfo.cancelIdentifier ); | |||
} | |||
var id = this.repeatInfo.id; | |||
this.repeatInfo = null; | |||
this.inProgressRefresh = Object.create(null); | |||
var unsetBusy = this.setBusy(); | |||
this.refresh( [ '--timer-active', '--timer-active-' + id, '--timer-iteration', '--timer-iteration-' + id ] ); | |||
this.inProgressRefresh = undefined; | |||
unsetBusy(); | |||
} | |||
this.repeatInfo = null; | |||
}; | |||
CalculatorWidgets.prototype.startRepeat = function (maxIterations, id) { | |||
if ( this.repeatInfo ) { | |||
console.log( "Calculator: Trying to start a repeat when one is already in progress. This should not happen" ); | |||
} | |||
this.repeatInfo = { | |||
maxIterations: maxIterations, | |||
iterationsSoFar: 0, | |||
cancelIdentifier: null, | |||
timeStart: Date.now(), | |||
id: id | |||
}; | |||
// Not 100% clear if we should immediately refresh or wait for after first iteration. | |||
if ( this.inProgressRefresh ) { | |||
console.log( "Calculator: Refresh already in progress. This probably indicates a bug" ); | |||
} else { | |||
this.inProgressRefresh = Object.create(null); | |||
} | |||
var unsetBusy = this.setBusy(); | |||
this.refresh( [ '--timer-active', '--timer-active-' + id, '--timer-iteration', '--timer-iteration-' + id ] ); | |||
this.inProgressRefresh = undefined; | |||
unsetBusy(); | |||
}; | |||
CalculatorWidgets.prototype.incrementRepeatIteration = function (until) { | |||
this.repeatInfo.iterationsSoFar++; | |||
if ( this.repeatInfo.iterationsSoFar > this.repeatInfo.maxIterations ) { | |||
this.stopRepeat(); | |||
return false; | |||
} | |||
if ( until ) { | |||
var untilRes = this.evaluate( until ); | |||
if ( !isNaN( untilRes ) && !almostEquals( untilRes, 0 ) ) { | |||
this.stopRepeat(); | |||
return false; | |||
} | |||
} | |||
// Update fields that depend on the iteration number | |||
var id = this.repeatInfo.id; | |||
if ( this.inProgressRefresh ) { | |||
console.log( "Calculator: Refresh already in progress. This probably indicates a bug" ); | |||
} else { | |||
this.inProgressRefresh = Object.create(null); | |||
} | |||
var unsetBusy = this.setBusy(); | |||
this.refresh( [ '--timer-iteration', '--timer-iteration-' + id ] ); | |||
this.inProgressRefresh = undefined; | |||
unsetBusy(); | |||
return true; | |||
}; | |||
var convertEventTypeToNumber = function ( type ) { | |||
switch( type ) { | |||
case 'pointerdown': | |||
return 1; | |||
case 'pointermove': | |||
return 2; | |||
case 'pointerup': | |||
case 'click': | |||
return 3; | |||
case 'pointerleave': | |||
return 4; | |||
default: | |||
throw new Error( "Unrecognized event type: " + type ); | |||
} | |||
}; | |||
var updateClick = function(calc) { | |||
if ( calc.inProgressRefresh ) { | |||
console.log( "Calculator: Refresh already in progress. This probably indicates a bug" ); | |||
} else { | |||
calc.inProgressRefresh = Object.create(null); | |||
} | |||
var unsetBusy = calc.setBusy(); | |||
calc.refresh( [ '--click' ] ); | |||
calc.inProgressRefresh = undefined; | |||
unsetBusy(); | |||
}; | |||
// Handler for click events on button. | |||
var makeButtonCallback = function ( calc, forElms, formulaASTs, repeat, maxIterations, toggle, until ) { | |||
return function buttonCallback (event) { | |||
if ( event.type !== 'click' ) { | |||
if ( !event.isPrimary || ( !calc.clickInProgress && event.type !== 'pointerdown' ) ) { | |||
return; // Skip secondary pointers. | |||
} | |||
} | |||
event.preventDefault(); | |||
doStats(); | |||
var unsetBusy = calc.setBusy(); // stopping previous repeats might have side effects so set busy. | |||
if ( toggle && calc.repeatInfo && calc.repeatInfo.id === event.currentTarget.id ) { | |||
// If we click a toggle button a second time, all we do is stop the repeat. | |||
calc.stopRepeat(); | |||
unsetBusy(); | |||
return; | |||
} | |||
if ( maxIterations !== undefined || repeat !== undefined ) { | |||
// Only one repeating callback allowed at a time. | |||
calc.stopRepeat(); | |||
if ( maxIterations > 1 ) { | |||
// Start a new repeat | |||
calc.startRepeat( maxIterations, event.currentTarget.id ); | |||
} | |||
} | |||
var doStuff = function(event) { | |||
var unsetBusyInner = calc.setBusy(); | |||
var stillGoing = true; | |||
if ( calc.repeatInfo ) { | |||
stillGoing = calc.incrementRepeatIteration(until); | |||
} | |||
if ( stillGoing ) { | |||
for ( var i = 0; i < forElms.length; i++ ) { | |||
if ( !formulaASTs[i] || !forElms[i] ) { | |||
console.log( "Skipping button update due to invalid formula or for attribute" ); | |||
continue; | |||
} | |||
var res = calc.evaluate( formulaASTs[i] ); | |||
calc.setValue( forElms[i], res ); | |||
forElms[i].dispatchEvent( new InputEvent( 'input' ) ); | |||
} | |||
} | |||
unsetBusyInner(); | |||
}; | |||
if ( maxIterations === undefined || maxIterations >= 1 ) { | |||
// click coordinates can be negative or > 100% if user clicked border | |||
if ( calc.clickInProgress ) { | |||
calc.clickPrevX = calc.clickX; | |||
calc.clickPrevY = calc.clickY; | |||
calc.clickPrevPercentX = calc.clickPercentX; | |||
calc.clickPrevPercentY = calc.clickPercentY; | |||
} | |||
if ( event.type === 'pointerdown' ) { | |||
// This ensures better consistency between mouse and touchscreen. | |||
event.currentTarget.setPointerCapture( event.pointerId ); | |||
} | |||
if ( event.type !== 'pointerleave' ) { | |||
// The coordinates returned for pointerleave events are inconsistent. | |||
// Especially in firefox when a context menu is opened from right click. | |||
calc.clickX = event.offsetX; | |||
calc.clickY = event.offsetY; | |||
calc.clickPercentX = 100*event.offsetX/event.currentTarget.clientWidth; | |||
calc.clickPercentY = 100*event.offsetY/event.currentTarget.clientHeight; | |||
calc.clickInProgress = 1; | |||
} | |||
calc.clickType = convertEventTypeToNumber( event.type ); | |||
if ( event.type === 'click' || event.type === 'pointerdown' ) { | |||
calc.clickPrevX = calc.clickStartX = calc.clickX; | |||
calc.clickPrevY = calc.clickStartY = calc.clickY; | |||
calc.clickPrevPercentX = calc.clickStartPercentX = calc.clickPercentX; | |||
calc.clickPrevPercentY = calc.clickStartPercentY = calc.clickPercentY; | |||
} | |||
updateClick(calc); | |||
doStuff(); | |||
// We are done with the click events. | |||
if ( calc.clickType >= 3 ) { | |||
calc.clickX = calc.clickY = calc.clickPercentX = calc.clickPercentY = NaN; | |||
calc.clickPrevX = calc.clickPrevY = calc.clickPrevPercentX = calc.clickPrevPercentY = NaN; | |||
calc.clickStartX = calc.clickStartY = calc.clickStartPercentX = calc.clickStartPercentY = NaN; | |||
calc.clickInProgress = 0; | |||
calc.clickType = NaN; | |||
updateClick(calc); | |||
} | |||
} | |||
unsetBusy(); | |||
// repeatInfo will be null if until formula evaluates to true on first iteration | |||
if ( calc.repeatInfo && maxIterations > 1 && repeat >= 500 ) { | |||
calc.repeatInfo.cancelIdentifier = window.setInterval( doStuff, repeat ); | |||
} | |||
}; | |||
}; | |||
var connectButtonToFormula = function ( calc, $button, forAttr, formulaAttr, repeatDuration, maxIterations, toggle, untilAttr, draggable ) { | |||
// The intended use case for repeats is something like a chess board that automatically plays the next move. | |||
// We don't want it to be used for animation or otherwise harm performance, so we limit to once per 500ms and | |||
// only allow one repeat per container to be active at a time. | |||
if ( repeatDuration !== undefined ) { | |||
repeatDuration = Math.max( 500, Math.ceil(parseFloat( repeatDuration )*1000 ) ); | |||
if ( isNaN( repeatDuration ) ) { | |||
repeatDuration = undefined; | |||
} | |||
} | |||
if ( toggle === 'false' ) { | |||
toggle = false; | |||
} | |||
// If neither repeatDuration or maxIterations is set then the button is normal and does not repeat but does not cancel | |||
// if repeatDuration is set and no maxIterations, then maxIterations is infinite | |||
// If maxIterations is 0, then this cancels the current repeat without doing anything | |||
// If maxIterations is 1, then cancel but do normal button op (but don't repeat) | |||
if ( maxIterations !== undefined ) { | |||
maxIterations = parseInt( maxIterations ); | |||
} else if( repeatDuration ) { | |||
maxIterations = Infinity; | |||
} | |||
if ( typeof forAttr !== 'string' || typeof formulaAttr !== 'string' ) { | |||
// Nothing to do. Skip. | |||
return; | |||
} | |||
if ( formulaAttr.length > 2000 || (untilAttr && untilAttr.length > 2000 ) ) { | |||
console.log( "Skipping button with too long formula" ); | |||
return; | |||
} | |||
var until = null; | |||
try { | |||
until = (new Parser( untilAttr )).parse(); | |||
} catch( e ) { | |||
console.log( "Calculator: Error parsing until formula of button " + $button[0].id + ": " + e.message ); | |||
} | |||
var forList = forAttr.split( ';' ); | |||
var formulaList = formulaAttr.split( ';' ); | |||
var forElms = []; | |||
var formulaASTs = []; | |||
for ( var i = 0; i < forList.length; i++ ) { | |||
if (formulaList[i] === undefined) { | |||
break; | |||
} | |||
forElms[i] = calc.elmList[forList[i]]; | |||
try { | |||
formulaASTs[i] = (new Parser( formulaList[i] )).parse(); | |||
} catch( e ) { | |||
formulaASTs[i] = null; | |||
console.log( "Calculator: Error parsing formula of button " + $button[0].id + " for " + forList[i] + ": " + e.message ); | |||
} | |||
} | |||
if ( draggable ) { | |||
$button[0].addEventListener( 'pointerdown', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) ); | |||
$button[0].addEventListener( 'pointerup', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) ); | |||
$button[0].addEventListener( 'pointerleave', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) ); | |||
$button[0].addEventListener( 'pointermove', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) ); | |||
} else { | |||
$button[0].addEventListener( 'click', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) ); | |||
} | |||
}; | |||
CalculatorWidgets.prototype.connectButtonDisable = function ( button, formula ) { | |||
if ( formula === '' || formula === '0' || formula === 'false' ) { | |||
return; | |||
} | |||
if ( formula.length > 2000 ) { | |||
console.log( "Calculator: Disable formula for button " + button.id + " is too long" ); | |||
return; | |||
} | |||
var formulaAST; | |||
try { | |||
formulaAST = (new Parser( formula )).parse(); | |||
} catch( e ) { | |||
console.log( "Error parsing disable formula of button " + button.id + ": " + e.message ); | |||
return; | |||
} | |||
this.buttonInfo[button.id] = { | |||
elm: button, | |||
formula: formulaAST, | |||
}; | |||
// Add disabled formula's dependencies to the dependency tree. | |||
var idents = getIdentifiers( formulaAST ); | |||
var that = this; | |||
var item = '--button-disable-' + button.id; | |||
idents.forEach( function (ident) { | |||
if ( !that.backlinks[ident] ) { | |||
that.backlinks[ident] = []; | |||
} | |||
that.backlinks[ident].push( item ); | |||
} ); | |||
// Now set the initial value. | |||
var curValue = this.evaluate( formulaAST ); | |||
if ( !isNaN( curValue ) && !almostEquals( curValue, 0 ) ) { | |||
button.disabled = true; | |||
} | |||
}; | |||
CalculatorWidgets.prototype.setupButtons = function() { | |||
var calc = this; | |||
$( this.parent ).find( '.calculator-field-button' ).replaceWith( function () { | |||
var b = $( '<button type="button">' ); | |||
if ( this.id && this.id !== '__proto__' ) { | |||
b.attr( 'id', this.id ); | |||
} else { | |||
b.attr( 'id', 'calculator-button-' + Math.ceil(Math.random()*100000000000) ); | |||
} | |||
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.calculatorAriaLive ) { | |||
b.attr( 'aria-live', this.dataset.calculatorAriaLive ); | |||
} | |||
if ( this.getAttribute( 'role' ) ) { | |||
b.attr( 'role', this.getAttribute( 'role' ) ); | |||
} | |||
if ( this.getAttribute( 'aria-owns' ) ) { | |||
b.attr( 'aria-owns', this.getAttribute( 'aria-owns' ) ); | |||
} | |||
if ( this.getAttribute( 'aria-flowto' ) ) { | |||
b.attr( 'aria-flowto', this.getAttribute( 'aria-flowto' ) ); | |||
} | |||
if ( this.dataset.calculatorDisabled !== undefined ) { | |||
// TODO: Would be cool if this was a formula. | |||
if ( this.dataset.calculatorDisabled === 'true' || this.dataset.calculatorDisabled === '1' ) { | |||
// Simple case | |||
b.attr( 'disabled', true ); | |||
} else { | |||
calc.connectButtonDisable( b[0], this.dataset.calculatorDisabled ); | |||
} | |||
} | |||
// This will be a no-op if no formula or for specified. | |||
connectButtonToFormula( | |||
calc, | |||
b, | |||
this.dataset.calculatorFor, | |||
this.dataset.calculatorFormula, | |||
this.dataset.calculatorDelay, | |||
this.dataset.calculatorMaxIterations, | |||
this.dataset.calculatorToggle, | |||
this.dataset.calculatorUntil | |||
); | |||
b.append( this.childNodes ); | |||
return b; | |||
} ); | |||
// This is meant to allow image map type things. | |||
$( this.parent ).find( '.calculator-field-buttonraw' ).each( function () { | |||
if (!this.id || this.id === '__proto__') { | |||
this.id = 'calculator-button-' + Math.ceil(Math.random()*100000000000); | |||
} | |||
// Make sure we do not process twice. | |||
this.classList.remove( 'calculator-field-buttonraw' ); | |||
connectButtonToFormula( | |||
calc, | |||
$(this), | |||
this.dataset.calculatorFor, | |||
this.dataset.calculatorFormula, | |||
this.dataset.calculatorDelay, | |||
this.dataset.calculatorMaxIterations, | |||
this.dataset.calculatorToggle, | |||
this.dataset.calculatorUntil, | |||
this.classList.contains( 'calculator-field-buttondraggable' ) | |||
); | |||
} ); | |||
}; | |||
CalculatorWidgets.prototype.changeHandler = function(e) { | |||
this.inProgressRefresh = Object.create(null); | |||
var unsetBusy = this.setBusy(); | |||
doStats(); | |||
e.target.removeAttribute( 'data-calculator-real-value' ); | |||
var itemId = e.target.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ); | |||
var itemsToRefresh = [ this.backlinks[itemId] ]; | |||
this.inProgressRefresh[itemId] = true; | |||
this.setValueProperties( e.target, getValueOfElm( e.target, this.mappingInput[e.target.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] ) ); | |||
var staritem = itemId.replace( /[0-9]+$/, '*' ); | |||
if ( itemId.match( /[0-9]+$/ ) && this.backlinks[staritem] ) { | |||
this.inProgressRefresh[staritem] = true; | |||
itemsToRefresh.push( this.backlinks[staritem] ); | |||
} | |||
this.refresh( itemsToRefresh.flat() ); | |||
this.inProgressRefresh = undefined; | |||
unsetBusy(); | |||
}; | |||
// The subset of formatting that still returns a float-like thing. | |||
// input type=number generally expects something that looks like a float (However if given "1.200" it will preserve the 0's) | |||
var formatNumeric = function( n, options ) { | |||
if ( typeof n !== "number" ) { | |||
return n; | |||
} | |||
if ( !isNaN( parseInt( options.calculatorDecimals ) ) ) { | |||
return n.toFixed( parseInt( options.calculatorDecimals ) ); | |||
} | |||
if ( !isNaN( parseInt( options.calculatorPrecision ) ) ) { | |||
return n.toPrecision( parseInt( options.calculatorPrecision ) ); | |||
} | |||
if ( !isNaN( parseInt( options.calculatorExponentialPrecision ) ) ) { | |||
return n.toExponential( parseInt( options.calculatorExponentialPrecision ) ); | |||
} | |||
return n; | |||
}; | |||
var format = function ( n, options, mapping ) { | |||
var res = n.toString(); | |||
if ( typeof n !== "number" ) { | |||
return res; | |||
} | |||
if ( mapping && mapping[n] ) { | |||
return mapping[n]; | |||
} | |||
if ( mapping && mapping['default'] !== undefined ) { | |||
return mapping['default']; | |||
} | |||
if ( isNaN( n ) ) { | |||
return options.calculatorNanText ? 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.setValuePropertiesSpecialPurpose = function( id ) { | |||
// Maybe in future we should set CSS variables for each special purpose item. Not sure if that would be useful. | |||
if ( id.match( /^--button-disable-.+/ ) ) { | |||
var buttonId = id.substring(17); | |||
if ( this.buttonInfo[buttonId] ) { | |||
var res = this.evaluate( this.buttonInfo[buttonId].formula ); | |||
this.buttonInfo[buttonId].elm.disabled = !isNaN( res ) && !almostEquals( res, 0 ); | |||
} else { | |||
console.log( 'Tried to refresh disable status for ' + buttonId + ' but there is no such button.' ); | |||
} | |||
} | |||
return; | |||
}; | |||
// Set a data attribute, classes, etc. For ease of targeting via CSS. | |||
CalculatorWidgets.prototype.setValueProperties = function ( elm, value ) { | |||
var itemId = elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ); | |||
elm.dataset.calculatorFieldValue = value.toFixed(5); | |||
if ( !itemId.match( /^auto\d\d\d\d+unnamed$/ ) ) { | |||
this.parent.style.setProperty( '--calculator-' + itemId, value ); | |||
} | |||
if ( !isNaN( value ) && !almostEquals( value, 0 ) ) { | |||
elm.classList.remove( 'calculator-value-false' ); | |||
elm.classList.add( 'calculator-value-true' ); | |||
} else { | |||
elm.classList.remove( 'calculator-value-true' ); | |||
elm.classList.add( 'calculator-value-false' ); | |||
} | |||
}; | |||
CalculatorWidgets.prototype.refresh = function (itemIds) { | |||
var i; | |||
// Based on https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search | |||
var permMarks = Object.create(null); | |||
var tempMarks = Object.create(null); | |||
var topList = []; | |||
var that = this; | |||
if ( !itemIds ) { | |||
return; | |||
} | |||
var visit = function( item ) { | |||
var i; | |||
if ( permMarks[item] ) { | |||
return; | |||
} | |||
if ( tempMarks[item] ) { | |||
if ( !item.match( /^--radio-/ ) ) { | |||
// Loops are expected for radio buttons. | |||
console.log( "Loop detected in calculator. '" + item + "' may not be updated properly. (This is expected for radio buttons)" ); | |||
} | |||
return; | |||
} | |||
tempMarks[item] = true; | |||
for ( i = 0; that.backlinks[item] && i < that.backlinks[item].length; i++ ) { | |||
visit(that.backlinks[item][i]); | |||
} | |||
// Special case for index() | |||
if ( item.match( /[0-9]+$/ ) ) { | |||
var staritem = item.replace( /[0-9]+$/, '*' ); | |||
for ( i = 0; that.backlinks[staritem] && i < that.backlinks[staritem].length; i++ ) { | |||
visit(that.backlinks[staritem][i]); | |||
} | |||
} | |||
permMarks[item] = true; | |||
topList.push(item); // later we iterate backwards through this list. | |||
}; | |||
for ( i = 0; i < itemIds.length; i++ ) { | |||
if ( itemIds[i] !== undefined ) { | |||
visit( itemIds[i] ); | |||
} | |||
} | |||
for ( i = topList.length - 1; i >= 0; i-- ) { | |||
var itemId = topList[i]; | |||
if ( this.inProgressRefresh[itemId] ) { | |||
// It is expected radio buttons loop, since they are depend on each other. If you check one, the others uncheck. | |||
if ( this.elmList[itemId].type !== 'radio' ) { | |||
console.log( "Loop Detected! Skipping " + itemId ); | |||
} | |||
continue; | |||
} | |||
if ( itemId.match( /^--/ ) ) { | |||
// A special purpose identifier | |||
this.setValuePropertiesSpecialPurpose( itemId ); | |||
this.inProgressRefresh[itemId] = true; | |||
continue; | |||
} else if ( !this.itemList[itemId] || this.itemList[itemId] instanceof Null ) { | |||
// This mostly should not happen but might if refresh on page load is set or with radio buttons. | |||
var elm = this.elmList[itemId]; | |||
this.setValueProperties( elm, getValueOfElm( elm, this.mappingInput[itemId] ) ); | |||
if ( this.elmList[itemId].type !== 'radio' ) { | |||
console.log( "Tried to refresh field " + itemId + " with no formula" ); | |||
} | |||
continue; | |||
} | |||
this.inProgressRefresh[itemId] = true; | |||
var elm = this.elmList[itemId]; | |||
// Special handling for radio buttons to prevent loops from indirect clicks. | |||
// If someone clicked a different radio button, we want to refresh dependencies of the unclicked button that is now deselected | |||
// but we don't want to re-evaluate its formula which might reselect it. | |||
if ( elm.type === 'radio' && this.inProgressRefresh['--radio-' + elm.name.replace( /^calcgadget-\d+-/, '' )] ) { | |||
this.setValueProperties( elm, getValueOfElm( elm, this.mappingInput[itemId] ) ); | |||
continue; | |||
} | |||
var res = this.evaluate( this.itemList[itemId] ); | |||
this.setValue( elm, res ); | |||
} | |||
}; | |||
CalculatorWidgets.prototype.setValue = function( elm, res ) { | |||
this.setValueProperties( elm, res ); | |||
var mappingOut = this.mappingOutput[elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] | |||
if ( elm.tagName === 'INPUT' ) { | |||
if ( elm.type === 'range' ) { | |||
elm.value = res; | |||
} else if ( elm.type === 'number' ) { | |||
// We support some formatting types but not others | |||
// as browser still expects this to be a number. | |||
elm.setAttribute( 'data-calculator-real-value', res ); | |||
elm.value = formatNumeric( res, elm.dataset ); | |||
} else if ( elm.type === 'radio' || elm.type === 'checkbox' ) { | |||
elm.checked = !isNaN( res ) && !almostEquals( res, 0 ); | |||
if ( !elm.checked && elm.dataset.calculatorValue ) { | |||
// If we are unchecking this box, set its value back to the default | |||
// so that if a human rechecks it, the value of the control is the default | |||
// value and not the last value of the formula. | |||
elm.value = elm.dataset.calculatorValue; | |||
} | |||
} else { | |||
elm.setAttribute( 'data-calculator-real-value', res ); | |||
elm.value = format( res, elm.dataset, mappingOut ); | |||
} | |||
} else if ( elm.tagName === 'SELECT' ) { | |||
elm.value = res; | |||
} else if ( elm.dataset.calculatorType !== 'passthru' ) { | |||
// plain type. | |||
elm.setAttribute( 'data-calculator-real-value', res ); | |||
elm.textContent = format( res, elm.dataset, mappingOut ); | |||
} | |||
} | |||
// Set a calculator container to busy, which pauses some screen readers until everything is updated. | |||
// Returns a callback to unset to busy. | |||
CalculatorWidgets.prototype.setBusy = function () { | |||
var curVal = this.parent.getAttribute( 'aria-busy' ); | |||
if ( curVal === 'true' ) { | |||
return function () {}; | |||
} | |||
this.parent.setAttribute( 'aria-busy', 'true' ); | |||
var that = this; | |||
return function () { | |||
that.parent.setAttribute( 'aria-busy', 'false' ); | |||
}; | |||
} | |||
CalculatorWidgets.prototype.evaluate = evaluate; | |||
mw.hook( 'wikipage.content' ).add( setup ); | |||
} )(); | |||
Latest revision as of 23:13, 8 June 2026
/**
* On-Wiki calculator script. See [[Template:Calculator]]. Created by [[User:Bawolff]]
* License: Creative Commons Attribution-ShareAlike 3.0 and 4.0 International Public License
* Canonical source: https://mdwiki.org/wiki/MediaWiki:Gadget-calculator.js
*
* 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.
* * Prototype pollution - We use objects with null prototypes and also reject fields named __proto__ just in case.
*
* 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', 'cbrt', 'ceil', 'cos', 'cosh', 'exp', 'expm1', 'floor', 'hypot',
'log', 'log10', 'log2', 'log1p', 'max', 'min', 'pow', 'random', 'sign', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'trunc', 'clz32' ];
var otherFuncs = [
'ifzero', 'coalesce', 'iffinite', 'ifnan', 'ifpositive', 'ifequal', 'round', 'jsround', 'not', 'and', 'or', 'bool', 'ifless', 'iflessorequal', 'ifgreater', 'ifgreaterorequal', 'ifbetween', 'xor',
'index', 'switch', 'radiogroup', 'bitand', 'bitor', 'bitxor', 'bitnot', 'bitleftshift', 'bitlogicrightshift', 'bitarithrightshift', 'if', 'getclick', 'timer', 'timeriterations', 'timertime'
];
var allFuncs = mathFuncs.concat(otherFuncs);
var convertFloat = function ( f ) {
if ( typeof f === 'number' ) {
return f;
}
f = f.replace( /×\s?10/, 'e' );
return parseFloat( 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,
'true': /^(?:true)(?![A-z_0-9-])/,
'false': /^(?:false)(?![A-z_0-9-])/,
'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 + " 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 parenthesis or are using an invalid function." );
}
return res;
},
expression: function () {
var text2, res, res2;
res = this.term();
this.consume( 'WS' );
while ( this.check( "PLUSMINUS" )) {
var op = this.consume( "PLUSMINUS" );
this.consume( 'WS' );
res2 = this.term();
this.consume( 'WS' );
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 = '/';
this.consume( 'WS' );
res2 = this.factor();
this.consume( 'WS' );
res = new Operator( op, [ res, res2 ] );
}
return res;
},
factor: function () {
this.consume( 'WS' );
var res;
if ( this.check( 'pi' ) ) {
this.consume( 'pi' );
return new Numb( Math.PI );
}
if ( this.check( 'true' ) ) {
this.consume( 'true' );
return new Numb( 1 );
}
if ( this.check( 'false' ) ) {
this.consume( 'false' );
return new Numb( 0 );
}
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( '(' );
this.consume( 'WS' );
res = this.expression();
this.consume( 'WS' );
this.consume( ')' );
return res;
}
// unary minus
if ( this.check( '-' ) ) {
this.consume( '-' );
this.consume( 'WS' );
if ( this.check( '-' ) ) {
throw new Error( "Double unary minus without parenthesis not allowed near " + this.text.substring(0,15));
}
res = this.factor();
return new Operator( '*', [new Numb(-1), 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 < minNormal ) {
return diff < epsilon * minNormal;
}
return diff / Math.min((absA + absB), Number.MAX_VALUE) < epsilon;
};
// elm: Element to get value of
// Mapping to process value with (usually this.mappingInput[element item id])
var getValueOfElm = function (elm, mapping) {
var val;
if ( elm.hasAttribute( 'data-calculator-real-value' ) ) {
// We have a non-formatted value set. Use that and skip any mappings.
return parseFloat( elm.getAttribute( 'data-calculator-real-value' ) );
} else if ( elm.tagName === 'INPUT' || elm.tagName === 'SELECT' ) {
if ( elm.type === 'radio' || elm.type === 'checkbox' ) {
// Radio/checkboxes are not allowed to have mappings
return elm.checked ? convertFloat( elm.value ) : 0;
}
val = elm.value;
} else {
val = elm.textContent;
}
if ( typeof val === 'string' ) {
val = val.trim();
var mapVals = [ val, val.toLowerCase() ];
for ( var k = 0; k < mapVals.length; k++ ) {
if (mapping && typeof mapping[mapVals[k]] === 'number' ) {
return mapping[mapVals[k]];
} else if ( mapping && [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[mapVals[k]] ) !== -1 ) {
return parseFloat( mapping[mapVals[k]] );
}
}
}
return convertFloat( val );
};
// Evaluate the value of an AST at runtime.
var evaluate = function( ast ) {
var that = this;
var elmList = this.elmList;
var i, then, ielse, res, elm;
if ( ast instanceof Numb ) {
return ast.value;
}
if ( ast instanceof Ident ) {
elm = elmList[ast.value];
if ( elm === undefined ) {
console.log( "Calculator: Reference to '" + ast.value + "' but there is no field by that name" );
return NaN;
}
return getValueOfElm( elm, this.mappingInput[elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] );
}
if ( ast instanceof Operator ) {
// Special case, index() does not directly eval first arg
// index() is like an array index operator (sort of)
// It evaluates its second argument and concats that to the first identifier
if ( 'index' === ast.op ) {
if ( ast.args.length < 2 || !( ast.args[0] instanceof Ident ) ) {
return NaN;
}
var indexValue = Math.floor(this.evaluate( ast.args[1] ));
if ( !Number.isSafeInteger( indexValue ) || indexValue < 0 ) {
return NaN;
}
res = elmList[ast.args[0].value + indexValue];
if ( res === undefined ) {
return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : NaN;
}
return getValueOfElm( res, this.mappingInput[res.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] );
}
// Get the value of the checked element of a radio group or NaN if none checked
// Unlike most things, first args identifies the radio group name and not an id.
if ( ast.op === 'radiogroup' ) {
if ( ast.args.length < 1 || !( ast.args[0] instanceof Ident ) ) {
return NaN;
}
var radioName = ast.args[0].value;
elm = this.parent.querySelector( 'input:checked[type=radio][name=' + CSS.escape( 'calcgadget-' + this.rand + '-' + radioName ) + ']' );
if ( !elm ) {
return NaN;
}
return this.evaluate( new Ident( elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) ) );
}
if ( ast.op === 'getclick' ) {
if ( ast.args.length < 1 || !( ast.args[0] instanceof Ident ) ) {
return NaN;
}
switch( ast.args[0].value.toLowerCase() ) {
case 'x':
return this.clickX;
case 'prevx':
return this.clickPrevX;
case 'prevpercentx':
return this.clickPrevPercentX;
case 'startpercentx':
return this.clickStartPercentX;
case 'startx':
return this.clickStartX;
case 'percentx':
return this.clickPercentX;
case 'y':
return this.clickY;
case 'percenty':
return this.clickPercentY;
case 'prevy':
return this.clickPrevY;
case 'prevpercenty':
return this.clickPrevPercentY;
case 'starty':
return this.clickStartY;
case 'startpercenty':
return this.clickStartPercentY;
case 'inprogress':
return this.clickInProgress;
case 'type':
// 1 = start, 2 - move, 3 = done, 4 = cancel
return this.clickType;
default:
return NaN;
}
}
// Check if a specific timer is running.
// timer() returns 0 or 1 if any timer
// timer( timerName) checks for that sepcific timer
// timer(timerName, trueValue, falseValue ) returns specific value.
if ( ast.op === 'timer' ) {
if (!this.repeatInfo) {
return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : 0;
}
if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) {
return ast.args.length >= 2 ? this.evaluate( ast.args[1] ) : 1;
}
return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : 0;
}
if ( ast.op === 'timeriterations' ) {
if (!this.repeatInfo) {
return NaN;
}
if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) {
return this.repeatInfo.iterationsSoFar;
}
return NaN;
}
if ( ast.op === 'timertime' ) {
if (!this.repeatInfo) {
return NaN;
}
if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) {
return (Date.now() - this.repeatInfo.timeStart)/1000;
}
return NaN;
}
// Start of functions that evaluate their arguments.
evaledArgs = ast.args.map( function (i) { return that.evaluate( i ) } );
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 < 1 ) {
return NaN;
}
then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
return almostEquals( evaledArgs[0], 0 ) ? then : ielse;
}
// Opposite of ifzero
if ( 'if' === ast.op ) {
if ( evaledArgs.length < 1 ) {
return NaN;
}
then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
return !almostEquals( evaledArgs[0], 0 ) ? then : ielse;
}
if ( 'ifequal' === ast.op ) {
if ( evaledArgs.length < 2 ) {
return NaN;
}
then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
return almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
}
if ( 'iffinite' === ast.op ) {
if ( evaledArgs.length < 1 ) {
return NaN;
}
then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
return isFinite( evaledArgs[0] ) ? then : ielse;
}
if ( 'ifnan' === ast.op ) {
if ( evaledArgs.length < 1 ) {
return NaN;
}
then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
return isNaN( evaledArgs[0] ) ? then : ielse;
}
if ( 'ifpositive' === ast.op ) {
if ( evaledArgs.length < 1 ) {
return NaN;
}
then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
return evaledArgs[0] >= 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 < 2 ) {
return NaN;
}
then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
return evaledArgs[0] < evaledArgs[1] && !almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
}
if ( 'ifgreater' === ast.op ) {
if ( evaledArgs.length < 2 ) {
return NaN;
}
then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
return evaledArgs[0] > evaledArgs[1] && !almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
}
if ( 'iflessorequal' === ast.op ) {
if ( evaledArgs.length < 2 ) {
return NaN;
}
then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
return evaledArgs[0] <= evaledArgs[1] || almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
}
if ( 'ifgreaterorequal' === ast.op ) {
if ( evaledArgs.length < 2 ) {
return NaN;
}
then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
return evaledArgs[0] >= evaledArgs[1] || almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
}
// Should this use almostEquals???
if ( 'ifbetween' === ast.op ) {
if ( evaledArgs.length < 3 ) {
return NaN;
}
then = evaledArgs.length < 4 ? 1 : evaledArgs[3];
ielse = evaledArgs.length < 5 ? 0 : evaledArgs[4];
return evaledArgs[0] >= evaledArgs[1] && evaledArgs[0] <= evaledArgs[2] ? then : ielse;
}
if ( 'bool' === ast.op ) {
if ( evaledArgs.length !== 1 ) {
return NaN;
}
return isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ? 0 : 1;
}
if ( 'not' === ast.op ) {
if ( evaledArgs.length !== 1 ) {
return NaN;
}
return isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ? 1 : 0;
}
if ( 'xor' === ast.op ) {
if ( evaledArgs.length !== 2 ) {
return NaN;
}
if (
( ( isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ) && !( isNaN( evaledArgs[1] ) || almostEquals( evaledArgs[1], 0.0 ) ) ) ||
( ( isNaN( evaledArgs[1] ) || almostEquals( evaledArgs[1], 0.0 ) ) && !( isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ) )
) {
return 1;
} else {
return 0;
}
}
// Short circuit like in lua
if ( 'and' === ast.op ) {
for ( i = 0; i < evaledArgs.length; i++ ) {
if ( isNaN( evaledArgs[i] ) || almostEquals( evaledArgs[i], 0.0 ) ) {
return evaledArgs[i];
}
}
return evaledArgs.length >= 1 ? evaledArgs[evaledArgs.length-1] : 1;
}
if ( 'or' === ast.op ) {
for ( i = 0; i < evaledArgs.length; i++ ) {
if ( !isNaN( evaledArgs[i] ) && !almostEquals( evaledArgs[i], 0.0 ) ) {
return evaledArgs[i];
}
}
return evaledArgs.length >= 1 ? evaledArgs[evaledArgs.length-1] : 0;
}
// Bitwise operators (numbers treated like 32bit integer). The binary ones can take any number of args.
if ( 'bitand' === ast.op ) {
if ( evaledArgs.length < 1 ) {
return NaN;
}
res = evaledArgs[0];
for ( i = 1; i < evaledArgs.length; i++ ) {
res = res & evaledArgs[i];
}
return res;
}
if ( 'bitor' === ast.op ) {
if ( evaledArgs.length < 1 ) {
return NaN;
}
res = evaledArgs[0];
for ( i = 1; i < evaledArgs.length; i++ ) {
res = res | evaledArgs[i];
}
return res;
}
if ( 'bitxor' === ast.op ) {
if ( evaledArgs.length < 1 ) {
return NaN;
}
res = evaledArgs[0];
for ( i = 1; i < evaledArgs.length; i++ ) {
res = res ^ evaledArgs[i];
}
return res;
}
// Bitwise operators that don't take infinite amount of ops.
if ( 'bitnot' === ast.op ) {
if ( evaledArgs.length < 1 ) {
return NaN;
}
return ~evaledArgs[0];
}
if ( 'bitleftshift' === ast.op ) {
if ( evaledArgs.length < 2 ) {
return NaN;
}
return evaledArgs[0] << evaledArgs[1];
}
if ( 'bitlogicrightshift' === ast.op ) {
if ( evaledArgs.length < 2 ) {
return NaN;
}
return evaledArgs[0] >>> evaledArgs[1];
}
if ( 'bitarithrightshift' === ast.op ) {
if ( evaledArgs.length < 2 ) {
return NaN;
}
return evaledArgs[0] >> evaledArgs[1];
}
// switch(value,2,valueFor2,5,valueFor5,...,valueIfNoneMatch)
// select the arg where the test <= value.
if ( 'switch' === ast.op ) {
if ( evaledArgs.length < 2 ) {
return NaN;
}
var defaultVal = NaN;
if ( evaledArgs.length % 2 === 0 ) {
// Last arg is default if even number of args.
defaultVal = evaledArgs[evaledArgs.length-1];
}
for ( i = 1; i < evaledArgs.length-1; i+=2 ) {
if ( evaledArgs[0] <= evaledArgs[i] || almostEquals( evaledArgs[i], evaledArgs[0] ) ) {
return evaledArgs[i+1];
}
}
return defaultVal;
}
// 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[1] : 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([]);
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.args[0] ).forEach( function (x) { res.add(x + '*') } );
} else if ( (tree.op === 'radiogroup' ) && tree.args.length > 0 && tree.args[0] instanceof Ident ) {
i++;
res.add( '--radio-' + tree.args[0].value );
} else if ( (tree.op === 'timer' ) ) {
if ( tree.args.length > 0 && tree.args[0] instanceof Ident ) {
i++;
res.add( '--timer-active-' + tree.args[0].value );
} else {
res.add( '--timer-active' );
}
} else if ( tree.op === 'timertime' || tree.op === 'timeriterations' ) {
res.add( '--timer-iteration' );
if ( tree.args.length > 0 && tree.args[0] instanceof Ident ) {
res.add( '--timer-iteration-' + tree.args[0].value );
} else {
res.add( '--timer-iteration' );
}
return res;
} else if ( tree.op === 'getclick' ) {
res.add( '--click' );
return res;
}
for ( ; i < tree.args.length; i++ ) {
getIdentifiers( tree.args[i] ).forEach( function (x) { res.add(x) } );
}
return res;
}
return new Set();
};
// e.g. if a=foo+1 then backlinks['foo'] = ['a',...]
var buildBacklinks = function (items, radioGroups) {
var backlinks = Object.create(null);
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 );
} );
}
// We use identifiers starting with -- as special purpose
// Each radio item should depend on each other, since if one gets checked the others get unchecked.
var identPrefix = '--radio-';
for ( var groupName in radioGroups ) {
if ( backlinks[identPrefix + groupName] == undefined ) {
backlinks[identPrefix + groupName] = [];
}
for ( var i = 0; i < radioGroups[groupName].length; i++ ) {
backlinks[identPrefix + groupName].push( radioGroups[groupName][i] );
if ( backlinks[radioGroups[groupName][i]] == undefined ) {
backlinks[radioGroups[groupName][i]] = [];
}
backlinks[radioGroups[groupName][i]].push( identPrefix + groupName );
}
}
return backlinks;
};
// End dependency graph code
// Start code that does setup and HTML interaction.
var setup = function ( $content ) {
// We allow users to group calculator widgets inside a <div class="calculator-container"> 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 <fieldset>?).
var containers = Array.from( $content.find( '.calculator-container' ) );
for ( var i = 0; i < containers.length; i++ ) {
var calc = new CalculatorWidgets( Array.from( containers[i].getElementsByClassName( 'calculator-field' ) ), containers[i] );
}
// Anything not in a container is scoped to the page.
var elms = Array.from( $content.find( '.calculator-field' ) );
new CalculatorWidgets( elms, $content[0] );
$content.find( '.calculator-field-label').replaceWith( function() {
var l = $( '<label>' );
// 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( /^(?:calcdisambig-\d+-)?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 () {
if ( window.calculatorStatsAlreadyDone !== true ) {
window.calculatorStatsAlreadyDone = true;
mw.track( 'stats.mediawiki_gadget_calculator_total' );
mw.track( 'stats.mediawiki_gadget_calculator_wiki_total', 1, { wiki: mw.config.get( 'wgDBname' ) } );
var pageName = encodeURIComponent( mw.config.get( 'wgTitle' ).replace( / /g, '_' ) );
// alphanumeric and underscore only.
pageName = pageName.replace( /[^a-zA-Z0-9_]/g, '_' );
mw.track( 'stats.mediawiki_gadget_calculator_page_total', 1, { wiki: mw.config.get( 'wgDBname' ), page: pageName, NS: mw.config.get( 'wgNamespaceNumber' ) } );
}
};
var createSelect = function( elm ) {
var sel = document.createElement( 'select' );
var mapping;
try {
mapping = JSON.parse( elm.dataset.calculatorMapping );
} catch(e) {
console.log( "Calculator: Error processing mapping of " + elm.id + ". " + e.message );
return sel;
}
if ( typeof mapping !== 'object' ) {
console.log( "Calculator: Error processing mapping of " + elm.id + ". unexpected type" );
return sel;
}
var opt;
for ( var i in mapping ) {
if ( typeof mapping[i] === 'object' ) {
var optgroup = document.createElement( 'optgroup' );
optgroup.label = i;
sel.appendChild( optgroup );
// Note: optgroup cannot be nested.
for ( var j in mapping[i] ) {
if ( typeof mapping[i][j] === 'number' || [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i][j] ) !== -1 ) {
opt = document.createElement( 'option' );
opt.value = mapping[i][j];
opt.appendChild( document.createTextNode( j ) );
optgroup.appendChild( opt );
}
}
} else if ( typeof mapping[i] === 'number' || [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i] ) !== -1 ) {
opt = document.createElement( 'option' );
opt.value = mapping[i];
opt.appendChild( document.createTextNode( i ) );
sel.appendChild( opt );
}
}
return sel;
};
var CalculatorWidgets = function( elms, parent ) {
this.parent = parent;
this.rand = Math.floor(Math.random()*1000000000);
this.itemList = Object.create(null);
this.elmList = Object.create(null);
this.backlinks = Object.create(null);
this.inProgressRefresh = undefined;
this.clickX = this.clickY = this.clickPercentX = this.clickPercentY = NaN;
this.clickPrevX = this.clickPrevY = this.clickPrevPercentX = this.clickPrevPercentY = NaN;
this.clickStartX = this.clickStartY = this.clickStartPercentX = this.clickStartPercentY = NaN;
this.clickType = NaN;
this.clickInProgress = 0;
this.repeatInfo = null;
var that = this;
var radioGroups = Object.create(null);
this.buttonInfo = Object.create(null);
this.mappingInput = Object.create(null);
this.mappingOutput = Object.create(null);
this.repeatInfo = null;
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.toString();
if ( id.match( /^(?:calcdisambig-\d+-)?calculator-field-__proto__$/ ) || elm.dataset.calculatorName === '__proto__' ) {
// be paranoid
throw new Error( "Invalid calculator id: " + id );
}
if ( !id ) {
id = 'calculator-field-auto' + Math.floor( Math.random()*10000000 ) + 'unnamed';
}
if ( this.parent.classList.contains( 'calculator-container' ) && document.getElementById( id ) !== elm ) {
// There is a duplicate id, and we are not at the top level.
// This is not ideal, as you can't reference it using other attributes (e.g. aria-labelledby, aria-describedby) or link targets, but at least we won't label the wrong thing.
var labelsToUpdate = this.parent.querySelectorAll( ":scope .calculator-field-label[data-for=\"" + CSS.escape( id ) + "\"]" );
id = 'calcdisambig-' + this.rand + '-' + id;
for ( var l = 0; l < labelsToUpdate.length; l++ ) {
labelsToUpdate[l].dataset.for = id;
}
}
var defaultVal = ("" + elm.textContent).trim();
if ( type === undefined || !id.match( /^(?:calcdisambig-\d+-)?calculator-field-[a-zA-Z_][a-zA-Z0-9_]*$/ ) ) {
console.log( "Skipping " + id );
continue;
}
var formulaAST;
try {
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-/ ) || ( elm.dataset.calculatorClass && elm.dataset.calculatorClass.match( /(^| )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 = type === 'select' ? createSelect( elm ) : 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 = convertFloat(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;
if ( type === 'radio' ) {
// 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 ( radioGroups[elm.dataset.calculatorName] === undefined ) {
radioGroups[elm.dataset.calculatorName] = [];
}
radioGroups[elm.dataset.calculatorName][radioGroups[elm.dataset.calculatorName].length] = id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' );
}
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 ( elm.getAttribute( 'aria-owns' ) !== null ) input.setAttribute( 'aria-owns', elm.getAttribute( 'aria-owns' ) );
if ( elm.getAttribute( 'aria-flowto' ) !== null ) input.setAttribute( 'aria-flowto', elm.getAttribute( 'aria-flowto' ) );
if ( elm.getAttribute( 'role' ) !== null ) input.setAttribute( 'role', elm.getAttribute( 'role' ) );
// To support only setting the role if gadget is enabled
if ( elm.dataset.calculatorAriaRole ) input.setAttribute( 'role', elm.dataset.calculatorAriaRole );
if ( ['true', 'false'].indexOf( elm.dataset.calculatorAriaAtomic ) !== -1 ) input.setAttribute( 'aria-atomic', elm.dataset.calculatorAriaAtomic );
if ( elm.dataset.calculatorAriaRelevant ) input.setAttribute( 'aria-relevant', elm.dataset.calculatorAriaRelevant );
if ( ['off', 'polite', 'assertive'].indexOf( elm.dataset.calculatorAriaLive ) !== -1 ) {
input.setAttribute( 'aria-live', elm.dataset.calculatorAriaLive );
} else {
// We treat invalid values as set nothing, which inherits from parent which is different than setting off. This allows user to override
// We also set a default of polite for cells with a formula
if ( formula ) {
input.setAttribute( 'aria-live', 'polite' );
}
}
if ( type === 'number' || type === 'text' || type === 'radio' || type === 'checkbox' || type === "hidden" || type === "range" ) {
input.type = type;
}
if ( type === 'select' ) {
if ( elm.dataset.calculatorValue ) {
// Allow people to override the default value for select with attribute, as the value might not be a good non-js fallback.
input.value = elm.dataset.calculatorValue;
}
}
if ( type === 'radio' || type === 'checkbox' ) {
input.onchange = this.changeHandler.bind(this); // some browsers dont fire oninput for checkboxes/radio
if ( elm.dataset.calculatorChecked === 'true' || elm.dataset.calculatorChecked === '1' ) {
input.checked = true;
} else if ( elm.dataset.calculatorChecked === 'false' || elm.dataset.calculatorChecked === '0' ) {
input.checked = false;
} else if ( !isNaN( defaultVal ) && !almostEquals( defaultVal, 0 ) ) {
// If all else fails, use the default value to decide if it is checked
input.checked = true;
}
// Allow user to set a different checked value other than 1. This will be the value when checked by human, but not neccesarily if by formula
// We use a specific data-calculator-value if set and true, otherwise we use the defaultVal (text firstChild) if true, and 1 as a last resort.
var checkValue = convertFloat( elm.dataset.calculatorValue || '' );
if ( !isNaN( checkValue ) && !almostEquals( checkValue, 0 ) ) {
input.value = checkValue;
input.dataset.calculatorValue = checkValue;
} else if ( !isNaN( defaultVal ) && !almostEquals( defaultVal, 0 ) ) {
input.value = defaultVal;
input.dataset.calculatorValue = defaultVal;
} else {
input.value = 1;
input.dataset.calculatorValue = 1;
}
}
if ( elm.dataset.calculatorMapping && type === 'text') {
this.processMapping( elm.dataset.calculatorMapping, id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) );
}
input.id = id;
elm.replaceWith( input );
elm = input;
input.addEventListener( 'input', 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 ( ['off', 'polite', 'assertive' ].indexOf( elm.dataset.calculatorAriaLive ) !== -1 ) {
elm.setAttribute( 'aria-live', elm.dataset.calculatorAriaLive );
} else if ( formula && type !== 'passthru' && !elm.dataset.calculatorAriaLive ) {
// Announce plain outputs by default, but allow user to override. Treat invalid value as do not set, since not setting is slightly different then the value off.
elm.setAttribute( 'aria-live', 'polite' );
}
if ( ['true', 'false'].indexOf( elm.dataset.calculatorAriaAtomic ) !== -1 ) elm.setAttribute( 'aria-atomic', elm.dataset.calculatorAriaAtomic );
// Support either directly setting role or using data attribute, in case people only want to set the role in the JS case.
if ( elm.dataset.calculatorAriaRole ) elm.setAttribute( 'role', elm.dataset.calculatorAriaRole );
if ( elm.dataset.calculatorAriaRelevant ) elm.setAttribute( 'aria-relevant', elm.dataset.calculatorAriaRelevant );
if ( elm.id === '' ) {
elm.id = id;
}
if ( elm.dataset.calculatorMapping && type === 'plain') {
this.processMapping( elm.dataset.calculatorMapping, id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) );
}
}
var itemId = id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' );
this.itemList[itemId] = formulaAST;
this.elmList[itemId] = elm;
}
this.backlinks = buildBacklinks( this.itemList, radioGroups );
if ( this.parent.dataset.calculatorRefreshOnLoad && this.parent.dataset.calculatorRefreshOnLoad === 'true' ) {
this.inProgressRefresh = Object.create(null);
var unsetBusy = this.setBusy();
this.refresh( Object.keys( this.itemList ).filter( function (a) { return !(that.itemList[a] instanceof Null); } ) );
this.inProgressRefresh = undefined;
unsetBusy();
}
this.setupButtons();
};
// MappingText is a json blob of {"some string": number}
// Used to map between numbers <-> strings during input/output.
CalculatorWidgets.prototype.processMapping = function ( mappingText, itemId ) {
var mapping;
try {
mapping = JSON.parse( mappingText );
} catch(e) {
console.log( "Calculator: Error processing mapping of " + itemId + ". " + e.message );
return;
}
if ( typeof mapping !== 'object' ) {
console.log( "Calculator: Error processing mapping of " + itemId + ". unexpected type" );
return;
}
this.mappingInput[itemId] = mapping;
var mapOut = Object.create(null);
for (var i in mapping) {
if ( typeof mapping[i] === 'number' || [ 'default', 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i] ) !== -1 ) {
mapOut[mapping[i]] = i;
}
}
this.mappingOutput[itemId] = mapOut;
};
CalculatorWidgets.prototype.stopRepeat = function () {
if ( this.repeatInfo ) {
if ( this.repeatInfo.cancelIdentifier ) {
// If until param is immediately true, the interval may not have been set yet.
window.clearInterval( this.repeatInfo.cancelIdentifier );
}
var id = this.repeatInfo.id;
this.repeatInfo = null;
this.inProgressRefresh = Object.create(null);
var unsetBusy = this.setBusy();
this.refresh( [ '--timer-active', '--timer-active-' + id, '--timer-iteration', '--timer-iteration-' + id ] );
this.inProgressRefresh = undefined;
unsetBusy();
}
this.repeatInfo = null;
};
CalculatorWidgets.prototype.startRepeat = function (maxIterations, id) {
if ( this.repeatInfo ) {
console.log( "Calculator: Trying to start a repeat when one is already in progress. This should not happen" );
}
this.repeatInfo = {
maxIterations: maxIterations,
iterationsSoFar: 0,
cancelIdentifier: null,
timeStart: Date.now(),
id: id
};
// Not 100% clear if we should immediately refresh or wait for after first iteration.
if ( this.inProgressRefresh ) {
console.log( "Calculator: Refresh already in progress. This probably indicates a bug" );
} else {
this.inProgressRefresh = Object.create(null);
}
var unsetBusy = this.setBusy();
this.refresh( [ '--timer-active', '--timer-active-' + id, '--timer-iteration', '--timer-iteration-' + id ] );
this.inProgressRefresh = undefined;
unsetBusy();
};
CalculatorWidgets.prototype.incrementRepeatIteration = function (until) {
this.repeatInfo.iterationsSoFar++;
if ( this.repeatInfo.iterationsSoFar > this.repeatInfo.maxIterations ) {
this.stopRepeat();
return false;
}
if ( until ) {
var untilRes = this.evaluate( until );
if ( !isNaN( untilRes ) && !almostEquals( untilRes, 0 ) ) {
this.stopRepeat();
return false;
}
}
// Update fields that depend on the iteration number
var id = this.repeatInfo.id;
if ( this.inProgressRefresh ) {
console.log( "Calculator: Refresh already in progress. This probably indicates a bug" );
} else {
this.inProgressRefresh = Object.create(null);
}
var unsetBusy = this.setBusy();
this.refresh( [ '--timer-iteration', '--timer-iteration-' + id ] );
this.inProgressRefresh = undefined;
unsetBusy();
return true;
};
var convertEventTypeToNumber = function ( type ) {
switch( type ) {
case 'pointerdown':
return 1;
case 'pointermove':
return 2;
case 'pointerup':
case 'click':
return 3;
case 'pointerleave':
return 4;
default:
throw new Error( "Unrecognized event type: " + type );
}
};
var updateClick = function(calc) {
if ( calc.inProgressRefresh ) {
console.log( "Calculator: Refresh already in progress. This probably indicates a bug" );
} else {
calc.inProgressRefresh = Object.create(null);
}
var unsetBusy = calc.setBusy();
calc.refresh( [ '--click' ] );
calc.inProgressRefresh = undefined;
unsetBusy();
};
// Handler for click events on button.
var makeButtonCallback = function ( calc, forElms, formulaASTs, repeat, maxIterations, toggle, until ) {
return function buttonCallback (event) {
if ( event.type !== 'click' ) {
if ( !event.isPrimary || ( !calc.clickInProgress && event.type !== 'pointerdown' ) ) {
return; // Skip secondary pointers.
}
}
event.preventDefault();
doStats();
var unsetBusy = calc.setBusy(); // stopping previous repeats might have side effects so set busy.
if ( toggle && calc.repeatInfo && calc.repeatInfo.id === event.currentTarget.id ) {
// If we click a toggle button a second time, all we do is stop the repeat.
calc.stopRepeat();
unsetBusy();
return;
}
if ( maxIterations !== undefined || repeat !== undefined ) {
// Only one repeating callback allowed at a time.
calc.stopRepeat();
if ( maxIterations > 1 ) {
// Start a new repeat
calc.startRepeat( maxIterations, event.currentTarget.id );
}
}
var doStuff = function(event) {
var unsetBusyInner = calc.setBusy();
var stillGoing = true;
if ( calc.repeatInfo ) {
stillGoing = calc.incrementRepeatIteration(until);
}
if ( stillGoing ) {
for ( var i = 0; i < forElms.length; i++ ) {
if ( !formulaASTs[i] || !forElms[i] ) {
console.log( "Skipping button update due to invalid formula or for attribute" );
continue;
}
var res = calc.evaluate( formulaASTs[i] );
calc.setValue( forElms[i], res );
forElms[i].dispatchEvent( new InputEvent( 'input' ) );
}
}
unsetBusyInner();
};
if ( maxIterations === undefined || maxIterations >= 1 ) {
// click coordinates can be negative or > 100% if user clicked border
if ( calc.clickInProgress ) {
calc.clickPrevX = calc.clickX;
calc.clickPrevY = calc.clickY;
calc.clickPrevPercentX = calc.clickPercentX;
calc.clickPrevPercentY = calc.clickPercentY;
}
if ( event.type === 'pointerdown' ) {
// This ensures better consistency between mouse and touchscreen.
event.currentTarget.setPointerCapture( event.pointerId );
}
if ( event.type !== 'pointerleave' ) {
// The coordinates returned for pointerleave events are inconsistent.
// Especially in firefox when a context menu is opened from right click.
calc.clickX = event.offsetX;
calc.clickY = event.offsetY;
calc.clickPercentX = 100*event.offsetX/event.currentTarget.clientWidth;
calc.clickPercentY = 100*event.offsetY/event.currentTarget.clientHeight;
calc.clickInProgress = 1;
}
calc.clickType = convertEventTypeToNumber( event.type );
if ( event.type === 'click' || event.type === 'pointerdown' ) {
calc.clickPrevX = calc.clickStartX = calc.clickX;
calc.clickPrevY = calc.clickStartY = calc.clickY;
calc.clickPrevPercentX = calc.clickStartPercentX = calc.clickPercentX;
calc.clickPrevPercentY = calc.clickStartPercentY = calc.clickPercentY;
}
updateClick(calc);
doStuff();
// We are done with the click events.
if ( calc.clickType >= 3 ) {
calc.clickX = calc.clickY = calc.clickPercentX = calc.clickPercentY = NaN;
calc.clickPrevX = calc.clickPrevY = calc.clickPrevPercentX = calc.clickPrevPercentY = NaN;
calc.clickStartX = calc.clickStartY = calc.clickStartPercentX = calc.clickStartPercentY = NaN;
calc.clickInProgress = 0;
calc.clickType = NaN;
updateClick(calc);
}
}
unsetBusy();
// repeatInfo will be null if until formula evaluates to true on first iteration
if ( calc.repeatInfo && maxIterations > 1 && repeat >= 500 ) {
calc.repeatInfo.cancelIdentifier = window.setInterval( doStuff, repeat );
}
};
};
var connectButtonToFormula = function ( calc, $button, forAttr, formulaAttr, repeatDuration, maxIterations, toggle, untilAttr, draggable ) {
// The intended use case for repeats is something like a chess board that automatically plays the next move.
// We don't want it to be used for animation or otherwise harm performance, so we limit to once per 500ms and
// only allow one repeat per container to be active at a time.
if ( repeatDuration !== undefined ) {
repeatDuration = Math.max( 500, Math.ceil(parseFloat( repeatDuration )*1000 ) );
if ( isNaN( repeatDuration ) ) {
repeatDuration = undefined;
}
}
if ( toggle === 'false' ) {
toggle = false;
}
// If neither repeatDuration or maxIterations is set then the button is normal and does not repeat but does not cancel
// if repeatDuration is set and no maxIterations, then maxIterations is infinite
// If maxIterations is 0, then this cancels the current repeat without doing anything
// If maxIterations is 1, then cancel but do normal button op (but don't repeat)
if ( maxIterations !== undefined ) {
maxIterations = parseInt( maxIterations );
} else if( repeatDuration ) {
maxIterations = Infinity;
}
if ( typeof forAttr !== 'string' || typeof formulaAttr !== 'string' ) {
// Nothing to do. Skip.
return;
}
if ( formulaAttr.length > 2000 || (untilAttr && untilAttr.length > 2000 ) ) {
console.log( "Skipping button with too long formula" );
return;
}
var until = null;
try {
until = (new Parser( untilAttr )).parse();
} catch( e ) {
console.log( "Calculator: Error parsing until formula of button " + $button[0].id + ": " + e.message );
}
var forList = forAttr.split( ';' );
var formulaList = formulaAttr.split( ';' );
var forElms = [];
var formulaASTs = [];
for ( var i = 0; i < forList.length; i++ ) {
if (formulaList[i] === undefined) {
break;
}
forElms[i] = calc.elmList[forList[i]];
try {
formulaASTs[i] = (new Parser( formulaList[i] )).parse();
} catch( e ) {
formulaASTs[i] = null;
console.log( "Calculator: Error parsing formula of button " + $button[0].id + " for " + forList[i] + ": " + e.message );
}
}
if ( draggable ) {
$button[0].addEventListener( 'pointerdown', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
$button[0].addEventListener( 'pointerup', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
$button[0].addEventListener( 'pointerleave', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
$button[0].addEventListener( 'pointermove', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
} else {
$button[0].addEventListener( 'click', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
}
};
CalculatorWidgets.prototype.connectButtonDisable = function ( button, formula ) {
if ( formula === '' || formula === '0' || formula === 'false' ) {
return;
}
if ( formula.length > 2000 ) {
console.log( "Calculator: Disable formula for button " + button.id + " is too long" );
return;
}
var formulaAST;
try {
formulaAST = (new Parser( formula )).parse();
} catch( e ) {
console.log( "Error parsing disable formula of button " + button.id + ": " + e.message );
return;
}
this.buttonInfo[button.id] = {
elm: button,
formula: formulaAST,
};
// Add disabled formula's dependencies to the dependency tree.
var idents = getIdentifiers( formulaAST );
var that = this;
var item = '--button-disable-' + button.id;
idents.forEach( function (ident) {
if ( !that.backlinks[ident] ) {
that.backlinks[ident] = [];
}
that.backlinks[ident].push( item );
} );
// Now set the initial value.
var curValue = this.evaluate( formulaAST );
if ( !isNaN( curValue ) && !almostEquals( curValue, 0 ) ) {
button.disabled = true;
}
};
CalculatorWidgets.prototype.setupButtons = function() {
var calc = this;
$( this.parent ).find( '.calculator-field-button' ).replaceWith( function () {
var b = $( '<button type="button">' );
if ( this.id && this.id !== '__proto__' ) {
b.attr( 'id', this.id );
} else {
b.attr( 'id', 'calculator-button-' + Math.ceil(Math.random()*100000000000) );
}
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.calculatorAriaLive ) {
b.attr( 'aria-live', this.dataset.calculatorAriaLive );
}
if ( this.getAttribute( 'role' ) ) {
b.attr( 'role', this.getAttribute( 'role' ) );
}
if ( this.getAttribute( 'aria-owns' ) ) {
b.attr( 'aria-owns', this.getAttribute( 'aria-owns' ) );
}
if ( this.getAttribute( 'aria-flowto' ) ) {
b.attr( 'aria-flowto', this.getAttribute( 'aria-flowto' ) );
}
if ( this.dataset.calculatorDisabled !== undefined ) {
// TODO: Would be cool if this was a formula.
if ( this.dataset.calculatorDisabled === 'true' || this.dataset.calculatorDisabled === '1' ) {
// Simple case
b.attr( 'disabled', true );
} else {
calc.connectButtonDisable( b[0], this.dataset.calculatorDisabled );
}
}
// This will be a no-op if no formula or for specified.
connectButtonToFormula(
calc,
b,
this.dataset.calculatorFor,
this.dataset.calculatorFormula,
this.dataset.calculatorDelay,
this.dataset.calculatorMaxIterations,
this.dataset.calculatorToggle,
this.dataset.calculatorUntil
);
b.append( this.childNodes );
return b;
} );
// This is meant to allow image map type things.
$( this.parent ).find( '.calculator-field-buttonraw' ).each( function () {
if (!this.id || this.id === '__proto__') {
this.id = 'calculator-button-' + Math.ceil(Math.random()*100000000000);
}
// Make sure we do not process twice.
this.classList.remove( 'calculator-field-buttonraw' );
connectButtonToFormula(
calc,
$(this),
this.dataset.calculatorFor,
this.dataset.calculatorFormula,
this.dataset.calculatorDelay,
this.dataset.calculatorMaxIterations,
this.dataset.calculatorToggle,
this.dataset.calculatorUntil,
this.classList.contains( 'calculator-field-buttondraggable' )
);
} );
};
CalculatorWidgets.prototype.changeHandler = function(e) {
this.inProgressRefresh = Object.create(null);
var unsetBusy = this.setBusy();
doStats();
e.target.removeAttribute( 'data-calculator-real-value' );
var itemId = e.target.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' );
var itemsToRefresh = [ this.backlinks[itemId] ];
this.inProgressRefresh[itemId] = true;
this.setValueProperties( e.target, getValueOfElm( e.target, this.mappingInput[e.target.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] ) );
var staritem = itemId.replace( /[0-9]+$/, '*' );
if ( itemId.match( /[0-9]+$/ ) && this.backlinks[staritem] ) {
this.inProgressRefresh[staritem] = true;
itemsToRefresh.push( this.backlinks[staritem] );
}
this.refresh( itemsToRefresh.flat() );
this.inProgressRefresh = undefined;
unsetBusy();
};
// The subset of formatting that still returns a float-like thing.
// input type=number generally expects something that looks like a float (However if given "1.200" it will preserve the 0's)
var formatNumeric = function( n, options ) {
if ( typeof n !== "number" ) {
return n;
}
if ( !isNaN( parseInt( options.calculatorDecimals ) ) ) {
return n.toFixed( parseInt( options.calculatorDecimals ) );
}
if ( !isNaN( parseInt( options.calculatorPrecision ) ) ) {
return n.toPrecision( parseInt( options.calculatorPrecision ) );
}
if ( !isNaN( parseInt( options.calculatorExponentialPrecision ) ) ) {
return n.toExponential( parseInt( options.calculatorExponentialPrecision ) );
}
return n;
};
var format = function ( n, options, mapping ) {
var res = n.toString();
if ( typeof n !== "number" ) {
return res;
}
if ( mapping && mapping[n] ) {
return mapping[n];
}
if ( mapping && mapping['default'] !== undefined ) {
return mapping['default'];
}
if ( isNaN( n ) ) {
return options.calculatorNanText ? 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.setValuePropertiesSpecialPurpose = function( id ) {
// Maybe in future we should set CSS variables for each special purpose item. Not sure if that would be useful.
if ( id.match( /^--button-disable-.+/ ) ) {
var buttonId = id.substring(17);
if ( this.buttonInfo[buttonId] ) {
var res = this.evaluate( this.buttonInfo[buttonId].formula );
this.buttonInfo[buttonId].elm.disabled = !isNaN( res ) && !almostEquals( res, 0 );
} else {
console.log( 'Tried to refresh disable status for ' + buttonId + ' but there is no such button.' );
}
}
return;
};
// Set a data attribute, classes, etc. For ease of targeting via CSS.
CalculatorWidgets.prototype.setValueProperties = function ( elm, value ) {
var itemId = elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' );
elm.dataset.calculatorFieldValue = value.toFixed(5);
if ( !itemId.match( /^auto\d\d\d\d+unnamed$/ ) ) {
this.parent.style.setProperty( '--calculator-' + itemId, value );
}
if ( !isNaN( value ) && !almostEquals( value, 0 ) ) {
elm.classList.remove( 'calculator-value-false' );
elm.classList.add( 'calculator-value-true' );
} else {
elm.classList.remove( 'calculator-value-true' );
elm.classList.add( 'calculator-value-false' );
}
};
CalculatorWidgets.prototype.refresh = function (itemIds) {
var i;
// Based on https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search
var permMarks = Object.create(null);
var tempMarks = Object.create(null);
var topList = [];
var that = this;
if ( !itemIds ) {
return;
}
var visit = function( item ) {
var i;
if ( permMarks[item] ) {
return;
}
if ( tempMarks[item] ) {
if ( !item.match( /^--radio-/ ) ) {
// Loops are expected for radio buttons.
console.log( "Loop detected in calculator. '" + item + "' may not be updated properly. (This is expected for radio buttons)" );
}
return;
}
tempMarks[item] = true;
for ( i = 0; that.backlinks[item] && i < that.backlinks[item].length; i++ ) {
visit(that.backlinks[item][i]);
}
// Special case for index()
if ( item.match( /[0-9]+$/ ) ) {
var staritem = item.replace( /[0-9]+$/, '*' );
for ( i = 0; that.backlinks[staritem] && i < that.backlinks[staritem].length; i++ ) {
visit(that.backlinks[staritem][i]);
}
}
permMarks[item] = true;
topList.push(item); // later we iterate backwards through this list.
};
for ( i = 0; i < itemIds.length; i++ ) {
if ( itemIds[i] !== undefined ) {
visit( itemIds[i] );
}
}
for ( i = topList.length - 1; i >= 0; i-- ) {
var itemId = topList[i];
if ( this.inProgressRefresh[itemId] ) {
// It is expected radio buttons loop, since they are depend on each other. If you check one, the others uncheck.
if ( this.elmList[itemId].type !== 'radio' ) {
console.log( "Loop Detected! Skipping " + itemId );
}
continue;
}
if ( itemId.match( /^--/ ) ) {
// A special purpose identifier
this.setValuePropertiesSpecialPurpose( itemId );
this.inProgressRefresh[itemId] = true;
continue;
} else if ( !this.itemList[itemId] || this.itemList[itemId] instanceof Null ) {
// This mostly should not happen but might if refresh on page load is set or with radio buttons.
var elm = this.elmList[itemId];
this.setValueProperties( elm, getValueOfElm( elm, this.mappingInput[itemId] ) );
if ( this.elmList[itemId].type !== 'radio' ) {
console.log( "Tried to refresh field " + itemId + " with no formula" );
}
continue;
}
this.inProgressRefresh[itemId] = true;
var elm = this.elmList[itemId];
// Special handling for radio buttons to prevent loops from indirect clicks.
// If someone clicked a different radio button, we want to refresh dependencies of the unclicked button that is now deselected
// but we don't want to re-evaluate its formula which might reselect it.
if ( elm.type === 'radio' && this.inProgressRefresh['--radio-' + elm.name.replace( /^calcgadget-\d+-/, '' )] ) {
this.setValueProperties( elm, getValueOfElm( elm, this.mappingInput[itemId] ) );
continue;
}
var res = this.evaluate( this.itemList[itemId] );
this.setValue( elm, res );
}
};
CalculatorWidgets.prototype.setValue = function( elm, res ) {
this.setValueProperties( elm, res );
var mappingOut = this.mappingOutput[elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )]
if ( elm.tagName === 'INPUT' ) {
if ( elm.type === 'range' ) {
elm.value = res;
} else if ( elm.type === 'number' ) {
// We support some formatting types but not others
// as browser still expects this to be a number.
elm.setAttribute( 'data-calculator-real-value', res );
elm.value = formatNumeric( res, elm.dataset );
} else if ( elm.type === 'radio' || elm.type === 'checkbox' ) {
elm.checked = !isNaN( res ) && !almostEquals( res, 0 );
if ( !elm.checked && elm.dataset.calculatorValue ) {
// If we are unchecking this box, set its value back to the default
// so that if a human rechecks it, the value of the control is the default
// value and not the last value of the formula.
elm.value = elm.dataset.calculatorValue;
}
} else {
elm.setAttribute( 'data-calculator-real-value', res );
elm.value = format( res, elm.dataset, mappingOut );
}
} else if ( elm.tagName === 'SELECT' ) {
elm.value = res;
} else if ( elm.dataset.calculatorType !== 'passthru' ) {
// plain type.
elm.setAttribute( 'data-calculator-real-value', res );
elm.textContent = format( res, elm.dataset, mappingOut );
}
}
// Set a calculator container to busy, which pauses some screen readers until everything is updated.
// Returns a callback to unset to busy.
CalculatorWidgets.prototype.setBusy = function () {
var curVal = this.parent.getAttribute( 'aria-busy' );
if ( curVal === 'true' ) {
return function () {};
}
this.parent.setAttribute( 'aria-busy', 'true' );
var that = this;
return function () {
that.parent.setAttribute( 'aria-busy', 'false' );
};
}
CalculatorWidgets.prototype.evaluate = evaluate;
mw.hook( 'wikipage.content' ).add( setup );
} )();