MediaWiki:Gadget-calc-core.js

From RuneRealm Wiki

This is an old revision of this page, as edited by Alex (talk | contribs) at 00:10, 17 October 2024. The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

Jump to navigation Jump to search

After saving, you may need to bypass your browser's cache to see the changes. For further information, see Wikipedia:Bypass your cache.

  • In most Windows and Linux browsers: Hold down Ctrl and press F5.
  • In Safari: Hold down ⇧ Shift and click the Reload button.
  • In Chrome and Firefox for Mac: Hold down both ⌘ Cmd+⇧ Shift and press R.
/**
 * Calc script for RuneScape Wiki
 * 
 * MAIN SCRIPT	https://runescape.wiki/w/MediaWiki:Gadget-calc.js
 *				https://runescape.wiki/w/MediaWiki:Gadget-calc.css
 * DUPLICATE TO	https://oldschool.runescape.wiki/w/MediaWiki:Gadget-calc.js
 *				https://oldschool.runescape.wiki/w/MediaWiki:Gadget-calc.css
 * make sure to update the hiscores URL for OSRS
 * 
 * This script exposes the following hooks, accessible via `mw.hook`:
 *     1. 'rscalc.setupComplete' - Fires when all calculator forms have been added to the DOM.
 *     2. 'rscalc.submit' - Fires when a calculator form has been submitted and the result has
 *                          been added to the DOM.
 * For instructions on how to use `mw.hook`, see <https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.hook>
 *
 * @see Documentation <https://runescape.wiki/w/RuneScape:Calculators/Form_calculators>
 * @see Tests <https://runescape.wiki/w/RuneScape:Calculators/Form_calculators/Tests>
 *
 * @license GLPv3 <https://www.gnu.org/licenses/gpl-3.0.en.html>
 *
 * @author Quarenon
 * @author TehKittyCat
 * @author Joeytje50
 * @author Cook Me Plox
 * @author Gaz Lloyd
 * @author Cqm
 * @author Elessar2
 *
 * @todo Whitelist domains for href attributes when sanitising HTML?
 * @todo if we get cross-wiki imports, add a way to change hiscores URL
 */

/*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 mediaWiki, mw, rswiki, rs, OO */

'use strict';

    /**
     * Prefix of localStorage key for calc data. This is prepended to the form ID
     * localStorage name for autosubmit setting
     */
