MediaWiki:Gadget-calc-core.js: Difference between revisions

From RuneRealm Wiki
Jump to navigation Jump to search
Content added Content deleted
(Created page with "/** * 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...")
 
No edit summary
 
(3 intermediate revisions by the same user not shown)
Line 42: Line 42:
'use strict';
'use strict';


/**
/**
* Prefix of localStorage key for calc data. This is prepended to the form ID
* Prefix of localStorage key for calc data. This is prepended to the form ID
* localStorage name for autosubmit setting
* localStorage name for autosubmit setting
*/
*/
var calcstorage = 'rsw-calcsdata',
var calcstorage = 'rsw-calcsdata',
calcautostorage = 'rsw-calcsdata-allautosub',
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
* Caching for search suggestions
* Added support for multiple namespaces to mw.widgets.TitleInputWidget
*
* @todo implement caching for mw.TitleInputWidget accroding to https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.widgets.TitleWidget-cfg-cache
*/
*/
initClasses: function initClasses() {
cache = {},
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()) {
* Internal variable to store references to each calculator on the page.
*/
continue;
calcStore = {},
}


// When excludeDynamicNamespaces is set, ignore all pages with negative namespace
/**
if (this.excludeDynamicNamespaces && suggestionPage.ns < 0) {
* Private helper methods for `Calc`
*
continue;
}
* Most methods here are called with `Function.prototype.call`
pageData[suggestionPage.title] = {
* and are passed an instance of `Calc` to access it's prototype
known: suggestionPage.known !== undefined,
*/
missing: suggestionPage.missing !== undefined,
helper = {
redirect: suggestionPage.redirect !== undefined,
/**
disambiguation: OO.getProp(suggestionPage, 'pageprops', 'disambiguation') !== undefined,
* Add/change functionality of mw/OO.ui classes
imageUrl: OO.getProp(suggestionPage, 'thumbnail', 'source'),
* Added support for multiple namespaces to mw.widgets.TitleInputWidget
description: suggestionPage.description,
*/
initClasses: function () {
// Sort index
var hasOwn = Object.prototype.hasOwnProperty;
index: suggestionPage.index,
/**
originalData: suggestionPage
};
* 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);});


// Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true
if ( data.redirects ) {
// and we encounter a cross-namespace redirect.
for ( i = 0, len = data.redirects.length; i < len; i++ ) {
if (this.namespace === null || namespaces.indexOf(suggestionPage.ns) >= 0) {
redirect = data.redirects[ i ];
titles.push(suggestionPage.title);
redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || [];
}
redirectsTo[ redirect.to ].push( redirect.from );
redirects = hasOwn.call(redirectsTo, suggestionPage.title) ? redirectsTo[suggestionPage.title] : [];
}
}
for (i = 0, len = redirects.length; i < len; i++) {
pageData[redirects[i]] = {

for ( index in data.pages ) {
missing: false,
suggestionPage = data.pages[ index ];
known: true,
redirect: true,

disambiguation: false,
// 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() ) {
description: mw.msg('mw-widgets-titleinput-description-redirect', suggestionPage.title),
continue;
// Sort index, just below its target
}
index: suggestionPage.index + 0.5,
originalData: suggestionPage

// 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;
};
};
},
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)
* 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;


pageExistsExact = hasOwn.call(pageData, this.getQueryValue()) && (!pageData[this.getQueryValue()].missing || pageData[this.getQueryValue()].known);
// parse the calculator's config
pageExists = pageExistsExact || titleObj && hasOwn.call(pageData, titleObj.getPrefixedText()) && (!pageData[titleObj.getPrefixedText()].missing || pageData[titleObj.getPrefixedText()].known);
// @example param=arg1|arg1|arg3|arg4
lines.forEach(function (line) {
if (this.cache) {
var temp = line.split('='),
this.cache.set(pageData);
param,
}
args;


// incorrect config
// Offer the exact text as a suggestion if the page exists
if (temp.length < 2) {
if (this.addQueryInput && pageExists && !pageExistsExact) {
return;
titles.unshift(this.getQueryValue());
}
}
for (i = 0, len = titles.length; i < len; i++) {

page = hasOwn.call(pageData, titles[i]) ? pageData[titles[i]] : {};
// an equals is used in one of the arguments
items.push(this.createOptionWidget(this.getOptionWidgetData(titles[i], page)));
// @example HTML label with attributes
}
// so join them back together to preserve it
return items;
// this also allows support of HTML attributes in labels
};
if (temp.length > 2) {
},
temp[1] = temp.slice(1,temp.length).join('=');
}
/**
* Parse the calculator configuration

*
param = temp[0].trim().toLowerCase();
* @param lines {Array} An array containing the calculator's configuration
args = temp[1].trim();
* @returns {Object} An object representing the calculator's configuration

*/
if (validParams.indexOf(param) === -1) {
parseConfig: function parseConfig(lines) {
// use console for easier debugging
var defConfig = {
console.log('Unknown parameter: ' + param);
configError = true;
suggestns: [],
return;
autosubmit: 'off',
}
name: 'Calculator'

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;
},
},
config = {

/**
// this isn't in `defConfig`
* Generate a unique id for each input
// as it'll get overridden anyway
*
tParams: []
* @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('-');
},
},
// 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
* Output an error to the UI
*
lines.forEach(function (line) {
var temp = line.split('='),
* @param error {String} A string representing the error message to be output
*/
param,
showError: function (error) {
args;
$('#' + this.result)
.empty()
.append(
$('<strong>')
.addClass('error')
.text(error)
);
},


/**
// incorrect config
if (temp.length < 2) {
* Toggle the visibility and enabled status of fields/groups
*
return;
}
* @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) {
// an equals is used in one of the arguments
// @example HTML label with attributes
var param = self.tParams[ self.indexkeys[widget] ];
if (param.type === 'group') {
// so join them back together to preserve it
// this also allows support of HTML attributes in labels
param.ooui.toggle(show);
param.ooui.getItems().forEach(function (child) {
if (temp.length > 2) {
temp[1] = temp.slice(1, temp.length).join('=');
if (!!child.setDisabled) {
}
child.setDisabled(!show);
param = temp[0].trim().toLowerCase();
} else if (!!child.getField().setDisabled) {
child.getField().setDisabled(!show);
args = temp[1].trim();
if (validParams.indexOf(param) === -1) {
}
});
// use console for easier debugging
} else if ( param.type === 'semihidden' ) {
console.log('Unknown parameter: ' + param);
configError = true;
if (!!param.ooui.setDisabled) {
param.ooui.setDisabled(!show);
return;
}
}
} else {
if (param === 'suggestns') {
param.layout.toggle(show);
config.suggestns = args.split(/\s*,\s*/);
return;
if (!!param.ooui.setDisabled) {
}
param.ooui.setDisabled(!show);
}
if (param !== 'param') {
}
config[param] = args;
};
return;
}


