MediaWiki:Common.js/calc.js

From Fallen London Wiki

Note: After saving, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Go to Menu → Settings (Opera → Preferences on a Mac) and then to Privacy & security → Clear browsing data → Cached images and files.
/** <nowiki>
 * 
 * Based on: https://runescape.wiki/w/MediaWiki:Gadget-calc-core.js?oldid=35795330
 *
 * @license GLPv3 <https://www.gnu.org/licenses/gpl-3.0.en.html>
 *
 */

/*jshint bitwise:true, browser:true, camelcase:true, curly:true, devel:false,
		eqeqeq:true, es3:false, forin:true, immed:true, jquery:true,
		latedef:true, newcap:true, noarg:true, noempty:true, nonew:true,
		onevar:false, plusplus:false, quotmark:single, undef:true, unused:true,
		strict:true, trailing:true
*/

/*global mw, OO */

'use strict';

	/**
	* Prefix of localStorage key for calc data. This is prepended to the form ID
	* localStorage name for autosubmit setting
	*/
var calcstorage = 'flw-calcsdata',
	calcautostorage = 'flw-calcsdata-autosub',
	/**
	* Local storage availability.
	*/
	hasLocalStorage;
	/**
	* Caching for search suggestions
	*
	* @todo implement caching for mw.TitleInputWidget accroding to https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.widgets.TitleWidget-cfg-cache
	*/