var calcstorage = 'rsw-calcsdata',
    calcautostorage = 'rsw-calcsdata-allautosub',
    /**
     * 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
     */
    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.mw.pages ) {
                    suggestionPage = data.mw.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 mw.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 mw.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',
                    'int',
                    'select',
                    'buttonselect',
                    'combobox',
                    'check',
                    'toggleswitch',
                    'togglebutton',
                    'hs',
                    'rsn',
                    'fixed',
                    'hidden',
                    'semihidden',
                    '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) {
                    // use console for easier debugging
                    console.log('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) {
                    // use console for easier debugging
                    console.log('Unknown param type: ' + args[3]);
                    configError = true;
                    return;
                }

                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: mw.html.escape(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 report it ' +
                    '<a href="/w/RuneScape:User_help" title="RuneScape:User help">here</a> ' +
                    'or check the javascript console for details.';
            }

            config = $.extend(defConfig, config);
            mw.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('-');
        },

        /**
         * 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(
                    $('<strong>')
                        .addClass('error')
                        .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().setDisabled) {
                            child.getField().setDisabled(!show);
                        }
                    });
                } else if ( param.type === 'semihidden' ) {
                    if (!!param.ooui.setDisabled) {
                        param.ooui.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';
            	}//<nowiki>
            	code = '{{#invoke:'+self.module+'|'+self.modulefunc;
            }//</nowiki>

            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) {
                                val = param.range.split(',')[val ? 0 : 1];
                            }
                        } else if (param.type === 'toggleswitch' || param.type === 'togglebutton') {
                            if (param.range) {
                                val = param.range.split(',')[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.mw.pages && Object.keys(ret.query.mw.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.mw.pages) {
                                            if ( ret.query.mw.pages.hasOwnProperty(pgID) && ret.query.mw.pages[pgID].missing!== '' ) {
                                                if ( allNS ) {
                                                    resolve();
                                                }
                                                if ( ret.query.mw.pages[pgID].ns !== undefined && nspaces.indexOf(ret.query.mw.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([]);

                // Save all values to localStorage
                if (!rs.hasLocalStorage()) {
                    console.warn('Browser does not support localStorage, inputs will not be saved.');
                } else {
                    mw.log('Saving inputs to localStorage');
                    localStorage.setItem( self.localname, JSON.stringify(paramVals) );
                }

                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|limitreportdata',
                    title: mw.config.get('wgPageName'),
                    disablelimitreport: 'true',
                    contentmodel: 'wikitext',
                    format: 'json'
                },
                method = 'GET';
            
            // experimental support for using VE to parse calc templates
            if (!!mw.mw.util.getParamValue('vecalc')) {
                params = {
                    action: 'visualeditor',
                    // has to be a mainspace page or VE won't work
                    page: 'No page',
                    paction: 'parsefragment',
                    wikitext: code,
                    format: 'json',
                    rswcalcautosubmit: self.autosubmit
                };
            }
            if (code.length > 1900) {
            	method = 'POST';
            }

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

            // @todo time how long these calls take
            $.ajax({method:method, url:'/api.php', data:params})
                .done(function (response) {
                    var html;
                    
                    if (!!mw.mw.util.getParamValue('vecalc')) {
                        // strip body tag
                        html = $(response.visualeditor.content).contents();
                    } else {
                        html = response.parse.text['*'];
                    }
                    if (response.parse.limitreportdata) {
                        var logs = response.parse.limitreportdata.filter(function(e){return e.name === 'scribunto-limitreport-logs'});
                        if (logs.length>0) {
                            var log_str = ['Scribunto logs:'];
                            logs.forEach(function(log){
                                var i = 0;
                                while (log.hasOwnProperty(''+i)) {
                                    log_str.push(log[''+i]);
                                    i++;
                                }
                            });
                            console.log(log_str.join('\n'));
                        }
                    }
                    
                    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')
                .find('#' + this.result)
                    .empty()
                    .removeClass('jcError')
                    .html(html);
            
            // allow scripts to hook into form submission
            mw.hook('rscalc.submit').fire();

			// run all standard page-init things so various JS works as expected, including:
			// - sortable tables
			// - collapsible sections
			// - collapsed headers on mobile
            mw.hook('wikipage.content').fire($('#'+this.result));
            /*
            mw.loader.using('jquery.tablesorter', function () {
                $('table.sortable:not(.jquery-tablesorter)').tablesorter();
            });
            mw.loader.using('jquery.makeCollapsible', function () {
                $('.mw-collapsible').makeCollapsible();
            });
            */

            if ($('.rsw-chartjs-config').length) {
    			mw.loader.load('ext.gadget.Charts-core');
            }
        },

        /**
         * 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) {
                    mw.log('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) {
                        mw.log('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
                        ) {
                            mw.log('Script URL detected in ' + tagname);
                            $this.removeAttr('href');
                        }
                    }
                });
            });

            return $div.contents();
        },

        /**
         * 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,
                        dropdown: {
                        	$overlay: true
                        }
                    },
                    layconf = {
                        label: new OO.ui.HtmlSnippet(param.label),
                        align: 'right',
                        classes: ['jsCalc-field', 'jsCalc-field-select']
                    },
                    opts = param.range.split(/\s*,\s*/),
                    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, i) {
                    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']
                    },
                    opts = param.range.split(/\s*,\s*/),
                    def;
                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, i) {
                    var opid = opt.replace(/[^a-zA-Z0-9]/g, '');
                    var $opt = helper.sanitiseLabels(opt);
                    var txt = $opt.text().trim();
                    if (txt === '') {
                    	txt = (i+1).toString();
                    }
                    buttons[opid] = new OO.ui.ButtonOptionWidget({data:txt, label: new OO.ui.HtmlSnippet($opt), title:txt});
                    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: 'Enter filter name',
                        options: [],
                        name: id,
                        id: id,
                        menu: { filterFromInput: true },
                        value: param.def,
                        $overlay: true
                    },
                    layconf = {
                        label: new OO.ui.HtmlSnippet(param.label),
                        align: 'right',
                        classes: ['jsCalc-field', 'jsCalc-field-combobox']
                    },
                    opts = param.range.split(/\s*,\s*/),
                    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);
                });

                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);
                }

                if ( (param.def === 'true' || param.def === true) ||
                    (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[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);
                }

                if ( (param.def === 'true' || param.def === true) ||
                    (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[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);
                }

                if ( (param.def === 'true' || param.def === true) ||
                    (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[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 hiscore 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
             */
            hs: function (param, id) {
                var self = this,
                    layconf = {
                        label: new OO.ui.HtmlSnippet(param.label),
                        align:'right',
                        classes: ['jsCalc-field', 'jsCalc-field-hs']
                    },
                    lookups = {},
                    range = param.range.split(';'),
                    input1 = new OO.ui.TextInputWidget({type: 'text', id: id, name: id, value:param.def}),
					button1 = new OO.ui.ButtonInputWidget({ label: 'Lookup', id: id+'-button', name:  id+'-button', classes: ['jsCalc-field-hs-lookup'], data: {param: param.name} });

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

                var layout = new OO.ui.ActionFieldLayout(input1, button1, layconf);

				var lookupHS = function(event) {
					var $t = $(event.target),
						lookup = self.lookups[button1.getData().param],
                        // replace spaces with _ for the query
                        name = $('#' + lookup.id + ' input')
                            .val()
                            // @todo will this break for players with multiple spaces
                            //       in their name? e.g. suomi's old display name
                            .replace(/\s+/g, '_'),
                        button = lookup.button;
					button.setDisabled(true);
                    $.ajax({
						url: '/cors/m=hiscore_oldschool/index_lite.ws?player=' + name,
						dataType: 'text',
						async: true,
						timeout: 10000 // msec
					}).done(function (data) {
						var hsdata;

						hsdata = data.trim()
							.split(/\n+/g);

						lookup.params.forEach(function (param) {
							var id = helper.getId.call(self, param.param),
								$input = $('#' + id + ' input'),
								tParam = null,
                                val;
							self.tParams.forEach(function(p) {
								if (p.name === param.param) {
									tParam = p;
								}
							});
							if (tParam === null) {
								return;
							}
							if (isNaN(param.skill)) {
                                val = param.skill;
								//tParam.ooui.setValue(param.skill);
							} else {
                                val = hsdata[param.skill].split(',')[param.val];
								//tParam.ooui.setValue(hsdata[param.skill].split(',')[param.val]);
							}
                            if (!!tParam.ooui.setValue) {
                                tParam.ooui.setValue(val);
                            } else if (!!tParam.ooui.selectItemByData) {
                                tParam.ooui.selectItemByData(val);
                            } else if (tParam.type === 'fixed') {
                                tParam.ooui.setLabel(val);
                            }
						});
					
						// store in localStorage for future use
                        if (rs.hasLocalStorage()) {
                            self.lsRSN = name;
                            localStorage.setItem('rsn', name);
                        }

						button.setDisabled(false);
                        layout.setErrors([]);
					})
					.fail(function (xhr, status) {
						button.setDisabled(false);

                        var err = 'The player "' + name + '" does not exist, is banned or unranked, or we couldn\'t fetch your hiscores. Please enter the data manually.';
                        console.warn(status);
                        layout.setErrors([err]);
						helper.showError.call(self, err);
					});
				};
					
				button1.$element.click(lookupHS);
				input1.$element.keydown(function(event){
					if (event.which === 13) {
						lookupHS(event);
						event.preventDefault();
					}
				});
				
                // Use rsn loaded from localstorage
                if (self.lsRSN) {
                    input1.setValue(self.lsRSN);
                }

                lookups[param.name] = {
                    id: id,
                    button: button1,
                    params: []
                };
                
                range.forEach(function (el) {
                    // to catch empty strings
                    if (!el) {
                        return;
                    }

                    var spl = el.split(',');

                    lookups[param.name].params.push({
                        param: spl[0],
                        skill: spl[1],
                        val: spl[2]
                    });
                });

                // merge lookups into one object
                if (!self.lookups) {
                    self.lookups = lookups;
                } else {
                    self.lookups = $.extend(self.lookups, lookups);
                }
				
				param.ooui = input1;
				param.oouiButton = button1;

                return layout;
            },

            /**
             * Handler for Runescape name 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
             */
            rsn: function (param, id) {
                var self = this,
                    conf = {
                        type: 'text',
                        name: id,
                        id: id,
                        placeholder: 'Enter runescape name',
                        spellcheck: false,
                        maxLength: 12,
                        value: param.def
                    },
                    layconf = {
                        label: new OO.ui.HtmlSnippet(param.label),
                        align: 'right',
                        classes: ['jsCalc-field', 'jsCalc-field-string']
                    };
                param.error = 'Invalid runescape name: RS names must be 1-12 characters long, can only contain letters, numbers, spaces, dashes and underscores. Names containing Mod are also not allowed.';

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

                // Use rsn loaded from localstorage, if available
                if (self.lsRSN) {
                    conf.value = self.lsRSN;
                }

                var validrsn = function (name) {
                    if ( name.search( /[^0-9a-zA-Z\-_\s]/ ) >= 0 ) {
                        return false;
                    } else {
                        if ( name.toLowerCase().search( /(^mod\s|\smod\s|\smod$)/ ) >= 0 ) {
                            return false;
                        } else {
                            return true;
                        }
                    }
                };
                conf.validate = validrsn;

                param.ooui = new OO.ui.TextInputWidget(conf);
                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
                    },
                    layconf = {
                        label: new OO.ui.HtmlSnippet(param.label),
                        align: 'right',
                        classes: ['jsCalc-field', 'jsCalc-field-int']
                    },
                    error = 'Invalid integer. 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 muiltiple of ' + rng[2];
                }
                param.error = error;


				param.ooui = new OO.ui.NumberInputWidget(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 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
                    },
                    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 interger. 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);
                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:'RuneScape', _5:'RuneScape 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', _100:'Update', _101:'Update talk', _110:'Forum', _111:'Forum talk', _112:'Exchange', _113:'Exchange talk', _114:'Charm', _115:'Charm talk', _116:'Calculator', _117:'Calculator talk', _118:'Map', _119:'Map talk', _828:'Module', _829:'Module talk' },
                    validNSnames = { all:'*', media:-2, special:-1, main:0, '(main)':0, talk:1, user:2, 'user talk':3, runescape:4, 'runescape 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, update:100, 'update talk':101, forum:110, 'forum talk':111, exchange:112, 'exchange talk':113, charm:114, 'charm talk':115, calculator:116, 'calculator talk':117, map:118, 'map talk':119, module:828, 'module talk':829 },
                    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});
                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
    this.localname = calcstorage + '-' + config.form;
    
    // Load previous parameter values.
    if (!rs.hasLocalStorage()) {
        console.warn('Browser does not support localStorage');
    } else {
        mw.log('Loading previous calculator values');
        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];
                }
            });
        }
        self.lsRSN = localStorage.getItem('rsn');
        mw.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, paramChangeAction, timeout,
        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[field] = index;
            });
        }

        param.layout = helper.tParams[method].call(self, param, id);

        if (param.type === 'semihidden') {
            param.layout.toggle(false);
        }
		
        // 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.type === 'rsn') {
            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 (['off', 'false', 'disabled'].indexOf(self.autosubmit) === -1) {
        // Add event
        paramChangeAction = function (widget) {
            if ( typeof widget.getFlags === 'undefined' || !widget.getFlags().includes('invalid')) {
                helper.submitForm.call(self);
            }
        };
        
		// 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 === 'hs' || 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);
    
    // make buttonselects all the same height
    self.tParams.filter(e=>e.type==='buttonselect').forEach(e=>{
    	let m = e.ooui.items.reduce((acc,curr)=>Math.max(acc, curr.$element.find('> a.oo-ui-buttonElement-button').height()), 0);
    	e.ooui.items.forEach(e=>e.$element.find('> a.oo-ui-buttonElement-button').height(m));
    })
};

/**
 * @todo
 */
function lookupCalc(calcId) {
    return calcStore[calcId];
}

/**
 * @todo
 */
function init() {
    // 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);
        // }
    });
    
    // allow scripts to hook into calc setup completion
    mw.hook('rscalc.setupComplete').fire();
}

$(init);

rs.calc = {};
rs.calc.lookup = lookupCalc;