if (toggles[item]) {
// split args
args = args.split(/\s*\|\s*/);
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);
});
}
},


// store template params in an array to make life easier
/**
config.tParams = config.tParams || [];
* Generate range and step for number and int inputs
if (validParamTypes.indexOf(args[3]) === -1 && args[3] !== '' && args[3] !== undefined) {
*
// use console for easier debugging
* @param rawdata {string} The string representation of the range and steps
* @param type {string} The name of the field type (int or number)
console.log('Unknown param type: ' + args[3]);
configError = true;
* @returns {array} An array containing the min value, max value, step and button step.
*/
return;
}
genRange: function (rawdata,type) {
var tmp = rawdata.split(/\s*,\s*/),
var inlinehelp = false,
rng = tmp[0].split(/\s*-\s*/),
help = '';
step = tmp[1] || '',
if (args[6]) {
bstep = tmp[2] || '',
var tmphelp = args[6].split(/\s*=\s*/);
min, max,
if (tmphelp.length > 1) {
parseFunc;
if (tmphelp[0] === 'inline') {
if (type==='int') {
inlinehelp = true;
parseFunc = function(x) { return parseInt(x, 10); }
// Html etc can have = so join them back together
tmphelp[1] = tmphelp.slice(1, tmphelp.length).join('=');
help = helper.sanitiseLabels(tmphelp[1] || '');
} else {
} else {
parseFunc = parseFloat;
// 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 getId(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 showError(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 toggle(item, toggles) {
var self = this;
var togitem = function togitem(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 genRange(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 parseFunc(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 (type === 'int') {
step = 1;
if (rng.length === 3) {
// 1 value is negative
if ( isNaN(parseInt(bstep,10)) ) {
bstep = 1;
if (rng[0] === '') {
} else {
// First value negative
if (isNaN(parseFunc(rng[1]))) {
bstep = parseInt(bstep,10);
}
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 parseToggles(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 {
} else {
if ( isNaN(parseFloat(step)) ) {
return true;
step = 0.01;
} else {
step = parseFloat(step);
}
if ( isNaN(parseFloat(bstep)) ) {
bstep = 1;
} else {
bstep = parseFloat(bstep);
}
}
}
});
});


// Accept negative values for either range position
// Add all items to default
if ( rng.length === 3 ) {
toggles.alltogs = {};
toggles.alltogs.off = allvals;
// 1 value is negative
}
if ( rng[0] === '' ) {
return toggles;
// First value negative
},
if ( isNaN(parseFunc(rng[1])) ) {
/**
min = -Infinity;
* Form submission handler
} else {
*/
min = 0 - parseFunc(rng[1]);
submitForm: function submitForm() {
}
var self = this,
if ( isNaN(parseFunc(rng[2])) ) {
max = Infinity;
code = '{{' + self.template,
} else {
formErrors = [],
max = parseFunc(rng[2]);
apicalls = [],
}
paramVals = {};
} else if ( rng[1] === '' ) {
if (self.module !== undefined) {
if (self.modulefunc === undefined) {
// Second value negative
self.modulefunc = 'main';
if ( isNaN(parseFunc(rng[0])) ) {
min = -Infinity;
} //<nowiki>
} else {
code = '{{#invoke:' + self.module + '|' + self.modulefunc;
} //</nowiki>
min = parseFunc(rng[0]);

}
self.submitlayout.setNotices(['Validating fields, please wait.']);
if ( isNaN(parseFunc(rng[2])) ) {
self.submitlayout.fieldWidget.setDisabled(true);
max = 0;

} else {
// setup template for submission
max = 0 - parseFunc(rng[2]);
self.tParams.forEach(function (param) {
}
if (param.type === 'hidden' || param.type !== 'group' && param.ooui.isDisabled() === false) {
}
} else if ( rng.length === 4 ) {
var val,
// Both negative
$input,
if ( isNaN(parseFunc(rng[1])) ) {
// use separate error tracking for each input
min = -Infinity;
// or every input gets flagged as an error
} else {
error = '';
min = 0 - parseFunc(rng[1]);
if (param.type === 'fixed' || param.type === 'hidden') {
}
val = param.def;
if ( isNaN(parseFunc(rng[3])) ) {
} else {
max = 0;
$input = $('#' + helper.getId.call(self, param.name) + ' input');
} else {
if (param.type === 'buttonselect') {
max = 0 - parseFunc(rng[3]);
val = param.ooui.findSelectedItem();
}
if (val !== null) {
val = val.getData();
}
} else {
} else {
// No negatives
val = param.ooui.getValue();
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 (param.type === 'int') {
if ( max < min ) {
val = val.split(',').join('');
return [ max, min, step, bstep ];
} else if (param.type === 'check') {
} else {
val = param.ooui.isSelected();
return [ min, max, step, bstep ];
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];
}
}
}
},

/**
* 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) {
// Check input is valid (based on widgets validation)
tmptogs.forEach(function (tog) {
if (!!param.ooui.hasFlag && param.ooui.hasFlag('invalid') && param.type !== 'article') {
var tmp = tog.split(/\s*=\s*/),
error = param.error;
keys = tmp[0],
} else if (param.type === 'article' && param.ooui.validateTitle && val.length > 0) {
val = [];
var api = param.ooui.getApi(),
if (tmp.length < 2) {
prms = {
keys = [defkey];
action: 'query',
val = tmp[0].split(/\s*,\s*/);
prop: [],
} else {
titles: [param.ooui.getValue()]
keys = tmp[0].split(/\s*,\s*/);
};
val = tmp[1].split(/\s*,\s*/);
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;
}
}
if (keys.length === 1) {
nspaces = nspaces.map(function (ns) {
var key = keys[0];
return parseInt(ns, 10);
toggles[key] = {};
});
toggles[key].on = val;
for (var pgID in ret.query.pages) {
allkeys.push(key);
if (ret.query.pages.hasOwnProperty(pgID) && ret.query.pages[pgID].missing !== '') {
} else {
if (allNS) {
keys.forEach( function (key) {
resolve();
toggles[key] = {};
}
toggles[key].on = val;
if (ret.query.pages[pgID].ns !== undefined && nspaces.indexOf(ret.query.pages[pgID].ns) >= 0) {
allkeys.push(key);
resolve();
});
}
}
}
}
allvals = allvals.concat(val);
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');


allkeys = allkeys.filter(function (item, pos, arr) {
// Save current parameter value
return arr.indexOf(item) === pos;
paramVals[param.name] = val;
});


// Save current parameter value for later calculator usage.
allkeys.forEach(function (key) {
toggles[key].off = allvals.filter(function (val) {
//window.localStorage.setItem(helper.getId.call(self, param.name), val);
if ( toggles[key].on.includes(val) ) {
return false;
} else {
return true;
}
});
});

// Add all items to default
toggles.alltogs = {};
toggles.alltogs.off = allvals;
}
}
}
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([]);


return toggles;
// 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 loadTemplate(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.util.getParamValue('vecalc')) {
* Form submission handler
*/
params = {
submitForm: function () {
action: 'visualeditor',
var self = this,
// has to be a mainspace page or VE won't work
code = '{{' + self.template,
page: 'No page',
formErrors = [],
paction: 'parsefragment',
apicalls = [],
wikitext: code,
paramVals = {};
format: 'json',
rswcalcautosubmit: self.autosubmit
};
if (self.module !== undefined) {
}
if (self.modulefunc === undefined) {
if (code.length > 1900) {
self.modulefunc = 'main';
}//<nowiki>
method = 'POST';
}
code = '{{#invoke:'+self.module+'|'+self.modulefunc;
$('#' + self.form + ' .jcSubmit').data('oouiButton').setDisabled(true);
}//</nowiki>


// @todo time how long these calls take
self.submitlayout.setNotices(['Validating fields, please wait.']);
$.ajax({
self.submitlayout.fieldWidget.setDisabled(true);
method: method,

// setup template for submission
url: '/api.php',
data: params
self.tParams.forEach(function (param) {
}).done(function (response) {
if ( param.type === 'hidden' || (param.type !== 'group' && param.ooui.isDisabled() === false) ) {
var val,
var html;
if (!!mw.util.getParamValue('vecalc')) {
$input,
// use separate error tracking for each input
// strip body tag
html = $(response.visualeditor.content).contents();
// or every input gets flagged as an error
error = '';
} else {
html = response.parse.text['*'];

}
if (param.type === 'fixed' || param.type === 'hidden') {
if (response.parse.limitreportdata) {
val = param.def;
var logs = response.parse.limitreportdata.filter(function (e) {
} else {
return e.name === 'scribunto-limitreport-logs';
$input = $('#' + helper.getId.call(self, param.name) + ' input');
});

if (param.type === 'buttonselect') {
if (logs.length > 0) {
var log_str = ['Scribunto logs:'];
val = param.ooui.findSelectedItem();
if (val !== null) {
logs.forEach(function (log) {
val = val.getData();
var i = 0;
}
while (log.hasOwnProperty('' + i)) {
} else {
log_str.push(log['' + i]);
val = param.ooui.getValue();
i++;
}
}

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.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;
}
});
});
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 dispResult(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
Promise.all(apicalls).then( function (vals) {
mw.hook('rscalc.submit').fire();
// All article fields valid
self.submitlayout.setNotices([]);
self.submitlayout.fieldWidget.setDisabled(false);


// run all standard page-init things so various JS works as expected, including:
if (formErrors.length > 0) {
// - sortable tables
self.submitlayout.setErrors(formErrors);
// - collapsible sections
helper.showError.call(self, 'One or more fields contains an invalid value.');
return;
// - 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) {
self.submitlayout.setErrors([]);
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 sanitiseLabels(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;
}


// Save all values to localStorage
// make sure there's nasty in nothing in href attributes
if (!rs.hasLocalStorage()) {
if (attr.name === 'href') {
href = $this.attr('href');
console.warn('Browser does not support localStorage, inputs will not be saved.');
} else {
if (
// disable warnings about script URLs
mw.log('Saving inputs to localStorage');
// jshint -W107
localStorage.setItem( self.localname, JSON.stringify(paramVals) );
}
href.indexOf('javascript:') > -1 ||
// the mw sanitizer doesn't like these

code += '}}';
// so lets follow suit
console.log(code);
// apparently it's something microsoft dreamed up
helper.loadTemplate.call(self, code);
href.indexOf('vbscript:') > -1
// jshint +W107

}, function (errparam) {
) {
// An article field is invalid
mw.log('Script URL detected in ' + tagname);
self.submitlayout.setNotices([]);
$this.removeAttr('href');
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.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';
});
});
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 fixed(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 select(param, id) {
var self = this,
conf = {
label: 'Select an option',
options: [],
name: id,
id: id,
value: param.def,
dropdown: {
$overlay: true
}
}
},

$('#' + self.form + ' .jcSubmit')
layconf = {
.data('oouiButton')
label: new OO.ui.HtmlSnippet(param.label),
.setDisabled(true);
align: 'right',
classes: ['jsCalc-field', 'jsCalc-field-select']
},

opts = param.range.split(/\s*,\s*/),
// @todo time how long these calls take
def = opts[0];
$.ajax({method:method, url:'/api.php', data:params})
param.error = 'Not a valid selection';
.done(function (response) {
var html;
if (param.help) {
layconf.helpInline = param.inlhelp;
if (!!mw.util.getParamValue('vecalc')) {
layconf.help = new OO.ui.HtmlSnippet(param.help);
}
// strip body tag
opts.forEach(function (opt, i) {
html = $(response.visualeditor.content).contents();
} else {
var op = {
html = response.parse.text['*'];
data: opt,
}
label: opt
};
if (response.parse.limitreportdata) {
if (opt === param.def) {
var logs = response.parse.limitreportdata.filter(function(e){return e.name === 'scribunto-limitreport-logs'});
if (logs.length>0) {
op.selected = true;
var log_str = ['Scribunto logs:'];
def = opt;
}
logs.forEach(function(log){
var i = 0;
conf.options.push(op);
});
while (log.hasOwnProperty(''+i)) {
param.toggles = helper.parseToggles(param.rawtogs, def);
log_str.push(log[''+i]);
i++;
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);
console.log(log_str.join('\n'));
}
});
}
}
return new OO.ui.FieldLayout(param.ooui, layconf);
},
helper.dispResult.call(self, html);
})
/**
* Handler for button selects
.fail(function (_, error) {
*
$('#' + self.form + ' .jcSubmit')
* @param param {object} An object containing the configuration of a parameter
.data('oouiButton')
* @param id {String} A string representing the id to be added to the input
.setDisabled(false);
* @returns {OOUI.object} A OOUI object containing the new FieldLayout
helper.showError.call(self, error);
});
*/
buttonselect: function buttonselect(param, id) {
},
var self = this,

/**
buttons = {},
* Display the calculator result on the page
conf = {
*
label: 'Select an option',
items: [],
* @param response {String} A string representing the HTML to be added to the page
*/
id: id
dispResult: function (html) {
},
var self = this;
layconf = {
$('#' + self.form + ' .jcSubmit')
label: new OO.ui.HtmlSnippet(param.label),
.data('oouiButton')
align: 'right',
.setDisabled(false);
classes: ['jsCalc-field', 'jsCalc-field-buttonselect']
},

$('#bodyContent')
opts = param.range.split(/\s*,\s*/),
.find('#' + this.result)
def;
.empty()
param.error = 'Please select a valid option';
.removeClass('jcError')
if (param.help) {
.html(html);
layconf.helpInline = param.inlhelp;
layconf.help = new OO.ui.HtmlSnippet(param.help);
}
// allow scripts to hook into form submission
mw.hook('rscalc.submit').fire();
opts.forEach(function (opt, i) {
var opid = opt.replace(/[^a-zA-Z0-9]/g, '');

var $opt = helper.sanitiseLabels(opt);
// run all standard page-init things so various JS works as expected, including:
var txt = $opt.text().trim();
// - sortable tables
if (txt === '') {
// - collapsible sections
txt = (i + 1).toString();
// - collapsed headers on mobile
}
mw.hook('wikipage.content').fire($('#'+this.result));
/*
buttons[opid] = new OO.ui.ButtonOptionWidget({
mw.loader.using('jquery.tablesorter', function () {
data: txt,
$('table.sortable:not(.jquery-tablesorter)').tablesorter();
label: new OO.ui.HtmlSnippet($opt),
});
title: txt
});
mw.loader.using('jquery.makeCollapsible', function () {
$('.mw-collapsible').makeCollapsible();
conf.items.push(buttons[opid]);
});
});
if (param.def.length > 0 && opts.indexOf(param.def) > -1) {
*/
def = param.def;

if ($('.rsw-chartjs-config').length) {
} else {
def = opts[0];
mw.loader.load('ext.gadget.Charts-core');
}
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 combobox(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 isvalid(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 check(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);
/**
},
* Sanitise any HTML used in labels
*
/**
* Handler for toggle switch inputs
* @param html {string} A HTML string to be sanitised
*
* @returns {jQuery.object} A jQuery object representing the sanitised HTML
* @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
sanitiseLabels: function (html) {
* @returns {OOUI.object} A OOUI object containing the new FieldLayout
var whitelistAttrs = [
*/
// mainly for span/div tags
toggleswitch: function toggleswitch(param, id) {
'style',
// for anchor tags
var self = this,
'href',
conf = {
'title',
id: id
// for img tags
},
'src',
layconf = {
'alt',
label: new OO.ui.HtmlSnippet(param.label),
'height',
align: 'right',
'width',
classes: ['jsCalc-field', 'jsCalc-field-toggleswitch']
// misc
};
param.toggles = helper.parseToggles(param.rawtogs, 'true');
'class'
],
param.error = 'Unknown error';
whitelistTags = [
if (param.help) {
'a',
layconf.helpInline = param.inlhelp;
'span',
layconf.help = new OO.ui.HtmlSnippet(param.help);
'div',
}
if (param.def === 'true' || param.def === true || param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) {
'img',
'strong',
conf.value = true;
'b',
}
param.ooui = new OO.ui.ToggleSwitchWidget(conf);
'em',
if (Object.keys(param.toggles).length > 0) {
'i',
param.ooui.on('change', function (selected) {
'br'
],
if (selected) {
helper.toggle.call(self, 'true', param.toggles);
// parse the HTML string, removing script tags at the same time
} else {
$html = $.parseHTML(html, /* document */ null, /* keepscripts */ false),
helper.toggle.call(self, 'false', param.toggles);
// append to a div so we can navigate the node tree
$div = $('<div>').append($html);
}
});

}
$div.find('*').each(function () {
return new OO.ui.FieldLayout(param.ooui, layconf);
var $this = $(this),
},
tagname = $this.prop('tagName').toLowerCase(),
attrs,
/**
array,
* Handler for toggle button inputs
href;
*
* @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
if (whitelistTags.indexOf(tagname) === -1) {
* @returns {OOUI.object} A OOUI object containing the new FieldLayout
mw.log('Disallowed tagname: ' + tagname);
*/
$this.remove();
togglebutton: function togglebutton(param, id) {
return;
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 hs(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 lookupHS(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;
}
}
});

attrs = $this.prop('attributes');
if (tParam === null) {
array = Array.prototype.slice.call(attrs);
return;
}

array.forEach(function (attr) {
if (isNaN(param.skill)) {
if (whitelistAttrs.indexOf(attr.name) === -1) {
val = param.skill;
//tParam.ooui.setValue(param.skill);
mw.log('Disallowed attribute: ' + attr.name + ', tagname: ' + tagname);
$this.removeAttr(attr.name);
} else {
return;
val = hsdata[param.skill].split(',')[param.val];
//tParam.ooui.setValue(hsdata[param.skill].split(',')[param.val]);
}
}

if (!!tParam.ooui.setValue) {
// make sure there's nasty in nothing in href attributes
if (attr.name === 'href') {
tParam.ooui.setValue(val);
href = $this.attr('href');
} else if (!!tParam.ooui.selectItemByData) {
tParam.ooui.selectItemByData(val);

if (
} else if (tParam.type === 'fixed') {
// disable warnings about script URLs
tParam.ooui.setLabel(val);
// 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();
// 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) {
* Handlers for parameter input types
*/
input1.setValue(self.lsRSN);
tParams: {
}
/**
lookups[param.name] = {
* Handler for 'fixed' inputs
id: id,
*
button: button1,
params: []
* @param param {object} An object containing the configuration of a parameter
};
* @returns {OOUI.object} A OOUI object containing the new FieldLayout
*/
range.forEach(function (el) {
fixed: function (param) {
// to catch empty strings
var layconf = {
if (!el) {
return;
label: new OO.ui.HtmlSnippet(param.label),
align: 'right',
}
classes: ['jsCalc-field', 'jsCalc-field-fixed'],
var spl = el.split(',');
value: param.def
lookups[param.name].params.push({
};
param: spl[0],
skill: spl[1],
val: spl[2]
});
});


if (param.help) {
// merge lookups into one object
if (!self.lookups) {
layconf.helpInline = param.inlhelp;
layconf.help = new OO.ui.HtmlSnippet(param.help);
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 rsn(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
param.ooui = new OO.ui.LabelWidget({ label: param.def });
if (self.lsRSN) {
return new OO.ui.FieldLayout(param.ooui, layconf);
},
conf.value = self.lsRSN;
}

/**
var validrsn = function validrsn(name) {
if (name.search(/[^0-9a-zA-Z\-_\s]/) >= 0) {
* Handler for select dropdowns
*
return false;
} else {
* @param param {object} An object containing the configuration of a parameter
if (name.toLowerCase().search(/(^mod\s|\smod\s|\smod$)/) >= 0) {
* @param id {String} A string representing the id to be added to the input
return false;
* @returns {OOUI.object} A OOUI object containing the new FieldLayout
*/
} else {
select: function (param, id) {
return true;
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);
}
}
}
};
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 int(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 number(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 article(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 nsmap(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 nsmap(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 group(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 def(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);
}
}
};


/**
/**
Line 1,691: Line 1,607:
*/
*/
function Calc(elem) {
function Calc(elem) {
var self = this,
var self = this,
$elem = $(elem),
$elem = $(elem),
lines,
lines,
config;
config;

// support div tags for config as well as pre
// support div tags for config as well as pre
// be aware using div tags relies on wikitext for parsing
// be aware using div tags relies on wikitext for parsing
// so you can't use anchor or img tags
// so you can't use anchor or img tags
// use the wikitext equivalent instead
// use the wikitext equivalent instead
if ($elem.children().length) {
if ($elem.children().length) {
$elem = $elem.children();
$elem = $elem.children();
lines = $elem.html();
lines = $elem.html();
} else {
} else {
// .html() causes html characters to be escaped for some reason
// .html() causes html characters to be escaped for some reason
// so use .text() instead for <pre> tags
// so use .text() instead for <pre> tags
lines = $elem.text();
lines = $elem.text();
}
}
lines = lines.split('\n');
config = helper.parseConfig.call(this, lines);
lines = lines.split('\n');
config = helper.parseConfig.call(this, lines);


// Calc name for localstorage, keyed to calc id
// Calc name for localstorage, keyed to calc id
this.localname = calcstorage + '-' + config.form;
this.localname = calcstorage + '-' + config.form;

// Load previous parameter values.
// Load previous parameter values.
if (!rs.hasLocalStorage()) {
if (!rs.hasLocalStorage()) {
console.warn('Browser does not support localStorage');
console.warn('Browser does not support localStorage');
} else {
} else {
mw.log('Loading previous calculator values');
mw.log('Loading previous calculator values');
var calcdata = JSON.parse( localStorage.getItem(this.localname) ) || false;
var calcdata = JSON.parse(localStorage.getItem(this.localname)) || false;
if (calcdata) {
if (calcdata) {
config.tParams.forEach( function(param) {
config.tParams.forEach(function (param) {
if (calcdata[param.name] !== undefined && calcdata[param.name] !== null) {
if (calcdata[param.name] !== undefined && calcdata[param.name] !== null) {
param.def = calcdata[param.name];
param.def = calcdata[param.name];
}
});
}
}
});
self.lsRSN = localStorage.getItem('rsn');
mw.log(config);
}
}
self.lsRSN = localStorage.getItem('rsn');
mw.log(config);
}


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


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


Line 1,756: Line 1,669:
*/
*/
Calc.prototype.getId = function (id) {
Calc.prototype.getId = function (id) {
var self = this,
var self = this,
inputId = helper.getId.call(self, id);
inputId = helper.getId.call(self, id);
return inputId;

return inputId;
};
};


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


// Used to store indexes of elements to toggle them later
// Used to store indexes of elements to toggle them later
self.indexkeys = {};
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
self.tParams.forEach(function (param, index) {
if (param.type === 'group') {
// can skip any output here as the result is pulled from the
var fields = param.range.split(/\s*,\s*/);
// param default in the config on submission
fields.forEach(function (field) {
if (param.type === 'hidden') {
return;
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
var id = helper.getId.call(self, param.name),
if (groupkeys[param.name] || groupkeys[param.name] === 0) {
method = helper.tParams[param.type] ?
self.tParams[groupkeys[param.name]].ooui.addItems([param.layout]);
param.type :
'def';
} else {
fieldset.addItems([param.layout]);
}


// Generate list of items in group
// Add item to indexkeys
if (param.type === 'group') {
self.indexkeys[param.name] = index;
});
var fields = param.range.split(/\s*,\s*/);
fields.forEach( function (field) {
groupkeys[field] = index;
});
}


// Run toggle for each field, check validity
param.layout = helper.tParams[method].call(self, param, id);
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 submitButtonAction() {
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 (param.type === 'semihidden') {
if (['off', 'false', 'disabled'].indexOf(self.autosubmit) === -1) {
param.layout.toggle(false);
// We only want one of these pending at once
}
var timeoutFunc = function timeoutFunc(param) {
clearTimeout(timeout);
// Add to group or form
timeout = setTimeout(paramChangeAction, 500, param);
if ( groupkeys[param.name] || groupkeys[param.name] === 0 ) {
};
self.tParams[ groupkeys[param.name] ].ooui.addItems([param.layout]);
} else {
// Add event
paramChangeAction = function paramChangeAction(widget) {
fieldset.addItems([param.layout]);
if (typeof widget.getFlags === 'undefined' || !widget.getFlags().includes('invalid')) {
}
helper.submitForm.call(self);

}
// Add item to indexkeys
};
self.indexkeys[param.name] = index;
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
// Run toggle for each field, check validity
self.tParams.forEach( function (param) {
self.tParams.filter(function (e) {
return e.type === 'buttonselect';
if (param.toggles && Object.keys(param.toggles).length > 0) {
}).forEach(function (e) {
var val;
var m = e.ooui.items.reduce(function (acc, curr) {
if (param.type === 'buttonselect') {
return Math.max(acc, curr.$element.find('> a.oo-ui-buttonElement-button').height());
val = param.ooui.findSelectedItem().getData();
}, 0);
} else if (param.type === 'check') {
e.ooui.items.forEach(function (e) {
val = param.ooui.isSelected() ? 'true' : 'false';
return e.$element.find('> a.oo-ui-buttonElement-button').height(m);
} 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));
})
};
};


Line 1,887: Line 1,804:
*/
*/
function lookupCalc(calcId) {
function lookupCalc(calcId) {
return calcStore[calcId];
return calcStore[calcId];
}
}


Line 1,894: Line 1,811:
*/
*/
function init() {
function init() {
// Initialises class changes
// Initialises class changes
helper.initClasses();
helper.initClasses();
$('.jcConfig').each(function () {
var c = new Calc(this);
c.setupCalc();
calcStore[c.form] = c;


// if (c.autosubmit === 'true' || c.autosubmit === true) {
$('.jcConfig').each(function () {
var c = new Calc(this);
// helper.submitForm.call(c);
c.setupCalc();
// }
});
calcStore[c.form] = c;


// allow scripts to hook into calc setup completion
// if (c.autosubmit === 'true' || c.autosubmit === true) {
mw.hook('rscalc.setupComplete').fire();
// helper.submitForm.call(c);
// }
});
// allow scripts to hook into calc setup completion
mw.hook('rscalc.setupComplete').fire();
}
}

$(init);
$(init);

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

Latest revision as of 23:09, 18 October 2024

/**
 * 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 initClasses() {
      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 parseConfig(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 getId(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 showError(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 toggle(item, toggles) {
      var self = this;
      var togitem = function togitem(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 genRange(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 parseFunc(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 parseToggles(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 submitForm() {
      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.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([]);

        // 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 loadTemplate(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.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.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 dispResult(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 sanitiseLabels(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 fixed(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 select(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 buttonselect(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 combobox(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 isvalid(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 check(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 toggleswitch(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 togglebutton(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 hs(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 lookupHS(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 rsn(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 validrsn(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 int(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 number(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 article(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 nsmap(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 nsmap(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 group(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 def(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 submitButtonAction() {
    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) {
    // We only want one of these pending at once
    var timeoutFunc = function timeoutFunc(param) {
      clearTimeout(timeout);
      timeout = setTimeout(paramChangeAction, 500, param);
    };
    // Add event
    paramChangeAction = function paramChangeAction(widget) {
      if (typeof widget.getFlags === 'undefined' || !widget.getFlags().includes('invalid')) {
        helper.submitForm.call(self);
      }
    };
    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(function (e) {
    return e.type === 'buttonselect';
  }).forEach(function (e) {
    var m = e.ooui.items.reduce(function (acc, curr) {
      return Math.max(acc, curr.$element.find('> a.oo-ui-buttonElement-button').height());
    }, 0);
    e.ooui.items.forEach(function (e) {
      return 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;