var cache = {},

	/**
	* Internal variable to store references to each calculator on the page.
	*/
	calcStore = {},

	/**
	* Private helper methods for `Calc`
	*
	* Most methods here are called with `Function.prototype.call`
	* and are passed an instance of `Calc` to access it's prototype
	*/
	helper = {
		/**
		* Add/change functionality of mw/OO.ui classes
		* Added support for multiple namespaces to mw.widgets.TitleInputWidget
		*/
		initClasses: function () {
			var hasOwn = Object.prototype.hasOwnProperty;
			/**
			* Get option widgets from the server response
			* Changed to add support for multiple namespaces
			* 
			* @param {Object} data Query result
			* @return {OO.ui.OptionWidget[]} Menu items
			*/
			mw.widgets.TitleInputWidget.prototype.getOptionsFromData = function (data) {
				var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects,
					currentPageName = new mw.Title( mw.config.get( 'wgRelevantPageName' ) ).getPrefixedText(),
					items = [],
					titles = [],
					titleObj = mw.Title.newFromText( this.getQueryValue() ),
					redirectsTo = {},
					pageData = {},
					namespaces = this.namespace.split('|').map(function (val) {return parseInt(val,10);});

				if ( data.redirects ) {
					for ( i = 0, len = data.redirects.length; i < len; i++ ) {
						redirect = data.redirects[ i ];
						redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || [];
						redirectsTo[ redirect.to ].push( redirect.from );
					}
				}

				for ( index in data.pages ) {
					suggestionPage = data.pages[ index ];

					// When excludeCurrentPage is set, don't list the current page unless the user has type the full title
					if ( this.excludeCurrentPage && suggestionPage.title === currentPageName && suggestionPage.title !== titleObj.getPrefixedText() ) {
						continue;
					}

					// When excludeDynamicNamespaces is set, ignore all pages with negative namespace
					if ( this.excludeDynamicNamespaces && suggestionPage.ns < 0 ) {
						continue;
					}
					pageData[ suggestionPage.title ] = {
						known: suggestionPage.known !== undefined,
						missing: suggestionPage.missing !== undefined,
						redirect: suggestionPage.redirect !== undefined,
						disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined,
						imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ),
						description: suggestionPage.description,
						// Sort index
						index: suggestionPage.index,
						originalData: suggestionPage
					};

					// Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true
					// and we encounter a cross-namespace redirect.
					if ( this.namespace === null || namespaces.indexOf(suggestionPage.ns) >= 0 ) {
						titles.push( suggestionPage.title );
					}

					redirects = hasOwn.call( redirectsTo, suggestionPage.title ) ? redirectsTo[ suggestionPage.title ] : [];
					for ( i = 0, len = redirects.length; i < len; i++ ) {
						pageData[ redirects[ i ] ] = {
							missing: false,
							known: true,
							redirect: true,
							disambiguation: false,
							description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title ),
							// Sort index, just below its target
							index: suggestionPage.index + 0.5,
							originalData: suggestionPage
						};
						titles.push( redirects[ i ] );
					}
				}

				titles.sort( function ( a, b ) {
					return pageData[ a ].index - pageData[ b ].index;
				} );

				// If not found, run value through mw.Title to avoid treating a match as a
				// mismatch where normalisation would make them matching (T50476)

				pageExistsExact = (
					hasOwn.call( pageData, this.getQueryValue() ) &&
					(
						!pageData[ this.getQueryValue() ].missing ||
						pageData[ this.getQueryValue() ].known
					)
				);
				pageExists = pageExistsExact || (
					titleObj &&
					hasOwn.call( pageData, titleObj.getPrefixedText() ) &&
					(
						!pageData[ titleObj.getPrefixedText() ].missing ||
						pageData[ titleObj.getPrefixedText() ].known
					)
				);

				if ( this.cache ) {
					this.cache.set( pageData );
				}

				// Offer the exact text as a suggestion if the page exists
				if ( this.addQueryInput && pageExists && !pageExistsExact ) {
					titles.unshift( this.getQueryValue() );
				}

				for ( i = 0, len = titles.length; i < len; i++ ) {
					page = hasOwn.call( pageData, titles[ i ] ) ? pageData[ titles[ i ] ] : {};
					items.push( this.createOptionWidget( this.getOptionWidgetData( titles[ i ], page ) ) );
				}

				return items;
			};
		},

		/**
		* Parse the calculator configuration
		*
		* @param lines {Array} An array containing the calculator's configuration
		* @returns {Object} An object representing the calculator's configuration
		*/
		parseConfig: function (lines) {
			var defConfig = {
					suggestns: [],
					autosubmit: 'off',
					name: 'Calculator'
				},
				config = {
					// this isn't in `defConfig`
					// as it'll get overridden anyway
					tParams: []
				},
				// used for debugging incorrect config names
				validParams = [
					'form',
					'param',
					'result',
					'suggestns',
					'template',
					'module',
					'modulefunc',
					'name',
					'autosubmit'
				],
				// used for debugging incorrect param types
				validParamTypes = [
					'string',
					'article',
					'number', 'float',
					'int',
					'select',
					'buttonselect',
					'combobox',
					'check',
					'toggleswitch',
					'togglebutton',
					'fixed',
					'hidden',
					'group'
				],
				configError = false;

			// parse the calculator's config
			// @example param=arg1|arg1|arg3|arg4
			lines.forEach(function (line) {
				var temp = line.split('='),
					param,
					args;

				// incorrect config
				if (temp.length < 2) {
					return;
				}

				// an equals is used in one of the arguments
				// @example HTML label with attributes
				// so join them back together to preserve it
				// this also allows support of HTML attributes in labels
				if (temp.length > 2) {
					temp[1] = temp.slice(1,temp.length).join('=');
				}

				param = temp[0].trim().toLowerCase();
				args = temp[1].trim();

				if (validParams.indexOf(param) === -1) {
					console.warn('Unknown parameter: ' + param);
					configError = true;
					return;
				}

				if (param === 'suggestns') {
					config.suggestns = args.split(/\s*,\s*/);
					return;
				}

				if (param !== 'param') {
					config[param] = args;
					return;
				}

				// split args
				args = args.split(/\s*\|\s*/);

				// store template params in an array to make life easier
				config.tParams = config.tParams || [];

				if (validParamTypes.indexOf(args[3]) === -1 && args[3] !== '' && args[3] !== undefined) {
					console.warn('Unknown param type: ' + args[3]);
					configError = true;
					return;
				}
				
				if (args[3] === 'float') {
					args[3] = 'number';
				}

				var inlinehelp = false, help = '';
				if (args[6]) {
					var tmphelp = args[6].split(/\s*=\s*/);
					if (tmphelp.length > 1) {
						if ( tmphelp[0] === 'inline' ) {
							inlinehelp = true;
							// Html etc can have = so join them back together
							tmphelp[1] = tmphelp.slice(1,tmphelp.length).join('=');
							help = helper.sanitiseLabels(tmphelp[1] || '');
						} else {
							// Html etc can have = so join them back together
							tmphelp[0] = tmphelp.join('=');
							help = helper.sanitiseLabels(tmphelp[0] || '');
						}
					} else {
						help = helper.sanitiseLabels(tmphelp[0] || '');
					}
				}

				config.tParams.push({
					name: mw.html.escape(args[0]),
					label: helper.sanitiseLabels(args[1] || args[0]),
					def: args[2] || '',
					type: mw.html.escape(args[3] || ''),
					range: args[4] || '',
					rawtogs: args[5] || '',
					inlhelp: inlinehelp,
					help: help
				});
			});
			
			if (configError) {
				config.configError = 'This calculator\'s config contains errors. Please ' +
					'check the javascript console for details.';
			}

			config = $.extend(defConfig, config);
			console.log(config);
			return config;
		},

		/**
		* Generate a unique id for each input
		*
		* @param inputId {String} A string representing the id of an input
		* @returns {String} A string representing the namespaced/prefixed id of an input
		*/
		getId: function (inputId) {
			return [this.form, this.result, inputId].join('-').replace(/\W/g, '-');
		},

		/**
		* Output an error to the UI
		*
		* @param error {String} A string representing the error message to be output
		*/
		showError: function (error) {
			$('#' + this.result)
				.empty()
				.append(
					$('<span>')
						.addClass('jcError')
						.text(error)
				);
		},

		/**
		* Toggle the visibility and enabled status of fields/groups
		*
		* @param item {String} A string representing the current value of the widget
		* @param toggles {object} An object representing arrays of items to be toggled keyed by widget values
		*/
		toggle: function (item, toggles) {
			var self = this;

			var togitem = function (widget, show) {
				var param = self.tParams[ self.indexkeys[widget] ];
				if (param.type === 'group') {
					param.ooui.toggle(show);
					param.ooui.getItems().forEach(function (child) {
						if (!!child.setDisabled) {
							child.setDisabled(!show);
						} else if (!!child.getField && !!child.getField().setDisabled) {
							child.getField().setDisabled(!show);
						}
					});
				} else {
					param.layout.toggle(show);
					if (!!param.ooui.setDisabled) {
						param.ooui.setDisabled(!show);
					}
				}
			};

			if (toggles[item]) {
				toggles[item].on.forEach( function (widget) {
					togitem(widget, true);
				});
				toggles[item].off.forEach( function (widget) {
					togitem(widget, false);
				});
			} else if ( toggles.not0 && !isNaN(parseFloat(item)) && parseFloat(item) !== 0 ) {
				toggles.not0.on.forEach( function (widget) {
					togitem(widget, true);
				});
				toggles.not0.off.forEach( function (widget) {
					togitem(widget, false);
				});
			} else if (toggles.alltogs) {
				toggles.alltogs.off.forEach( function (widget) {
					togitem(widget, false);
				});
			}
		},

		/**
		* Generate range and step for number and int inputs
		*
		* @param rawdata {string} The string representation of the range and steps
		* @param type {string} The name of the field type (int or number)
		* @returns {array} An array containing the min value, max value, step and button step.
		*/
		genRange: function (rawdata,type) {
			var tmp = rawdata.split(/\s*,\s*/),
				rng = tmp[0].split(/\s*-\s*/),
				step = tmp[1] || '',
				bstep = tmp[2] || '',
				min, max,
				parseFunc;
			if (type==='int') {
				parseFunc = function(x) { return parseInt(x, 10); };
			} else {
				parseFunc = parseFloat;
			}

			if (type === 'int') {
				step = 1;
				if ( isNaN(parseInt(bstep,10)) ) {
					bstep = 1;
				} else {
					bstep = parseInt(bstep,10);
				}
			} else {
				if ( isNaN(parseFloat(step)) ) {
					step = 0.01;
				} else {
					step = parseFloat(step);
				}
				if ( isNaN(parseFloat(bstep)) ) {
					bstep = 1;
				} else {
					bstep = parseFloat(bstep);
				}
			}

			// Accept negative values for either range position
			if ( rng.length === 3 ) {
				// 1 value is negative
				if ( rng[0] === '' ) {
					// First value negative
					if ( isNaN(parseFunc(rng[1])) ) {
						min = -Infinity;
					} else {
						min = 0 - parseFunc(rng[1]);
					}
					if ( isNaN(parseFunc(rng[2])) ) {
						max = Infinity;
					} else {
						max = parseFunc(rng[2]);
					}
				} else if ( rng[1] === '' ) {
					// Second value negative
					if ( isNaN(parseFunc(rng[0])) ) {
						min = -Infinity;
					} else {
						min = parseFunc(rng[0]);
					}
					if ( isNaN(parseFunc(rng[2])) ) {
						max = 0;
					} else {
						max = 0 - parseFunc(rng[2]);
					}
				}
			} else if ( rng.length === 4 ) {
				// Both negative
				if ( isNaN(parseFunc(rng[1])) ) {
					min = -Infinity;
				} else {
					min = 0 - parseFunc(rng[1]);
				}
				if ( isNaN(parseFunc(rng[3])) ) {
					max = 0;
				} else {
					max = 0 - parseFunc(rng[3]);
				}
			} else {
				// No negatives
				if ( isNaN(parseFunc(rng[0])) ) {
					min = 0;
				} else {
					min = parseFunc(rng[0]);
				}
				if ( isNaN(parseFunc(rng[1])) ) {
					max = Infinity;
				} else {
					max = parseFunc(rng[1]);
				}
			}
			// Check min < max
			if ( max < min ) {
				return [ max, min, step, bstep ];
			} else {
				return [ min, max, step, bstep ];
			}
		},

		/**
		* Parse the toggles for an input
		*
		* @param rawdata {string} A string representing the toggles for the widget
		* @param defkey {string} The default key for toggles
		* @returns {object} An object representing the toggles in the format { ['widget value']:[ widget-to-toggle, group-to-toggle, widget-to-toggle2 ] }
		*/
		parseToggles: function (rawdata,defkey) {
			var tmptogs = rawdata.split(/\s*;\s*/),
				allkeys = [], allvals = [],
				toggles = {};

			if (tmptogs.length > 0 && tmptogs[0].length > 0) {
				tmptogs.forEach(function (tog) {
					var tmp = tog.split(/\s*=\s*/),
						keys = tmp[0],
						val = [];
					if (tmp.length < 2) {
						keys = [defkey];
						val = tmp[0].split(/\s*,\s*/);
					} else {
						keys = tmp[0].split(/\s*,\s*/);
						val = tmp[1].split(/\s*,\s*/);
					}
					if (keys.length === 1) {
						var key = keys[0];
						toggles[key] = {};
						toggles[key].on = val;
						allkeys.push(key);
					} else {
						keys.forEach( function (key) {
							toggles[key] = {};
							toggles[key].on = val;
							allkeys.push(key);
						});
					}
					allvals = allvals.concat(val);
				});

				allkeys = allkeys.filter(function (item, pos, arr) {
					return arr.indexOf(item) === pos;
				});

				allkeys.forEach(function (key) {
					toggles[key].off = allvals.filter(function (val) {
						if ( toggles[key].on.includes(val) ) {
							return false;
						} else {
							return true;
						}
					});
				});

				// Add all items to default
				toggles.alltogs = {};
				toggles.alltogs.off = allvals;
			}

			return toggles;
		},

		/**
		* Form submission handler
		*/
		submitForm: function () {
			var self = this,
				code = '{{' + self.template,
				formErrors = [],
				apicalls = [],
				paramVals = {};
			
			if (self.module !== undefined) {
				if (self.modulefunc === undefined) {
					self.modulefunc = 'main';
				}
				code = '{{#invoke:'+self.module+'|'+self.modulefunc;
			}

			self.submitlayout.setNotices(['Validating fields, please wait.']);
			self.submitlayout.fieldWidget.setDisabled(true);

			// setup template for submission
			self.tParams.forEach(function (param) {
				if ( param.type === 'hidden' || (param.type !== 'group' && param.ooui.isDisabled() === false) ) {
					var val,
						$input,
						// use separate error tracking for each input
						// or every input gets flagged as an error
						error = '';

					if (param.type === 'fixed' || param.type === 'hidden') {
						val = param.def;
					} else {
						$input = $('#' + helper.getId.call(self, param.name) + ' input');

						if (param.type === 'buttonselect') {
							val = param.ooui.findSelectedItem();
							if (val !== null) {
								val = val.getData();
							}
						} else {
							val = param.ooui.getValue();
						}

						if (param.type === 'int') {
							val = val.split(',').join('');
						} else if (param.type === 'check') {
							val = param.ooui.isSelected();

							if (param.range) {
								var opts;
								if (param.range.match(/;/) !== null) {
									opts = param.range.split(';');
								} else {
									opts = param.range.split(',');
								}
								val = opts[val ? 0 : 1];
							}
						} else if (param.type === 'toggleswitch' || param.type === 'togglebutton') {
							if (param.range) {
								var opts;
								if (param.range.match(/;/) !== null) {
									opts = param.range.split(';');
								} else {
									opts = param.range.split(',');
								}
								val = opts[val ? 0 : 1];
							}
						}

						// Check input is valid (based on widgets validation)
						if ( !!param.ooui.hasFlag && param.ooui.hasFlag('invalid') && param.type !== 'article') {
							error = param.error;
						} else if ( param.type === 'article' && param.ooui.validateTitle && val.length > 0 ) {
							var api = param.ooui.getApi(),
								prms = {
									action: 'query',
									prop: [],
									titles: [ param.ooui.getValue() ]
								};

							var prom = new Promise ( function (resolve,reject) {
								api.get(prms).then( function (ret) {
									if ( ret.query.pages && Object.keys(ret.query.pages).length ) {
										var nspaces = param.ooui.namespace.split('|'), allNS = false;
										if (nspaces.indexOf('*') >= 0) {
											allNS = true;
										}
										nspaces = nspaces.map(function (ns) {return parseInt(ns,10);});
										for (var pgID in ret.query.pages) {
											if ( ret.query.pages.hasOwnProperty(pgID) && ret.query.pages[pgID].missing!== '' ) {
												if ( allNS ) {
													resolve();
												}
												if ( ret.query.pages[pgID].ns !== undefined && nspaces.indexOf(ret.query.pages[pgID].ns) >= 0 ) {
													resolve();
												}
											}
										}
										reject(param);
									} else {
										reject(param);
									}
								});
							});
							apicalls.push(prom);
						}

						if (error) {
							param.layout.setErrors([error]);
							if (param.ooui.setValidityFlag !== undefined) {
								param.ooui.setValidityFlag(false);
							}
							// TODO: Remove jsInvalid classes?
							$input.addClass('jcInvalid');
							formErrors.push( param.label[0].textContent + ': ' + error );
						} else {
							param.layout.setErrors([]);
							if (param.ooui.setValidityFlag !== undefined) {
								param.ooui.setValidityFlag(true);
							}
							// TODO: Remove jsInvalid classes?
							$input.removeClass('jcInvalid');

							// Save current parameter value
							paramVals[param.name] = val;
							
							// Save current parameter value for later calculator usage.
							//window.localStorage.setItem(helper.getId.call(self, param.name), val);
						}
					}
					code += '|' + param.name + '=' + val;
				}
			});

			Promise.all(apicalls).then( function (vals) {
				// All article fields valid
				self.submitlayout.setNotices([]);
				self.submitlayout.fieldWidget.setDisabled(false);

				if (formErrors.length > 0) {
					self.submitlayout.setErrors(formErrors);
					helper.showError.call(self, 'One or more fields contains an invalid value.');
					return;
				}

				self.submitlayout.setErrors([]);

				if (!hasLocalStorage) {
					console.warn('Browser does not support localStorage, inputs will not be saved.');
				} else {
					console.log('Saving inputs to localStorage');
					paramVals.autosubmit = !!self.autosubmit;
					localStorage.setItem(self.localname, JSON.stringify(paramVals));
					localStorage.setItem(self.localauto, paramVals.autosubmit);
				}

				code += '}}';
				console.log(code);
				helper.loadTemplate.call(self, code);

			}, function (errparam) {
				// An article field is invalid
				self.submitlayout.setNotices([]);
				self.submitlayout.fieldWidget.setDisabled(false);

				errparam.layout.setErrors([errparam.error]);
				formErrors.push( errparam.label[0].textContent + ': ' + errparam.error );

				self.submitlayout.setErrors(formErrors);
				helper.showError.call(self, 'One or more fields contains an invalid value.');
				return;
			});
		},

		/**
		* Parse the template used to display the result of the form
		*
		* @param code {string} Wikitext to send to the API for parsing
		*/
		loadTemplate: function (code) {
			var self = this,
				params = {
					action: 'parse',
					text: code,
					prop: 'text',
					title: mw.config.get('wgPageName'),
					disablelimitreport: 'true',
					contentmodel: 'wikitext',
					format: 'json'
				};

			$('#' + self.form + ' .jcSubmit')
				.data('oouiButton')
				.setDisabled(true);

			// @todo time how long these calls take
			$.post('/w/api.php', params)
				.done(function (response) {
					var html = response.parse.text['*'];
					helper.dispResult.call(self, html);
				})
				.fail(function (_, error) {
					$('#' + self.form + ' .jcSubmit')
						.data('oouiButton')
						.setDisabled(false);
					helper.showError.call(self, error);
				});
		},

		/**
		* Display the calculator result on the page
		*
		* @param response {String} A string representing the HTML to be added to the page
		*/
		dispResult: function (html) {
			var self = this;
			$('#' + self.form + ' .jcSubmit')
				.data('oouiButton')
				.setDisabled(false);

			$('#bodyContent, #WikiaArticle')
				.find('#' + this.result)
					.empty()
					.removeClass('jcError')
					.html(html);

			mw.loader.using('jquery.tablesorter', function () {
				$('table.sortable:not(.jquery-tablesorter)').tablesorter();
			});
			mw.loader.using('jquery.makeCollapsible', function () {
				$('.mw-collapsible').makeCollapsible();
			});
		},

		/**
		* Sanitise any HTML used in labels
		*
		* @param html {string} A HTML string to be sanitised
		* @returns {jQuery.object} A jQuery object representing the sanitised HTML
		*/
		sanitiseLabels: function (html) {
			var whitelistAttrs = [
					// mainly for span/div tags
					'style',
					// for anchor tags
					'href',
					'title',
					// for img tags
					'src',
					'alt',
					'height',
					'width',
					// misc
					'class'
				],
				whitelistTags = [
					'a',
					'span',
					'div',
					'img',
					'strong',
					'b',
					'em',
					'i',
					'br'
				],
				// parse the HTML string, removing script tags at the same time
				$html = $.parseHTML(html, /* document */ null, /* keepscripts */ false),
				// append to a div so we can navigate the node tree
				$div = $('<div>').append($html);

			$div.find('*').each(function () {
				var $this = $(this),
					tagname = $this.prop('tagName').toLowerCase(),
					attrs,
					array,
					href;

				if (whitelistTags.indexOf(tagname) === -1) {
					console.warn('Disallowed tagname: ' + tagname);
					$this.remove();
					return;
				}

				attrs = $this.prop('attributes');
				array = Array.prototype.slice.call(attrs);

				array.forEach(function (attr) {
					if (whitelistAttrs.indexOf(attr.name) === -1) {
						console.warn('Disallowed attribute: ' + attr.name + ', tagname: ' + tagname);
						$this.removeAttr(attr.name);
						return;
					}

					// make sure there's nasty in nothing in href attributes
					if (attr.name === 'href') {
						href = $this.attr('href');

						if (
							// disable warnings about script URLs
							// jshint -W107
							href.indexOf('javascript:') > -1 ||
							// the mw sanitizer doesn't like these
							// so lets follow suit
							// apparently it's something microsoft dreamed up
							href.indexOf('vbscript:') > -1
							// jshint +W107
						) {
							console.warn('Script URL detected in ' + tagname);
							$this.removeAttr('href');
						}
					}
				});
			});

			return $div.contents();
		},
		/**
		* Custom handler to check the validity of int and number inputs because the default
		* OOUI validator is bugged.
		* 
		* @param value {string} Optional value to check
		* @returns {boolean}
		*/
		checkNumberValidity: function(value) {
			var self = this.tParam !== undefined ? this.tParam : this;
			value = value !== undefined ? value : self.value;
			var n = + value;
			if (value === '' && self.isRequired) {
				return !self.isRequired();
			}
			if (isNaN(n) || !isFinite(n)) {
				return false;
			}
			if (self.step && Math.abs(Math.round(n / self.step) - (n / self.step)) > 1e-12) {
				return false;
			}
			if (n < self.min || n > self.max) {
				return false;
			}
			return true;
		},

		/**
		* Handlers for parameter input types
		*/
		tParams: {
			/**
			* Handler for 'fixed' inputs
			*
			* @param param {object} An object containing the configuration of a parameter
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			fixed: function (param) {
				var layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-fixed'],
						value: param.def
					};

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}

				param.ooui = new OO.ui.LabelWidget({ label: param.def });
				return new OO.ui.FieldLayout(param.ooui, layconf);
			},

			/**
			* Handler for select dropdowns
			* 
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			select: function (param, id) {
				var self = this,
					conf = {
						label: 'Select an option',
						options: [],
						name: id,
						id: id,
						value: param.def
					},
					layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-select']
					};
				var opts;
				if (param.range.match(/;/) !== null) {
					opts = param.range.split(';');
				} else {
					opts = param.range.split(',');
				}
				var def = opts[0];
				param.error = 'Not a valid selection';

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}

				opts.forEach(function (opt) {
					var op = { data: opt, label: opt };

					if (opt === param.def) {
						op.selected = true;
						def = opt;
					}

					conf.options.push(op);
				});

				param.toggles = helper.parseToggles(param.rawtogs, def);

				param.ooui = new OO.ui.DropdownInputWidget(conf);
				if ( Object.keys(param.toggles).length > 0 ) {
					param.ooui.on('change', function (value) {
						helper.toggle.call(self, value, param.toggles);
					});
				}
				return new OO.ui.FieldLayout(param.ooui, layconf);
			},


			/**
			* Handler for button selects
			*
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			buttonselect: function (param, id) {
				var self = this,
					buttons = {},
					conf = {
						label:'Select an option',
						items: [],
						id: id
					},
					layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-buttonselect']
					},
					def;
				var opts;
				if (param.range.match(/;/) !== null) {
					opts = param.range.split(';');
				} else {
					opts = param.range.split(',');
				}
				param.error = 'Please select a valid option';

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}

				opts.forEach(function (opt) {
					var opid = opt.replace(/[^a-zA-Z0-9]/g, '');
					buttons[opid] = new OO.ui.ButtonOptionWidget({data:opt, label:opt, title:opt});
					conf.items.push(buttons[opid]);
				});

				if (param.def.length > 0 && opts.indexOf(param.def) > -1) {
					def = param.def;
				} else {
					def = opts[0];
				}

				param.toggles = helper.parseToggles(param.rawtogs, def);

				param.ooui = new OO.ui.ButtonSelectWidget(conf);
				param.ooui.selectItemByData(def);
				if ( Object.keys(param.toggles).length > 0 ) {
					param.ooui.on('choose', function (button) {
						var item = button.getData();
						helper.toggle.call(self, item, param.toggles);
					});
				}
				return new OO.ui.FieldLayout(param.ooui, layconf);
			},

			/**
			* Handler for comboboxes
			* 
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			combobox: function (param, id) {
				var self = this,
					conf = {
						placeholder: 'Start typing to see suggestions',
						options: [],
						name: id,
						id: id,
						menu: { filterFromInput: true },
						value: param.def
					},
					layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-combobox']
					};
				var opts;
				if (param.range.match(/;/) !== null) {
					opts = param.range.split(';');
				} else {
					opts = param.range.split(',');
				}
				var def = opts[0];
				param.error = 'Not a valid selection';

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}
				
				var goodDefault = opts.indexOf(param.def) >= 0;
				var first = true;

				opts.forEach(function (opt) {
					var op = { data: opt, label: opt };

					if (!goodDefault && first) {
						op.selected = true;
						conf.value = opt;
					}
					if (opt === param.def) {
						op.selected = true;
						def = opt;
					}

					conf.options.push(op);
					first = false;
				});

				var isvalid = function (val) {return opts.indexOf(val) < 0 ? false : true;};
				conf.validate = isvalid;

				param.toggles = helper.parseToggles(param.rawtogs, def);

				param.ooui = new OO.ui.ComboBoxInputWidget(conf);
				if ( Object.keys(param.toggles).length > 0 ) {
					param.ooui.on('change', function (value) {
						helper.toggle.call(self, value, param.toggles);
					});
				}
				return new OO.ui.FieldLayout(param.ooui, layconf);
			},

			/**
			* Handler for checkbox inputs
			* 
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			check: function (param, id) {
				var self = this,
					conf = {
						name: id,
						id: id
					},
					layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-check']
					};
				param.toggles = helper.parseToggles(param.rawtogs, 'true');
				param.error = 'Unknown error';

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}
				
				var opts;
				if (param.range.match(/;/) !== null) {
					opts = param.range.split(';');
				} else {
					opts = param.range.split(',');
				}
				if ( param.def === 'true' ||
					(param.range !== undefined && param.def === opts[0]) ) {
					conf.selected = true;
				}

				param.ooui = new OO.ui.CheckboxInputWidget(conf);
				if ( Object.keys(param.toggles).length > 0 ) {
					param.ooui.on('change', function (selected) {
						if (selected) {
							helper.toggle.call(self, 'true', param.toggles);
						} else {
							helper.toggle.call(self, 'false', param.toggles);
						}
					});
				}
				return new OO.ui.FieldLayout(param.ooui, layconf);
			},

			/**
			* Handler for toggle switch inputs
			*
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			toggleswitch: function (param, id) {
				var self = this,
					conf = { id: id },
					layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-toggleswitch']
					};
				param.toggles = helper.parseToggles(param.rawtogs, 'true');
				param.error = 'Unknown error';

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}
				
				var opts;
				if (param.range.match(/;/) !== null) {
					opts = param.range.split(';');
				} else {
					opts = param.range.split(',');
				}
				if ( param.def === 'true' ||
					(param.range !== undefined && param.def === opts[0]) ) {
					conf.value = true;
				}

				param.ooui = new OO.ui.ToggleSwitchWidget(conf);
				if ( Object.keys(param.toggles).length > 0 ) {
					param.ooui.on('change', function (selected) {
						if (selected) {
							helper.toggle.call(self, 'true', param.toggles);
						} else {
							helper.toggle.call(self, 'false', param.toggles);
						}
					});
				}
				return new OO.ui.FieldLayout(param.ooui, layconf);
			},

			/**
			* Handler for toggle button inputs
			*
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			togglebutton: function (param, id) {
				var self = this,
					conf = {
						id: id,
						label: new OO.ui.HtmlSnippet(param.label)
					},
					layconf = {
						label:'',
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-togglebutton']
					};
				param.toggles = helper.parseToggles(param.rawtogs, 'true');
				param.error = 'Unknown error';

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}

				var opts;
				if (param.range.match(/;/) !== null) {
					opts = param.range.split(';');
				} else {
					opts = param.range.split(',');
				}
				if ( param.def === 'true' ||
					(param.range !== undefined && param.def === opts[0]) ) {
					conf.value = true;
				}

				param.ooui = new OO.ui.ToggleButtonWidget(conf);
				if ( Object.keys(param.toggles).length > 0 ) {
					param.ooui.on('change', function (selected) {
						if (selected) {
							helper.toggle.call(self, 'true', param.toggles);
						} else {
							helper.toggle.call(self, 'false', param.toggles);
						}
					});
				}
				return new OO.ui.FieldLayout(param.ooui, layconf);
			},
			
			/**
			* Handler for integer inputs
			* 
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			int: function (param, id) {
				var self = this,
					rng = helper.genRange(param.range, 'int'),
					conf = {
						min:rng[0],
						max:rng[1],
						step:rng[2],
						showButtons:true,
						buttonStep:rng[3],
						allowInteger:true,
						name: id,
						id: id,
						value: param.def || 0,
						inputFilter: function(val) {
							return parseInt(val, 10).toString();
						}
					},
					layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-int']
					},
					error = 'Invalid integer provided. Must be between ' + rng[0] + ' and ' + rng[1];
				param.toggles = helper.parseToggles(param.rawtogs, 'not0');

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}

				if ( rng[2] > 1 ) {
					error += ' and a multiple of ' + rng[2];
				}
				param.error = error;


				param.ooui = new OO.ui.NumberInputWidget(conf);
				param.ooui.validate = helper.checkNumberValidity;
				param.ooui.$input[0].checkValidity = helper.checkNumberValidity;
				if ( Object.keys(param.toggles).length > 0 ) {
					param.ooui.on('change', function (value) {
						helper.toggle.call(self, value, param.toggles);
					});
				}
				return new OO.ui.FieldLayout(param.ooui, layconf);
			},
			
			/**
			* Handler for number inputs
			* 
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			number: function (param, id) {
				var self = this,
					rng = helper.genRange(param.range, 'number'),
					conf = {
						min:rng[0],
						max:rng[1],
						step:rng[2],
						showButtons:true,
						buttonStep:rng[3],
						name:id,
						id:id,
						value:param.def || 0,
						//Use deafult filter (this filter 0.0 into 0 which is inconvenient when the 1st decimal is 0)
						/*inputFilter: function(val) {
							return (Math.round(parseFloat(val) * 1e12) / 1e12).toString();
						}*/
					},
					layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align: 'right',
						classes: ['jsCalc-field', 'jsCalc-field-number'],
					};
				param.toggles = helper.parseToggles(param.rawtogs, 'not0');
				param.error = 'Invalid number provided. Must be between ' + rng[0] + ' and ' + rng[1] + ' and a multiple of ' + rng[2];

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}

				param.ooui = new OO.ui.NumberInputWidget(conf);
				param.ooui.validate = helper.checkNumberValidity;
				param.ooui.$input[0].checkValidity = helper.checkNumberValidity;
				if ( Object.keys(param.toggles).length > 0 ) {
					param.ooui.on('change', function (value) {
						helper.toggle.call(self, value, param.toggles);
					});
				}
				return new OO.ui.FieldLayout( param.ooui, layconf);
			},

			/**
			* Handler for article inputs
			*
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			article: function (param, id) {
				var self = this,
					conf = {
						addQueryInput: false,
						excludeCurrentPage: true,
						showMissing: false,
						showDescriptions: true,
						validateTitle: true,
						relative: false,
						id: id,
						name: id,
						placeholder: 'Enter page name',
						value: param.def
					},
					layconf = {
						label: new OO.ui.HtmlSnippet(param.label),
						align:'right',
						classes: ['jsCalc-field', 'jsCalc-field-article']
					},
					validNSnumbers = {
						'_*':'All', '_-2':'Media', '_-1':'Special',
						_0:'(Main)', _1:'Talk',
						_2:'User', _3:'User talk',
						_4:'Fallen London Wiki', _5:'Fallen London Wiki talk',
						_6:'File', _7:'File talk',
						_8:'MediaWiki', _9:'MediaWiki talk',
						_10:'Template', _11:'Template talk',
						_12:'Help', _13:'Help talk',
						_14:'Category', _15:'Category talk',
						_102:'Property', _103:'Property talk',
						_108:'Concept', _109:'Concept talk',
						_110:'Forum',
						_112:'smw/schema', _113:'smw/schema talk',
						_114:'Rule', _115:'Rule talk',
						_274:'Widget', _275:'Widget talk',
						_500:'Blog', _501:'Blog talk',
						_828:'Module', _829:'Module talk',
						_844:'CommentStreams', _845:'CommentStreams Talk',
						_1200:'Message Wall', _1201:'Thread', _1202:'Message Wall Greeting',
						_2000:'Board',
						_2300:'Gadget', _2301:'Gadget talk',
						_2302:'Gadget definition', _2303:'Gadget definition talk',
						_3000:'Grind'
					},
					validNSnames = {
						all:'*', media:-2, special:-1,
						main:0, '(main)':0, talk:1,
						user:2, 'user talk':3,
						flwiki:4, 'flwiki talk':5,
						file:6, 'file talk':7,
						mediawiki:8, 'mediawiki talk':9,
						template:10, 'template talk':11,
						help:12, 'help talk':13,
						category:14, 'category talk':15,
						property:102, 'property talk':103,
						concept:108, 'concept talk':109,
						forum:110,
						'smw/schema':112, 'smw/schema talk':113,
						rule:114, 'rule talk':115,
						widget:274, 'widget talk':275,
						blog:500, 'blog talk':501,
						module:828, 'module talk':829,
						comments:844, 'comments talk':845,
						'message wall':1200, thread:1201, 'message wall greeting':1202,
						board:2000,
						gadget:2300, 'gadget talk': 2301,
						'gadget definition':2302, 'gadget definition talk':2303,
						grind:3000
					},
					namespaces = '';

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}

				if (param.range && param.range.length > 0) {
					var names = param.range.split(/\s*,\s*/),
					nsnumbers = [];
					names.forEach( function (nmspace) {
						nmspace = nmspace.toLowerCase();
						if ( validNSnumbers['_'+nmspace] ) {
							nsnumbers.push(nmspace);
						} else if ( validNSnames[nmspace] ) {
							nsnumbers.push( validNSnames[nmspace] );
						}
					});
					if (nsnumbers.length < 1) {
						conf.namespace = '0';
						namespaces = '(Main) namespace';
					} else if (nsnumbers.length < 2) {
						conf.namespace = nsnumbers[0];
						namespaces = nsnumbers[0] + ' namespace';
					} else {
						conf.namespace = nsnumbers.join('|');
						var nsmap = function (num) {
							return validNSnumbers['_'+num];
						};
						namespaces = nsnumbers.slice(0, -1).map(nsmap).join(', ') + ' or ' + nsnumbers.slice(-1).map(nsmap)[0] + ' namespaces';
					}
				} else if ( self.suggestns && self.suggestns.length > 0 ) {
					var nsnumbers = [];
					self.suggestns.forEach( function (nmspace) {
						nmspace = nmspace.toLowerCase();
						if ( validNSnumbers['_'+nmspace] ) {
							nsnumbers.push(nmspace);
						} else if ( validNSnames[nmspace] ) {
							nsnumbers.push( validNSnames[nmspace] );
						}
					});
					if (nsnumbers.length < 1) {
						conf.namespace = '0';
						namespaces = '(Main) namespace';
					} else if (nsnumbers.length < 2) {
						conf.namespace = nsnumbers[0];
						namespaces = nsnumbers[0] + ' namespace';
					} else {
						conf.namespace = nsnumbers.join('|');
						var nsmap = function (num) {
							return validNSnumbers['_'+num];
						};
						namespaces = nsnumbers.slice(0, -1).map(nsmap).join(', ') + ' or ' + nsnumbers.slice(-1).map(nsmap)[0] + ' namespaces';
					}
				} else {
					conf.namespace = '0';
					namespaces = '(Main) namespace';
				}

				param.error = 'Invalid page or page is not in ' + namespaces;

				param.ooui = new mw.widgets.TitleInputWidget(conf);
				return new OO.ui.FieldLayout( param.ooui, layconf);
			},

			/**
			* Handler for group type params
			*
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			group: function (param, id) {
				param.ooui = new OO.ui.HorizontalLayout({id: id, classes: ['jsCalc-group']});
				if (param.label !== param.name) {
					var label = new OO.ui.LabelWidget({ label: new OO.ui.HtmlSnippet(param.label), classes:['jsCalc-grouplabel'] });
					param.ooui.addItems([label]);
				}

				return param.ooui;
			},
			
			/**
			* Default handler for inputs
			* 
			* @param param {object} An object containing the configuration of a parameter
			* @param id {String} A string representing the id to be added to the input
			* @returns {OOUI.object} A OOUI object containing the new FieldLayout
			*/
			def: function (param, id) {
				var layconf = {
					label: new OO.ui.HtmlSnippet(param.label),
					align: 'right',
					classes: ['jsCalc-field', 'jsCalc-field-string'],
					value: param.def
				};
				param.error = 'Unknown error';

				if (param.help) {
					layconf.helpInline = param.inlhelp;
					layconf.help = new OO.ui.HtmlSnippet(param.help);
				}

				param.ooui = new OO.ui.TextInputWidget({type: 'text', name: id, id: id, value: param.def});
				return new OO.ui.FieldLayout(param.ooui, layconf);
			}
		}
	};

/**
 * Create an instance of `Calc`
 * and parse the config stored in `elem`
 *
 * @param elem {Element} An Element representing the HTML tag that contains
 *		the calculator's configuration
 */
function Calc(elem) {
	var self = this,
		$elem = $(elem),
		lines,
		config;
		
	// support div tags for config as well as pre
	// be aware using div tags relies on wikitext for parsing
	// so you can't use anchor or img tags
	// use the wikitext equivalent instead
	if ($elem.children().length) {
		$elem = $elem.children();
		lines = $elem.html();
	} else {
		// .html() causes html characters to be escaped for some reason
		// so use .text() instead for <pre> tags
		lines = $elem.text();
	}
	
	lines = lines.split('\n');
	
	config = helper.parseConfig.call(this, lines);

	// Calc name for localstorage, keyed to calc id
	var page = mw.config.get('wgPageName') || '???';
	this.localname = calcstorage + '-' + page + '-' + config.form;
	this.localauto = calcautostorage + '-' + page + '-' + config.form;
	
	if (!hasLocalStorage) {
		console.warn('Browser does not support localStorage, will skip loading form values.');
	} else {
		console.log('Loading previous calculator values');
		if (config.autosubmit === 'option') {
			self.storedAutosubmit = localStorage.getItem(this.localauto) || false;
		}
		var calcdata = JSON.parse(localStorage.getItem(this.localname)) || false;
		if (calcdata) {
			config.tParams.forEach(function(param) {
				if (calcdata[param.name] !== undefined && calcdata[param.name] !== null) {
					param.def = calcdata[param.name];
				}
			});
		}
		console.log(config);
	}

	// merge config in
	$.extend(this, config);

	/**
	* @todo document
	*/
	this.getInput = function (id) {
		if (id) {
			id = helper.getId.call(self, id);
			return $('#' + id);
		}
		
		return $('#jsForm-' + self.form).find('select, input');
	};
}

/**
 * Helper function for getting the id of an input
 *
 * @param id {string} The id of the input as specified by the calculator config.
 * @returns {string} The true id of the input with prefixes.
 */
Calc.prototype.getId = function (id) {
	var self = this,
		inputId = helper.getId.call(self, id);

	return inputId;
};

/**
 * Build the calculator form
 */
Calc.prototype.setupCalc = function () {
	var self = this,
		fieldset = new OO.ui.FieldsetLayout({label: self.name, classes: ['jcTable'], id: 'jsForm-'+self.form}),
		submitButton, submitButtonAction, autosubmit, paramChangeAction,
		groupkeys = {};

	// Used to store indexes of elements to toggle them later
	self.indexkeys = {};

	self.tParams.forEach(function (param, index) {
		// can skip any output here as the result is pulled from the
		// param default in the config on submission
		if (param.type === 'hidden') {
			return;
		}

		var id = helper.getId.call(self, param.name),
			method = helper.tParams[param.type] ?
				param.type :
				'def';

		// Generate list of items in group
		if (param.type === 'group') {
			var fields = param.range.split(/\s*,\s*/);
			fields.forEach( function (field) {
				groupkeys[mw.html.escape(field)] = index;
			});
		}

		param.layout = helper.tParams[method].call(self, param, id);
		
		// Add to group or form
		if ( groupkeys[param.name] || groupkeys[param.name] === 0 ) {
			self.tParams[ groupkeys[param.name] ].ooui.addItems([param.layout]);
		} else {
			fieldset.addItems([param.layout]);
		}

		// Add item to indexkeys
		self.indexkeys[param.name] = index;
	});

	// Run toggle for each field, check validity
	self.tParams.forEach( function (param) {
		if (param.toggles && Object.keys(param.toggles).length > 0) {
			var val;
			if (param.type === 'buttonselect') {
				val = param.ooui.findSelectedItem().getData();
			} else if (param.type === 'check') {
				val = param.ooui.isSelected() ? 'true' : 'false';
			} else if (param.type === 'toggleswitch' || param.type === 'togglebutton') {
				val = param.ooui.getValue() ? 'true' : 'false';
			} else {
				val = param.ooui.getValue();
			}
			helper.toggle.call(self, val, param.toggles);
		}
		if (param.type === 'number' || param.type === 'int') {
			param.ooui.setValidityFlag();
		}
	});

	
	submitButton = new OO.ui.ButtonInputWidget({ label: 'Submit', flags: ['primary', 'progressive'], classes: ['jcSubmit']});
	submitButtonAction = function (){
				helper.submitForm.call(self);
	};
	submitButton.on('click', submitButtonAction);
	submitButton.$element.data('oouiButton', submitButton);

	self.submitlayout = new OO.ui.FieldLayout(submitButton, {label: ' ', align: 'right', classes: ['jsCalc-field', 'jsCalc-field-submit']});
	fieldset.addItems([ self.submitlayout ]);
	
	// Auto-submit
	if (self.autosubmit !== 'off') {
		if (self.autosubmit === 'option') {
			// Add toggle to fieldset
			autosubmit = new OO.ui.ToggleSwitchWidget({
				value: self.storedAutosubmit || false
			});
			autosubmit.on('change', function (value) { self.autosubmit = value; });
			fieldset.addItems([
				new OO.ui.FieldLayout(
					autosubmit,
					{ label:'Auto-submit', align:'right', classes:['jsCalc-field', 'jsCalc-field-autosubmit'] }
				)
			]);
			self.autosubmit = self.storedAutosubmit || false;
		}
		if (self.autosubmit === 'on' || self.autosubmit === 'true') {
			self.autosubmit = true;
		}
		
		// Add event
		paramChangeAction = function (widget) {
			if (self.autosubmit === true) {
				if ( typeof widget.getFlagsa === 'undefined' || !widget.getFlags().includes('invalid')) {
					helper.submitForm.call(self);
				}
			}
		};
		var timeout;
		// We only want one of these pending at once
		function timeoutFunc(param) {
			clearTimeout(timeout);
			timeout = setTimeout(paramChangeAction, 500, param);
		}
		
		self.tParams.forEach( function (param) {
			if (param.type === 'hidden' || param.type === 'group') {
				return;
			} else if (param.type === 'buttonselect') {
				param.ooui.on('select', timeoutFunc, [param.ooui]);
			}
			param.ooui.on('change', timeoutFunc, [param.ooui]);
		});
	}
	
	if (self.configError) {
		fieldset.$element.append('<br>', self.configError);
	}

	$('#bodyContent')
		.find('#' + self.form)
			.empty()
			.append(fieldset.$element);
};

/**
 * @todo
 */
function init() {
	// Is local storage available?
	try {
		localStorage.setItem('test', 'test');
		localStorage.removeItem('test');
		hasLocalStorage = true;
	} catch(e) {
		hasLocalStorage = false;
	}
	
	// Initialises class changes
	helper.initClasses();

	$('.jcConfig').each(function () {
		var c = new Calc(this);
		c.setupCalc();
		
		calcStore[c.form] = c;

		// if (c.autosubmit === 'true' || c.autosubmit === true) {
		//	helper.submitForm.call(c);
		// }
	});
}

if ($('.jcConfig').length) {
	mw.loader.using(['oojs-ui-core', 'mediawiki.widgets'], function (){
		$(init);
	});
}

// </nowiki>