MediaWiki:Gadget-calc-core.js: Difference between revisions
Jump to navigation
Jump to search
Content added Content deleted
No edit summary Tag: Reverted |
No edit summary Tag: Reverted |
||
Line 1: | Line 1: | ||
/** * Calc script for RuneScape Wiki * * MAIN SCRIPT https://runescape.wiki/w/MediaWiki:Gadget-calc.js * https://runescape.wiki/w/MediaWiki:Gadget-calc.css * DUPLICATE TO https://oldschool.runescape.wiki/w/MediaWiki:Gadget-calc.js * https://oldschool.runescape.wiki/w/MediaWiki:Gadget-calc.css * make sure to update the hiscores URL for OSRS * * This script exposes the following hooks, accessible via `mw.hook`: * 1. 'rscalc.setupComplete' - Fires when all calculator forms have been added to the DOM. * 2. 'rscalc.submit' - Fires when a calculator form has been submitted and the result has * been added to the DOM. * For instructions on how to use `mw.hook`, see <https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.hook> * * @see Documentation <https://runescape.wiki/w/RuneScape:Calculators/Form_calculators> * @see Tests <https://runescape.wiki/w/RuneScape:Calculators/Form_calculators/Tests> * * @license GLPv3 <https://www.gnu.org/licenses/gpl-3.0.en.html> * * @author Quarenon * @author TehKittyCat * @author Joeytje50 * @author Cook Me Plox * @author Gaz Lloyd * @author Cqm * @author Elessar2 * * @todo Whitelist domains for href attributes when sanitising HTML? * @todo if we get cross-wiki imports, add a way to change hiscores URL *//*jshint bitwise:true, browser:true, camelcase:true, curly:true, devel:false, eqeqeq:true, es3:false, forin:true, immed:true, jquery:true, latedef:true, newcap:true, noarg:true, noempty:true, nonew:true, onevar:false, plusplus:false, quotmark:single, undef:true, unused:true, strict:true, trailing:true*//*global mediaWiki, mw, rswiki, rs, OO */'use strict'; /** * Prefix of localStorage key for calc data. This is prepended to the form ID * localStorage name for autosubmit setting */var calcstorage = 'rsw-calcsdata', calcautostorage = 'rsw-calcsdata-allautosub', /** * Caching for search suggestions * * @todo implement caching for mw.TitleInputWidget accroding to https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.widgets.TitleWidget-cfg-cache */ cache = {}, /** * Internal variable to store references to each calculator on the page. */ calcStore = {}, /** * Private helper methods for `Calc` * * Most methods here are called with `Function.prototype.call` * and are passed an instance of `Calc` to access it's prototype */ helper = { /** * Add/change functionality of mw/OO.ui classes * Added support for multiple namespaces to mw.widgets.TitleInputWidget */ initClasses: function () { var hasOwn = Object.prototype.hasOwnProperty; /** * Get option widgets from the server response * Changed to add support for multiple namespaces * * @param {Object} data Query result * @return {OO.ui.OptionWidget[]} Menu items */ mw.widgets.TitleInputWidget.prototype.getOptionsFromData = function (data) { var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects, currentPageName = new mw.Title( mw.config.get( 'wgRelevantPageName' ) ).getPrefixedText(), items = [], titles = [], titleObj = mw.Title.newFromText( this.getQueryValue() ), redirectsTo = {}, pageData = {}, namespaces = this.namespace.split('|').map(function (val) {return parseInt(val,10);}); if ( data.redirects ) { for ( i = 0, len = data.redirects.length; i < len; i++ ) { redirect = data.redirects[ i ]; redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || []; redirectsTo[ redirect.to ].push( redirect.from ); } } for ( index in data.pages ) { suggestionPage = data.pages[ index ]; // When excludeCurrentPage is set, don't list the current page unless the user has type the full title if ( this.excludeCurrentPage && suggestionPage.title === currentPageName && suggestionPage.title !== titleObj.getPrefixedText() ) { continue; } // When excludeDynamicNamespaces is set, ignore all pages with negative namespace if ( this.excludeDynamicNamespaces && suggestionPage.ns < 0 ) { continue; } pageData[ suggestionPage.title ] = { known: suggestionPage.known !== undefined, missing: suggestionPage.missing !== undefined, redirect: suggestionPage.redirect !== undefined, disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined, imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ), description: suggestionPage.description, // Sort index index: suggestionPage.index, originalData: suggestionPage }; // Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true // and we encounter a cross-namespace redirect. if ( this.namespace === null || namespaces.indexOf(suggestionPage.ns) >= 0 ) { titles.push( suggestionPage.title ); } redirects = hasOwn.call( redirectsTo, suggestionPage.title ) ? redirectsTo[ suggestionPage.title ] : []; for ( i = 0, len = redirects.length; i < len; i++ ) { pageData[ redirects[ i ] ] = { missing: false, known: true, redirect: true, disambiguation: false, description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title ), // Sort index, just below its target index: suggestionPage.index + 0.5, originalData: suggestionPage }; titles.push( redirects[ i ] ); } } titles.sort( function ( a, b ) { return pageData[ a ].index - pageData[ b ].index; } ); // If not found, run value through mw.Title to avoid treating a match as a // mismatch where normalisation would make them matching (T50476) pageExistsExact = ( hasOwn.call( pageData, this.getQueryValue() ) && ( !pageData[ this.getQueryValue() ].missing || pageData[ this.getQueryValue() ].known ) ); pageExists = pageExistsExact || ( titleObj && hasOwn.call( pageData, titleObj.getPrefixedText() ) && ( !pageData[ titleObj.getPrefixedText() ].missing || pageData[ titleObj.getPrefixedText() ].known ) ); if ( this.cache ) { this.cache.set( pageData ); } // Offer the exact text as a suggestion if the page exists if ( this.addQueryInput && pageExists && !pageExistsExact ) { titles.unshift( this.getQueryValue() ); } for ( i = 0, len = titles.length; i < len; i++ ) { page = hasOwn.call( pageData, titles[ i ] ) ? pageData[ titles[ i ] ] : {}; items.push( this.createOptionWidget( this.getOptionWidgetData( titles[ i ], page ) ) ); } return items; }; }, /** * Parse the calculator configuration * * @param lines {Array} An array containing the calculator's configuration * @returns {Object} An object representing the calculator's configuration */ parseConfig: function (lines) { var defConfig = { suggestns: [], autosubmit: 'off', name: 'Calculator' }, config = { // this isn't in `defConfig` // as it'll get overridden anyway tParams: [] }, // used for debugging incorrect config names validParams = [ 'form', 'param', 'result', 'suggestns', 'template', 'module', 'modulefunc', 'name', 'autosubmit' ], // used for debugging incorrect param types validParamTypes = [ 'string', 'article', 'number', 'int', 'select', 'buttonselect', 'combobox', 'check', 'toggleswitch', 'togglebutton', 'hs', 'rsn', 'fixed', 'hidden', 'semihidden', 'group' ], configError = false; // parse the calculator's config // @example param=arg1|arg1|arg3|arg4 lines.forEach(function (line) { var temp = line.split('='), param, args; // incorrect config if (temp.length < 2) { return; } // an equals is used in one of the arguments // @example HTML label with attributes // so join them back together to preserve it // this also allows support of HTML attributes in labels if (temp.length > 2) { temp[1] = temp.slice(1,temp.length).join('='); } param = temp[0].trim().toLowerCase(); args = temp[1].trim(); if (validParams.indexOf(param) === -1) { // use console for easier debugging console.log('Unknown parameter: ' + param); configError = true; return; } if (param === 'suggestns') { config.suggestns = args.split(/\s*,\s*/); return; } if (param !== 'param') { config[param] = args; return; } // split args args = args.split(/\s*\|\s*/); // store template params in an array to make life easier config.tParams = config.tParams || []; if (validParamTypes.indexOf(args[3]) === -1 && args[3] !== '' && args[3] !== undefined) { // use console for easier debugging console.log('Unknown param type: ' + args[3]); configError = true; return; } var inlinehelp = false, help = ''; if (args[6]) { var tmphelp = args[6].split(/\s*=\s*/); if (tmphelp.length > 1) { if ( tmphelp[0] === 'inline' ) { inlinehelp = true; // Html etc can have = so join them back together tmphelp[1] = tmphelp.slice(1,tmphelp.length).join('='); help = helper.sanitiseLabels(tmphelp[1] || ''); } else { // Html etc can have = so join them back together tmphelp[0] = tmphelp.join('='); help = helper.sanitiseLabels(tmphelp[0] || ''); } } else { help = helper.sanitiseLabels(tmphelp[0] || ''); } } config.tParams.push({ name: mw.html.escape(args[0]), label: helper.sanitiseLabels(args[1] || args[0]), def: mw.html.escape(args[2] || ''), type: mw.html.escape(args[3] || ''), range: args[4] || '', rawtogs: args[5] || '', inlhelp: inlinehelp, help: help }); }); if (configError) { config.configError = 'This calculator\'s config contains errors. Please report it ' + '<a href="/w/RuneScape:User_help" title="RuneScape:User help">here</a> ' + 'or check the javascript console for details.'; } config = $.extend(defConfig, config); mw.log(config); return config; }, /** * Generate a unique id for each input * * @param inputId {String} A string representing the id of an input * @returns {String} A string representing the namespaced/prefixed id of an input */ getId: function (inputId) { return [this.form, this.result, inputId].join('-'); }, /** * Output an error to the UI * * @param error {String} A string representing the error message to be output */ showError: function (error) { $('#' + this.result) .empty() .append( $('<strong>') .addClass('error') .text(error) ); }, /** * Toggle the visibility and enabled status of fields/groups * * @param item {String} A string representing the current value of the widget * @param toggles {object} An object representing arrays of items to be toggled keyed by widget values */ toggle: function (item, toggles) { var self = this; var togitem = function (widget, show) { var param = self.tParams[ self.indexkeys[widget] ]; if (param.type === 'group') { param.ooui.toggle(show); param.ooui.getItems().forEach(function (child) { if (!!child.setDisabled) { child.setDisabled(!show); } else if (!!child.getField().setDisabled) { child.getField().setDisabled(!show); } }); } else if ( param.type === 'semihidden' ) { if (!!param.ooui.setDisabled) { param.ooui.setDisabled(!show); } } else { param.layout.toggle(show); if (!!param.ooui.setDisabled) { param.ooui.setDisabled(!show); } } }; if (toggles[item]) { toggles[item].on.forEach( function (widget) { togitem(widget, true); }); toggles[item].off.forEach( function (widget) { togitem(widget, false); }); } else if ( toggles.not0 && !isNaN(parseFloat(item)) && parseFloat(item) !== 0 ) { toggles.not0.on.forEach( function (widget) { togitem(widget, true); }); toggles.not0.off.forEach( function (widget) { togitem(widget, false); }); } else if (toggles.alltogs) { toggles.alltogs.off.forEach( function (widget) { togitem(widget, false); }); } }, /** * Generate range and step for number and int inputs * * @param rawdata {string} The string representation of the range and steps * @param type {string} The name of the field type (int or number) * @returns {array} An array containing the min value, max value, step and button step. */ genRange: function (rawdata,type) { var tmp = rawdata.split(/\s*,\s*/), rng = tmp[0].split(/\s*-\s*/), step = tmp[1] || '', bstep = tmp[2] || '', min, max, parseFunc; if (type==='int') { parseFunc = function(x) { return parseInt(x, 10); } } else { parseFunc = parseFloat; } if (type === 'int') { step = 1; if ( isNaN(parseInt(bstep,10)) ) { bstep = 1; } else { bstep = parseInt(bstep,10); } } else { if ( isNaN(parseFloat(step)) ) { step = 0.01; } else { step = parseFloat(step); } if ( isNaN(parseFloat(bstep)) ) { bstep = 1; } else { bstep = parseFloat(bstep); } } // Accept negative values for either range position if ( rng.length === 3 ) { // 1 value is negative if ( rng[0] === '' ) { // First value negative if ( isNaN(parseFunc(rng[1])) ) { min = -Infinity; } else { min = 0 - parseFunc(rng[1]); } if ( isNaN(parseFunc(rng[2])) ) { max = Infinity; } else { max = parseFunc(rng[2]); } } else if ( rng[1] === '' ) { // Second value negative if ( isNaN(parseFunc(rng[0])) ) { min = -Infinity; } else { min = parseFunc(rng[0]); } if ( isNaN(parseFunc(rng[2])) ) { max = 0; } else { max = 0 - parseFunc(rng[2]); } } } else if ( rng.length === 4 ) { // Both negative if ( isNaN(parseFunc(rng[1])) ) { min = -Infinity; } else { min = 0 - parseFunc(rng[1]); } if ( isNaN(parseFunc(rng[3])) ) { max = 0; } else { max = 0 - parseFunc(rng[3]); } } else { // No negatives if ( isNaN(parseFunc(rng[0])) ) { min = 0; } else { min = parseFunc(rng[0]); } if ( isNaN(parseFunc(rng[1])) ) { max = Infinity; } else { max = parseFunc(rng[1]); } } // Check min < max if ( max < min ) { return [ max, min, step, bstep ]; } else { return [ min, max, step, bstep ]; } }, /** * Parse the toggles for an input * * @param rawdata {string} A string representing the toggles for the widget * @param defkey {string} The default key for toggles * @returns {object} An object representing the toggles in the format { ['widget value']:[ widget-to-toggle, group-to-toggle, widget-to-toggle2 ] } */ parseToggles: function (rawdata,defkey) { var tmptogs = rawdata.split(/\s*;\s*/), allkeys = [], allvals = [], toggles = {}; if (tmptogs.length > 0 && tmptogs[0].length > 0) { tmptogs.forEach(function (tog) { var tmp = tog.split(/\s*=\s*/), keys = tmp[0], val = []; if (tmp.length < 2) { keys = [defkey]; val = tmp[0].split(/\s*,\s*/); } else { keys = tmp[0].split(/\s*,\s*/); val = tmp[1].split(/\s*,\s*/); } if (keys.length === 1) { var key = keys[0]; toggles[key] = {}; toggles[key].on = val; allkeys.push(key); } else { keys.forEach( function (key) { toggles[key] = {}; toggles[key].on = val; allkeys.push(key); }); } allvals = allvals.concat(val); }); allkeys = allkeys.filter(function (item, pos, arr) { return arr.indexOf(item) === pos; }); allkeys.forEach(function (key) { toggles[key].off = allvals.filter(function (val) { if ( toggles[key].on.includes(val) ) { return false; } else { return true; } }); }); // Add all items to default toggles.alltogs = {}; toggles.alltogs.off = allvals; } return toggles; }, /** * Form submission handler */ submitForm: function () { var self = this, code = '{{' + self.template, formErrors = [], apicalls = [], paramVals = {}; if (self.module !== undefined) { if (self.modulefunc === undefined) { self.modulefunc = 'main'; }//<nowiki> code = '{{#invoke:'+self.module+'|'+self.modulefunc; }//</nowiki> self.submitlayout.setNotices(['Validating fields, please wait.']); self.submitlayout.fieldWidget.setDisabled(true); // setup template for submission self.tParams.forEach(function (param) { if ( param.type === 'hidden' || (param.type !== 'group' && param.ooui.isDisabled() === false) ) { var val, $input, // use separate error tracking for each input // or every input gets flagged as an error error = ''; if (param.type === 'fixed' || param.type === 'hidden') { val = param.def; } else { $input = $('#' + helper.getId.call(self, param.name) + ' input'); if (param.type === 'buttonselect') { val = param.ooui.findSelectedItem(); if (val !== null) { val = val.getData(); } } else { val = param.ooui.getValue(); } if (param.type === 'int') { val = val.split(',').join(''); } else if (param.type === 'check') { val = param.ooui.isSelected(); if (param.range) { val = param.range.split(',')[val ? 0 : 1]; } } else if (param.type === 'toggleswitch' || param.type === 'togglebutton') { if (param.range) { val = param.range.split(',')[val ? 0 : 1]; } } // Check input is valid (based on widgets validation) if ( !!param.ooui.hasFlag && param.ooui.hasFlag('invalid') && param.type !== 'article') { error = param.error; } else if ( param.type === 'article' && param.ooui.validateTitle && val.length > 0 ) { var api = param.ooui.getApi(), prms = { action: 'query', prop: [], titles: [ param.ooui.getValue() ] }; var prom = new Promise ( function (resolve,reject) { api.get(prms).then( function (ret) { if ( ret.query.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 (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 (html) { var self = this; $('#' + self.form + ' .jcSubmit') .data('oouiButton') .setDisabled(false); $('#bodyContent') .find('#' + this.result) .empty() .removeClass('jcError') .html(html); // allow scripts to hook into form submission mw.hook('rscalc.submit').fire(); // run all standard page-init things so various JS works as expected, including: // - sortable tables // - collapsible sections // - collapsed headers on mobile mw.hook('wikipage.content').fire($('#'+this.result)); /* mw.loader.using('jquery.tablesorter', function () { $('table.sortable:not(.jquery-tablesorter)').tablesorter(); }); mw.loader.using('jquery.makeCollapsible', function () { $('.mw-collapsible').makeCollapsible(); }); */ if ($('.rsw-chartjs-config').length) { mw.loader.load('ext.gadget.Charts-core'); } }, /** * Sanitise any HTML used in labels * * @param html {string} A HTML string to be sanitised * @returns {jQuery.object} A jQuery object representing the sanitised HTML */ sanitiseLabels: function (html) { var whitelistAttrs = [ // mainly for span/div tags 'style', // for anchor tags 'href', 'title', // for img tags 'src', 'alt', 'height', 'width', // misc 'class' ], whitelistTags = [ 'a', 'span', 'div', 'img', 'strong', 'b', 'em', 'i', 'br' ], // parse the HTML string, removing script tags at the same time $html = $.parseHTML(html, /* document */ null, /* keepscripts */ false), // append to a div so we can navigate the node tree $div = $('<div>').append($html); $div.find('*').each(function () { var $this = $(this), tagname = $this.prop('tagName').toLowerCase(), attrs, array, href; if (whitelistTags.indexOf(tagname) === -1) { mw.log('Disallowed tagname: ' + tagname); $this.remove(); return; } attrs = $this.prop('attributes'); array = Array.prototype.slice.call(attrs); array.forEach(function (attr) { if (whitelistAttrs.indexOf(attr.name) === -1) { mw.log('Disallowed attribute: ' + attr.name + ', tagname: ' + tagname); $this.removeAttr(attr.name); return; } // make sure there's nasty in nothing in href attributes if (attr.name === 'href') { href = $this.attr('href'); if ( // disable warnings about script URLs // jshint -W107 href.indexOf('javascript:') > -1 || // the mw sanitizer doesn't like these // so lets follow suit // apparently it's something microsoft dreamed up href.indexOf('vbscript:') > -1 // jshint +W107 ) { mw.log('Script URL detected in ' + tagname); $this.removeAttr('href'); } } }); }); return $div.contents(); }, /** * Handlers for parameter input types */ tParams: { /** * Handler for 'fixed' inputs * * @param param {object} An object containing the configuration of a parameter * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ fixed: function (param) { var layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-fixed'], value: param.def }; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } param.ooui = new OO.ui.LabelWidget({ label: param.def }); return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for select dropdowns * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ select: function (param, id) { var self = this, conf = { label: 'Select an option', options: [], name: id, id: id, value: param.def, dropdown: { $overlay: true } }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-select'] }, opts = param.range.split(/\s*,\s*/), def = opts[0]; param.error = 'Not a valid selection'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } opts.forEach(function (opt, i) { var op = { data: opt, label: opt }; if (opt === param.def) { op.selected = true; def = opt; } conf.options.push(op); }); param.toggles = helper.parseToggles(param.rawtogs, def); param.ooui = new OO.ui.DropdownInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (value) { helper.toggle.call(self, value, param.toggles); }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for button selects * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ buttonselect: function (param, id) { var self = this, buttons = {}, conf = { label:'Select an option', items: [], id: id }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-buttonselect'] }, opts = param.range.split(/\s*,\s*/), def; param.error = 'Please select a valid option'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } opts.forEach(function (opt, i) { var opid = opt.replace(/[^a-zA-Z0-9]/g, ''); var $opt = helper.sanitiseLabels(opt); var txt = $opt.text().trim(); if (txt === '') { txt = (i+1).toString(); } buttons[opid] = new OO.ui.ButtonOptionWidget({data:txt, label: new OO.ui.HtmlSnippet($opt), title:txt}); conf.items.push(buttons[opid]); }); if (param.def.length > 0 && opts.indexOf(param.def) > -1) { def = param.def; } else { def = opts[0]; } param.toggles = helper.parseToggles(param.rawtogs, def); param.ooui = new OO.ui.ButtonSelectWidget(conf); param.ooui.selectItemByData(def); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('choose', function (button) { var item = button.getData(); helper.toggle.call(self, item, param.toggles); }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for comboboxes * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ combobox: function (param, id) { var self = this, conf = { placeholder: 'Enter filter name', options: [], name: id, id: id, menu: { filterFromInput: true }, value: param.def, $overlay: true }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-combobox'] }, opts = param.range.split(/\s*,\s*/), def = opts[0]; param.error = 'Not a valid selection'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } opts.forEach(function (opt) { var op = { data: opt, label: opt }; if (opt === param.def) { op.selected = true; def = opt; } conf.options.push(op); }); var isvalid = function (val) {return opts.indexOf(val) < 0 ? false : true;}; conf.validate = isvalid; param.toggles = helper.parseToggles(param.rawtogs, def); param.ooui = new OO.ui.ComboBoxInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (value) { helper.toggle.call(self, value, param.toggles); }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for checkbox inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ check: function (param, id) { var self = this, conf = { name: id, id: id }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-check'] }; param.toggles = helper.parseToggles(param.rawtogs, 'true'); param.error = 'Unknown error'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if ( (param.def === 'true' || param.def === true) || (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { conf.selected = true; } param.ooui = new OO.ui.CheckboxInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (selected) { if (selected) { helper.toggle.call(self, 'true', param.toggles); } else { helper.toggle.call(self, 'false', param.toggles); } }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for toggle switch inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ toggleswitch: function (param, id) { var self = this, conf = { id: id }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-toggleswitch'] }; param.toggles = helper.parseToggles(param.rawtogs, 'true'); param.error = 'Unknown error'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if ( (param.def === 'true' || param.def === true) || (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { conf.value = true; } param.ooui = new OO.ui.ToggleSwitchWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (selected) { if (selected) { helper.toggle.call(self, 'true', param.toggles); } else { helper.toggle.call(self, 'false', param.toggles); } }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for toggle button inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ togglebutton: function (param, id) { var self = this, conf = { id: id, label: new OO.ui.HtmlSnippet(param.label) }, layconf = { label:'', align: 'right', classes: ['jsCalc-field', 'jsCalc-field-togglebutton'] }; param.toggles = helper.parseToggles(param.rawtogs, 'true'); param.error = 'Unknown error'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if ( (param.def === 'true' || param.def === true) || (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { conf.value = true; } param.ooui = new OO.ui.ToggleButtonWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (selected) { if (selected) { helper.toggle.call(self, 'true', param.toggles); } else { helper.toggle.call(self, 'false', param.toggles); } }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for hiscore inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ hs: function (param, id) { var self = this, layconf = { label: new OO.ui.HtmlSnippet(param.label), align:'right', classes: ['jsCalc-field', 'jsCalc-field-hs'] }, lookups = {}, range = param.range.split(';'), input1 = new OO.ui.TextInputWidget({type: 'text', id: id, name: id, value:param.def}), button1 = new OO.ui.ButtonInputWidget({ label: 'Lookup', id: id+'-button', name: id+'-button', classes: ['jsCalc-field-hs-lookup'], data: {param: param.name} }); if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } var layout = new OO.ui.ActionFieldLayout(input1, button1, layconf); var lookupHS = function(event) { var $t = $(event.target), lookup = self.lookups[button1.getData().param], // replace spaces with _ for the query name = $('#' + lookup.id + ' input') .val() // @todo will this break for players with multiple spaces // in their name? e.g. suomi's old display name .replace(/\s+/g, '_'), button = lookup.button; button.setDisabled(true); $.ajax({ url: '/cors/m=hiscore_oldschool/index_lite.ws?player=' + name, dataType: 'text', async: true, timeout: 10000 // msec }).done(function (data) { var hsdata; hsdata = data.trim() .split(/\n+/g); lookup.params.forEach(function (param) { var id = helper.getId.call(self, param.param), $input = $('#' + id + ' input'), tParam = null, val; self.tParams.forEach(function(p) { if (p.name === param.param) { tParam = p; } }); if (tParam === null) { return; } if (isNaN(param.skill)) { val = param.skill; //tParam.ooui.setValue(param.skill); } else { val = hsdata[param.skill].split(',')[param.val]; //tParam.ooui.setValue(hsdata[param.skill].split(',')[param.val]); } if (!!tParam.ooui.setValue) { tParam.ooui.setValue(val); } else if (!!tParam.ooui.selectItemByData) { tParam.ooui.selectItemByData(val); } else if (tParam.type === 'fixed') { tParam.ooui.setLabel(val); } }); // store in localStorage for future use if (rs.hasLocalStorage()) { self.lsRSN = name; localStorage.setItem('rsn', name); } button.setDisabled(false); layout.setErrors([]); }) .fail(function (xhr, status) { button.setDisabled(false); var err = 'The player "' + name + '" does not exist, is banned or unranked, or we couldn\'t fetch your hiscores. Please enter the data manually.'; console.warn(status); layout.setErrors([err]); helper.showError.call(self, err); }); }; button1.$element.click(lookupHS); input1.$element.keydown(function(event){ if (event.which === 13) { lookupHS(event); event.preventDefault(); } }); // Use rsn loaded from localstorage if (self.lsRSN) { input1.setValue(self.lsRSN); } lookups[param.name] = { id: id, button: button1, params: [] }; range.forEach(function (el) { // to catch empty strings if (!el) { return; } var spl = el.split(','); lookups[param.name].params.push({ param: spl[0], skill: spl[1], val: spl[2] }); }); // merge lookups into one object if (!self.lookups) { self.lookups = lookups; } else { self.lookups = $.extend(self.lookups, lookups); } param.ooui = input1; param.oouiButton = button1; return layout; }, /** * Handler for Runescape name inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ rsn: function (param, id) { var self = this, conf = { type: 'text', name: id, id: id, placeholder: 'Enter runescape name', spellcheck: false, maxLength: 12, value: param.def }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-string'] }; param.error = 'Invalid runescape name: RS names must be 1-12 characters long, can only contain letters, numbers, spaces, dashes and underscores. Names containing Mod are also not allowed.'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } // Use rsn loaded from localstorage, if available if (self.lsRSN) { conf.value = self.lsRSN; } var validrsn = function (name) { if ( name.search( /[^0-9a-zA-Z\-_\s]/ ) >= 0 ) { return false; } else { if ( name.toLowerCase().search( /(^mod\s|\smod\s|\smod$)/ ) >= 0 ) { return false; } else { return true; } } }; conf.validate = validrsn; param.ooui = new OO.ui.TextInputWidget(conf); return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for integer inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ int: function (param, id) { var self = this, rng = helper.genRange(param.range, 'int'), conf = { min:rng[0], max:rng[1], step:rng[2], showButtons:true, buttonStep:rng[3], allowInteger:true, name: id, id: id, value: param.def || 0 }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-int'] }, error = 'Invalid integer. Must be between ' + rng[0] + ' and ' + rng[1]; param.toggles = helper.parseToggles(param.rawtogs, 'not0'); if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if ( rng[2] > 1 ) { error += ' and a muiltiple of ' + rng[2]; } param.error = error; param.ooui = new OO.ui.NumberInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (value) { helper.toggle.call(self, value, param.toggles); }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for number inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ number: function (param, id) { var self = this, rng = helper.genRange(param.range, 'number'), conf = { min:rng[0], max:rng[1], step:rng[2], showButtons:true, buttonStep:rng[3], name:id, id:id, value:param.def || 0 }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-number'], }; param.toggles = helper.parseToggles(param.rawtogs, 'not0'); param.error = 'Invalid interger. Must be between ' + rng[0] + ' and ' + rng[1] + ' and a multiple of ' + rng[2]; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } param.ooui = new OO.ui.NumberInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (value) { helper.toggle.call(self, value, param.toggles); }); } return new OO.ui.FieldLayout( param.ooui, layconf); }, /** * Handler for article inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ article: function (param, id) { var self = this, conf = { addQueryInput: false, excludeCurrentPage: true, showMissing: false, showDescriptions: true, validateTitle: true, relative: false, id: id, name: id, placeholder: 'Enter page name', value: param.def }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align:'right', classes: ['jsCalc-field', 'jsCalc-field-article'] }, validNSnumbers = { '_*':'All', '_-2':'Media', '_-1':'Special', _0:'(Main)', _1:'Talk', _2:'User', _3:'User talk', _4:'RuneScape', _5:'RuneScape talk', _6:'File', _7:'File talk', _8:'MediaWiki', _9:'MediaWiki talk', _10:'Template', _11:'Template talk', _12:'Help', _13:'Help talk', _14:'Category', _15:'Category talk', _100:'Update', _101:'Update talk', _110:'Forum', _111:'Forum talk', _112:'Exchange', _113:'Exchange talk', _114:'Charm', _115:'Charm talk', _116:'Calculator', _117:'Calculator talk', _118:'Map', _119:'Map talk', _828:'Module', _829:'Module talk' }, validNSnames = { all:'*', media:-2, special:-1, main:0, '(main)':0, talk:1, user:2, 'user talk':3, runescape:4, 'runescape talk':5, file:6, 'file talk':7, mediawiki:8, 'mediawiki talk':9, template:10, 'template talk':11, help:12, 'help talk':13, category:14, 'category talk':15, update:100, 'update talk':101, forum:110, 'forum talk':111, exchange:112, 'exchange talk':113, charm:114, 'charm talk':115, calculator:116, 'calculator talk':117, map:118, 'map talk':119, module:828, 'module talk':829 }, namespaces = ''; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if (param.range && param.range.length > 0) { var names = param.range.split(/\s*,\s*/), nsnumbers = []; names.forEach( function (nmspace) { nmspace = nmspace.toLowerCase(); if ( validNSnumbers['_'+nmspace] ) { nsnumbers.push(nmspace); } else if ( validNSnames[nmspace] ) { nsnumbers.push( validNSnames[nmspace] ); } }); if (nsnumbers.length < 1) { conf.namespace = '0'; namespaces = '(Main) namespace'; } else if (nsnumbers.length < 2) { conf.namespace = nsnumbers[0]; namespaces = nsnumbers[0] + ' namespace'; } else { conf.namespace = nsnumbers.join('|'); var nsmap = function (num) { return validNSnumbers['_'+num]; }; namespaces = nsnumbers.slice(0, -1).map(nsmap).join(', ') + ' or ' + nsnumbers.slice(-1).map(nsmap)[0] + ' namespaces'; } } else if ( self.suggestns && self.suggestns.length > 0 ) { var nsnumbers = []; self.suggestns.forEach( function (nmspace) { nmspace = nmspace.toLowerCase(); if ( validNSnumbers['_'+nmspace] ) { nsnumbers.push(nmspace); } else if ( validNSnames[nmspace] ) { nsnumbers.push( validNSnames[nmspace] ); } }); if (nsnumbers.length < 1) { conf.namespace = '0'; namespaces = '(Main) namespace'; } else if (nsnumbers.length < 2) { conf.namespace = nsnumbers[0]; namespaces = nsnumbers[0] + ' namespace'; } else { conf.namespace = nsnumbers.join('|'); var nsmap = function (num) { return validNSnumbers['_'+num]; }; namespaces = nsnumbers.slice(0, -1).map(nsmap).join(', ') + ' or ' + nsnumbers.slice(-1).map(nsmap)[0] + ' namespaces'; } } else { conf.namespace = '0'; namespaces = '(Main) namespace'; } param.error = 'Invalid page or page is not in ' + namespaces; param.ooui = new mw.widgets.TitleInputWidget(conf); return new OO.ui.FieldLayout( param.ooui, layconf); }, /** * Handler for group type params * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ group: function (param, id) { param.ooui = new OO.ui.HorizontalLayout({id: id, classes: ['jsCalc-group']}); if (param.label !== param.name) { var label = new OO.ui.LabelWidget({ label: new OO.ui.HtmlSnippet(param.label), classes:['jsCalc-grouplabel'] }); param.ooui.addItems([label]); } return param.ooui; }, /** * Default handler for inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ def: function (param, id) { var layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-string'], value: param.def }; param.error = 'Unknown error'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } param.ooui = new OO.ui.TextInputWidget({type: 'text', name: id, id: id}); return new OO.ui.FieldLayout(param.ooui, layconf); } } };/** * Create an instance of `Calc` * and parse the config stored in `elem` * * @param elem {Element} An Element representing the HTML tag that contains * the calculator's configuration */function Calc(elem) { var self = this, $elem = $(elem), lines, config; // support div tags for config as well as pre // be aware using div tags relies on wikitext for parsing // so you can't use anchor or img tags // use the wikitext equivalent instead if ($elem.children().length) { $elem = $elem.children(); lines = $elem.html(); } else { // .html() causes html characters to be escaped for some reason // so use .text() instead for <pre> tags lines = $elem.text(); } lines = lines.split('\n'); config = helper.parseConfig.call(this, lines); // Calc name for localstorage, keyed to calc id this.localname = calcstorage + '-' + config.form; // Load previous parameter values. if (!rs.hasLocalStorage()) { console.warn('Browser does not support localStorage'); } else { mw.log('Loading previous calculator values'); var calcdata = JSON.parse( localStorage.getItem(this.localname) ) || false; if (calcdata) { config.tParams.forEach( function(param) { if (calcdata[param.name] !== undefined && calcdata[param.name] !== null) { param.def = calcdata[param.name]; } }); } self.lsRSN = localStorage.getItem('rsn'); mw.log(config); } // merge config in $.extend(this, config); /** * @todo document */ this.getInput = function (id) { if (id) { id = helper.getId.call(self, id); return $('#' + id); } return $('#jsForm-' + self.form).find('select, input'); };}/** * Helper function for getting the id of an input * * @param id {string} The id of the input as specified by the calculator config. * @returns {string} The true id of the input with prefixes. */Calc.prototype.getId = function (id) { var self = this, inputId = helper.getId.call(self, id); return inputId;};/** * Build the calculator form */Calc.prototype.setupCalc = function () { var self = this, fieldset = new OO.ui.FieldsetLayout({label: self.name, classes: ['jcTable'], id: 'jsForm-'+self.form}), submitButton, submitButtonAction, paramChangeAction, timeout, groupkeys = {}; // Used to store indexes of elements to toggle them later self.indexkeys = {}; self.tParams.forEach(function (param, index) { // can skip any output here as the result is pulled from the // param default in the config on submission if (param.type === 'hidden') { return; } var id = helper.getId.call(self, param.name), method = helper.tParams[param.type] ? param.type : 'def'; // Generate list of items in group if (param.type === 'group') { var fields = param.range.split(/\s*,\s*/); fields.forEach( function (field) { groupkeys[field] = index; }); } param.layout = helper.tParams[method].call(self, param, id); if (param.type === 'semihidden') { param.layout.toggle(false); } // Add to group or form if ( groupkeys[param.name] || groupkeys[param.name] === 0 ) { self.tParams[ groupkeys[param.name] ].ooui.addItems([param.layout]); } else { fieldset.addItems([param.layout]); } // Add item to indexkeys self.indexkeys[param.name] = index; }); // Run toggle for each field, check validity self.tParams.forEach( function (param) { if (param.toggles && Object.keys(param.toggles).length > 0) { var val; if (param.type === 'buttonselect') { val = param.ooui.findSelectedItem().getData(); } else if (param.type === 'check') { val = param.ooui.isSelected() ? 'true' : 'false'; } else if (param.type === 'toggleswitch' || param.type === 'togglebutton') { val = param.ooui.getValue() ? 'true' : 'false'; } else { val = param.ooui.getValue(); } helper.toggle.call(self, val, param.toggles); } if (param.type === 'number' || param.type === 'int' || param.type === 'rsn') { param.ooui.setValidityFlag(); } }); submitButton = new OO.ui.ButtonInputWidget({ label: 'Submit', flags: ['primary', 'progressive'], classes: ['jcSubmit']}); submitButtonAction = function (){ helper.submitForm.call(self); }; submitButton.on('click', submitButtonAction); submitButton.$element.data('oouiButton', submitButton); self.submitlayout = new OO.ui.FieldLayout(submitButton, {label: ' ', align: 'right', classes: ['jsCalc-field', 'jsCalc-field-submit']}); fieldset.addItems([ self.submitlayout ]); // Auto-submit if (['off', 'false', 'disabled'].indexOf(self.autosubmit) === -1) { // Add event paramChangeAction = function (widget) { if ( typeof widget.getFlags === 'undefined' || !widget.getFlags().includes('invalid')) { helper.submitForm.call(self); } }; // We only want one of these pending at once function timeoutFunc(param) { clearTimeout(timeout); timeout = setTimeout(paramChangeAction, 500, param); } self.tParams.forEach( function (param) { if (param.type === 'hidden' || param.type === 'hs' || param.type === 'group') { return; } else if (param.type === 'buttonselect') { param.ooui.on('select', timeoutFunc, [param.ooui]); } param.ooui.on('change', timeoutFunc, [param.ooui]); }); } if (self.configError) { fieldset.$element.append('<br>', self.configError); } $('#bodyContent') .find('#' + self.form) .empty() .append(fieldset.$element); // make buttonselects all the same height self.tParams.filter(e=>e.type==='buttonselect').forEach(e=>{ let m = e.ooui.items.reduce((acc,curr)=>Math.max(acc, curr.$element.find('> a.oo-ui-buttonElement-button').height()), 0); e.ooui.items.forEach(e=>e.$element.find('> a.oo-ui-buttonElement-button').height(m)); })};/** * @todo */function lookupCalc(calcId) { return calcStore[calcId];}/** * @todo */function init() { // Initialises class changes helper.initClasses(); $('.jcConfig').each(function () { var c = new Calc(this); c.setupCalc(); calcStore[c.form] = c; // if (c.autosubmit === 'true' || c.autosubmit === true) { // helper.submitForm.call(c); // } }); // allow scripts to hook into calc setup completion mw.hook('rscalc.setupComplete').fire();}$(init);rs.calc = {};rs.calc.lookup = lookupCalc; |
|||
/** |
|||
* Calc script for RuneScape Wiki |
|||
* |
|||
* MAIN SCRIPT https://runescape.wiki/w/MediaWiki:Gadget-calc.js |
|||
* https://runescape.wiki/w/MediaWiki:Gadget-calc.css |
|||
* DUPLICATE TO https://oldschool.runescape.wiki/w/MediaWiki:Gadget-calc.js |
|||
* https://oldschool.runescape.wiki/w/MediaWiki:Gadget-calc.css |
|||
* make sure to update the hiscores URL for OSRS |
|||
* |
|||
* This script exposes the following hooks, accessible via `mw.hook`: |
|||
* 1. 'rscalc.setupComplete' - Fires when all calculator forms have been added to the DOM. |
|||
* 2. 'rscalc.submit' - Fires when a calculator form has been submitted and the result has |
|||
* been added to the DOM. |
|||
* For instructions on how to use `mw.hook`, see <https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.hook> |
|||
* |
|||
* @see Documentation <https://runescape.wiki/w/RuneScape:Calculators/Form_calculators> |
|||
* @see Tests <https://runescape.wiki/w/RuneScape:Calculators/Form_calculators/Tests> |
|||
* |
|||
* @license GLPv3 <https://www.gnu.org/licenses/gpl-3.0.en.html> |
|||
* |
|||
* @author Quarenon |
|||
* @author TehKittyCat |
|||
* @author Joeytje50 |
|||
* @author Cook Me Plox |
|||
* @author Gaz Lloyd |
|||
* @author Cqm |
|||
* @author Elessar2 |
|||
* |
|||
* @todo Whitelist domains for href attributes when sanitising HTML? |
|||
* @todo if we get cross-wiki imports, add a way to change hiscores URL |
|||
*/ |
|||
/*jshint bitwise:true, browser:true, camelcase:true, curly:true, devel:false, |
|||
eqeqeq:true, es3:false, forin:true, immed:true, jquery:true, |
|||
latedef:true, newcap:true, noarg:true, noempty:true, nonew:true, |
|||
onevar:false, plusplus:false, quotmark:single, undef:true, unused:true, |
|||
strict:true, trailing:true |
|||
*/ |
|||
/*global mediaWiki, mw, rswiki, rs, OO */ |
|||
'use strict'; |
|||
/** |
|||
* Prefix of localStorage key for calc data. This is prepended to the form ID |
|||
* localStorage name for autosubmit setting |
|||
*/ |
|||
var calcstorage = 'rsw-calcsdata', |
|||
calcautostorage = 'rsw-calcsdata-allautosub', |
|||
/** |
|||
* Caching for search suggestions |
|||
* |
|||
* @todo implement caching for mw.TitleInputWidget accroding to https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.widgets.TitleWidget-cfg-cache |
|||
*/ |
|||
cache = {}, |
|||
/** |
|||
* Internal variable to store references to each calculator on the page. |
|||
*/ |
|||
calcStore = {}, |
|||
/** |
|||
* Private helper methods for `Calc` |
|||
* |
|||
* Most methods here are called with `Function.prototype.call` |
|||
* and are passed an instance of `Calc` to access it's prototype |
|||
*/ |
|||
helper = { |
|||
/** |
|||
* Add/change functionality of mw/OO.ui classes |
|||
* Added support for multiple namespaces to mw.widgets.TitleInputWidget |
|||
*/ |
|||
initClasses: function () { |
|||
var hasOwn = Object.prototype.hasOwnProperty; |
|||
/** |
|||
* Get option widgets from the server response |
|||
* Changed to add support for multiple namespaces |
|||
* |
|||
* @param {Object} data Query result |
|||
* @return {OO.ui.OptionWidget[]} Menu items |
|||
*/ |
|||
mw.widgets.TitleInputWidget.prototype.getOptionsFromData = function (data) { |
|||
var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects, |
|||
currentPageName = new mw.Title( mw.config.get( 'wgRelevantPageName' ) ).getPrefixedText(), |
|||
items = [], |
|||
titles = [], |
|||
titleObj = mw.Title.newFromText( this.getQueryValue() ), |
|||
redirectsTo = {}, |
|||
pageData = {}, |
|||
namespaces = this.namespace.split('|').map(function (val) {return parseInt(val,10);}); |
|||
if ( data.redirects ) { |
|||
for ( i = 0, len = data.redirects.length; i < len; i++ ) { |
|||
redirect = data.redirects[ i ]; |
|||
redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || []; |
|||
redirectsTo[ redirect.to ].push( redirect.from ); |
|||
} |
|||
} |
|||
for ( index in data.mw.pages ) { |
|||
suggestionPage = data.mw.pages[ index ]; |
|||
// When excludeCurrentPage is set, don't list the current page unless the user has type the full title |
|||
if ( this.excludeCurrentPage && suggestionPage.title === currentPageName && suggestionPage.title !== titleObj.getPrefixedText() ) { |
|||
continue; |
|||
} |
|||
// When excludeDynamicNamespaces is set, ignore all mw.pages with negative namespace |
|||
if ( this.excludeDynamicNamespaces && suggestionPage.ns < 0 ) { |
|||
continue; |
|||
} |
|||
pageData[ suggestionPage.title ] = { |
|||
known: suggestionPage.known !== undefined, |
|||
missing: suggestionPage.missing !== undefined, |
|||
redirect: suggestionPage.redirect !== undefined, |
|||
disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined, |
|||
imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ), |
|||
description: suggestionPage.description, |
|||
// Sort index |
|||
index: suggestionPage.index, |
|||
originalData: suggestionPage |
|||
}; |
|||
// Throw away mw.pages from wrong namespaces. This can happen when 'showRedirectTargets' is true |
|||
// and we encounter a cross-namespace redirect. |
|||
if ( this.namespace === null || namespaces.indexOf(suggestionPage.ns) >= 0 ) { |
|||
titles.push( suggestionPage.title ); |
|||
} |
|||
redirects = hasOwn.call( redirectsTo, suggestionPage.title ) ? redirectsTo[ suggestionPage.title ] : []; |
|||
for ( i = 0, len = redirects.length; i < len; i++ ) { |
|||
pageData[ redirects[ i ] ] = { |
|||
missing: false, |
|||
known: true, |
|||
redirect: true, |
|||
disambiguation: false, |
|||
description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title ), |
|||
// Sort index, just below its target |
|||
index: suggestionPage.index + 0.5, |
|||
originalData: suggestionPage |
|||
}; |
|||
titles.push( redirects[ i ] ); |
|||
} |
|||
} |
|||
titles.sort( function ( a, b ) { |
|||
return pageData[ a ].index - pageData[ b ].index; |
|||
} ); |
|||
// If not found, run value through mw.Title to avoid treating a match as a |
|||
// mismatch where normalisation would make them matching (T50476) |
|||
pageExistsExact = ( |
|||
hasOwn.call( pageData, this.getQueryValue() ) && |
|||
( |
|||
!pageData[ this.getQueryValue() ].missing || |
|||
pageData[ this.getQueryValue() ].known |
|||
) |
|||
); |
|||
pageExists = pageExistsExact || ( |
|||
titleObj && |
|||
hasOwn.call( pageData, titleObj.getPrefixedText() ) && |
|||
( |
|||
!pageData[ titleObj.getPrefixedText() ].missing || |
|||
pageData[ titleObj.getPrefixedText() ].known |
|||
) |
|||
); |
|||
if ( this.cache ) { |
|||
this.cache.set( pageData ); |
|||
} |
|||
// Offer the exact text as a suggestion if the page exists |
|||
if ( this.addQueryInput && pageExists && !pageExistsExact ) { |
|||
titles.unshift( this.getQueryValue() ); |
|||
} |
|||
for ( i = 0, len = titles.length; i < len; i++ ) { |
|||
page = hasOwn.call( pageData, titles[ i ] ) ? pageData[ titles[ i ] ] : {}; |
|||
items.push( this.createOptionWidget( this.getOptionWidgetData( titles[ i ], page ) ) ); |
|||
} |
|||
return items; |
|||
}; |
|||
}, |
|||
/** |
|||
* Parse the calculator configuration |
|||
* |
|||
* @param lines {Array} An array containing the calculator's configuration |
|||
* @returns {Object} An object representing the calculator's configuration |
|||
*/ |
|||
parseConfig: function (lines) { |
|||
var defConfig = { |
|||
suggestns: [], |
|||
autosubmit: 'off', |
|||
name: 'Calculator' |
|||
}, |
|||
config = { |
|||
// this isn't in `defConfig` |
|||
// as it'll get overridden anyway |
|||
tParams: [] |
|||
}, |
|||
// used for debugging incorrect config names |
|||
validParams = [ |
|||
'form', |
|||
'param', |
|||
'result', |
|||
'suggestns', |
|||
'template', |
|||
'module', |
|||
'modulefunc', |
|||
'name', |
|||
'autosubmit' |
|||
], |
|||
// used for debugging incorrect param types |
|||
validParamTypes = [ |
|||
'string', |
|||
'article', |
|||
'number', |
|||
'int', |
|||
'select', |
|||
'buttonselect', |
|||
'combobox', |
|||
'check', |
|||
'toggleswitch', |
|||
'togglebutton', |
|||
'hs', |
|||
'rsn', |
|||
'fixed', |
|||
'hidden', |
|||
'semihidden', |
|||
'group' |
|||
], |
|||
configError = false; |
|||
// parse the calculator's config |
|||
// @example param=arg1|arg1|arg3|arg4 |
|||
lines.forEach(function (line) { |
|||
var temp = line.split('='), |
|||
param, |
|||
args; |
|||
// incorrect config |
|||
if (temp.length < 2) { |
|||
return; |
|||
} |
|||
// an equals is used in one of the arguments |
|||
// @example HTML label with attributes |
|||
// so join them back together to preserve it |
|||
// this also allows support of HTML attributes in labels |
|||
if (temp.length > 2) { |
|||
temp[1] = temp.slice(1,temp.length).join('='); |
|||
} |
|||
param = temp[0].trim().toLowerCase(); |
|||
args = temp[1].trim(); |
|||
if (validParams.indexOf(param) === -1) { |
|||
// use console for easier debugging |
|||
console.log('Unknown parameter: ' + param); |
|||
configError = true; |
|||
return; |
|||
} |
|||
if (param === 'suggestns') { |
|||
config.suggestns = args.split(/\s*,\s*/); |
|||
return; |
|||
} |
|||
if (param !== 'param') { |
|||
config[param] = args; |
|||
return; |
|||
} |
|||
// split args |
|||
args = args.split(/\s*\|\s*/); |
|||
// store template params in an array to make life easier |
|||
config.tParams = config.tParams || []; |
|||
if (validParamTypes.indexOf(args[3]) === -1 && args[3] !== '' && args[3] !== undefined) { |
|||
// use console for easier debugging |
|||
console.log('Unknown param type: ' + args[3]); |
|||
configError = true; |
|||
return; |
|||
} |
|||
var inlinehelp = false, help = ''; |
|||
if (args[6]) { |
|||
var tmphelp = args[6].split(/\s*=\s*/); |
|||
if (tmphelp.length > 1) { |
|||
if ( tmphelp[0] === 'inline' ) { |
|||
inlinehelp = true; |
|||
// Html etc can have = so join them back together |
|||
tmphelp[1] = tmphelp.slice(1,tmphelp.length).join('='); |
|||
help = helper.sanitiseLabels(tmphelp[1] || ''); |
|||
} else { |
|||
// Html etc can have = so join them back together |
|||
tmphelp[0] = tmphelp.join('='); |
|||
help = helper.sanitiseLabels(tmphelp[0] || ''); |
|||
} |
|||
} else { |
|||
help = helper.sanitiseLabels(tmphelp[0] || ''); |
|||
} |
|||
} |
|||
config.tParams.push({ |
|||
name: mw.html.escape(args[0]), |
|||
label: helper.sanitiseLabels(args[1] || args[0]), |
|||
def: mw.html.escape(args[2] || ''), |
|||
type: mw.html.escape(args[3] || ''), |
|||
range: args[4] || '', |
|||
rawtogs: args[5] || '', |
|||
inlhelp: inlinehelp, |
|||
help: help |
|||
}); |
|||
}); |
|||
if (configError) { |
|||
config.configError = 'This calculator\'s config contains errors. Please report it ' + |
|||
'<a href="/w/RuneScape:User_help" title="RuneScape:User help">here</a> ' + |
|||
'or check the javascript console for details.'; |
|||
} |
|||
config = $.extend(defConfig, config); |
|||
mw.log(config); |
|||
return config; |
|||
}, |
|||
/** |
|||
* Generate a unique id for each input |
|||
* |
|||
* @param inputId {String} A string representing the id of an input |
|||
* @returns {String} A string representing the namespaced/prefixed id of an input |
|||
*/ |
|||
getId: function (inputId) { |
|||
return [this.form, this.result, inputId].join('-'); |
|||
}, |
|||
/** |
|||
* Output an error to the UI |
|||
* |
|||
* @param error {String} A string representing the error message to be output |
|||
*/ |
|||
showError: function (error) { |
|||
$('#' + this.result) |
|||
.empty() |
|||
.append( |
|||
$('<strong>') |
|||
.addClass('error') |
|||
.text(error) |
|||
); |
|||
}, |
|||
/** |
|||
* Toggle the visibility and enabled status of fields/groups |
|||
* |
|||
* @param item {String} A string representing the current value of the widget |
|||
* @param toggles {object} An object representing arrays of items to be toggled keyed by widget values |
|||
*/ |
|||
toggle: function (item, toggles) { |
|||
var self = this; |
|||
var togitem = function (widget, show) { |
|||
var param = self.tParams[ self.indexkeys[widget] ]; |
|||
if (param.type === 'group') { |
|||
param.ooui.toggle(show); |
|||
param.ooui.getItems().forEach(function (child) { |
|||
if (!!child.setDisabled) { |
|||
child.setDisabled(!show); |
|||
} else if (!!child.getField().setDisabled) { |
|||
child.getField().setDisabled(!show); |
|||
} |
|||
}); |
|||
} else if ( param.type === 'semihidden' ) { |
|||
if (!!param.ooui.setDisabled) { |
|||
param.ooui.setDisabled(!show); |
|||
} |
|||
} else { |
|||
param.layout.toggle(show); |
|||
if (!!param.ooui.setDisabled) { |
|||
param.ooui.setDisabled(!show); |
|||
} |
|||
} |
|||
}; |
|||
if (toggles[item]) { |
|||
toggles[item].on.forEach( function (widget) { |
|||
togitem(widget, true); |
|||
}); |
|||
toggles[item].off.forEach( function (widget) { |
|||
togitem(widget, false); |
|||
}); |
|||
} else if ( toggles.not0 && !isNaN(parseFloat(item)) && parseFloat(item) !== 0 ) { |
|||
toggles.not0.on.forEach( function (widget) { |
|||
togitem(widget, true); |
|||
}); |
|||
toggles.not0.off.forEach( function (widget) { |
|||
togitem(widget, false); |
|||
}); |
|||
} else if (toggles.alltogs) { |
|||
toggles.alltogs.off.forEach( function (widget) { |
|||
togitem(widget, false); |
|||
}); |
|||
} |
|||
}, |
|||
/** |
|||
* Generate range and step for number and int inputs |
|||
* |
|||
* @param rawdata {string} The string representation of the range and steps |
|||
* @param type {string} The name of the field type (int or number) |
|||
* @returns {array} An array containing the min value, max value, step and button step. |
|||
*/ |
|||
genRange: function (rawdata,type) { |
|||
var tmp = rawdata.split(/\s*,\s*/), |
|||
rng = tmp[0].split(/\s*-\s*/), |
|||
step = tmp[1] || '', |
|||
bstep = tmp[2] || '', |
|||
min, max, |
|||
parseFunc; |
|||
if (type==='int') { |
|||
parseFunc = function(x) { return parseInt(x, 10); } |
|||
} else { |
|||
parseFunc = parseFloat; |
|||
} |
|||
if (type === 'int') { |
|||
step = 1; |
|||
if ( isNaN(parseInt(bstep,10)) ) { |
|||
bstep = 1; |
|||
} else { |
|||
bstep = parseInt(bstep,10); |
|||
} |
|||
} else { |
|||
if ( isNaN(parseFloat(step)) ) { |
|||
step = 0.01; |
|||
} else { |
|||
step = parseFloat(step); |
|||
} |
|||
if ( isNaN(parseFloat(bstep)) ) { |
|||
bstep = 1; |
|||
} else { |
|||
bstep = parseFloat(bstep); |
|||
} |
|||
} |
|||
// Accept negative values for either range position |
|||
if ( rng.length === 3 ) { |
|||
// 1 value is negative |
|||
if ( rng[0] === '' ) { |
|||
// First value negative |
|||
if ( isNaN(parseFunc(rng[1])) ) { |
|||
min = -Infinity; |
|||
} else { |
|||
min = 0 - parseFunc(rng[1]); |
|||
} |
|||
if ( isNaN(parseFunc(rng[2])) ) { |
|||
max = Infinity; |
|||
} else { |
|||
max = parseFunc(rng[2]); |
|||
} |
|||
} else if ( rng[1] === '' ) { |
|||
// Second value negative |
|||
if ( isNaN(parseFunc(rng[0])) ) { |
|||
min = -Infinity; |
|||
} else { |
|||
min = parseFunc(rng[0]); |
|||
} |
|||
if ( isNaN(parseFunc(rng[2])) ) { |
|||
max = 0; |
|||
} else { |
|||
max = 0 - parseFunc(rng[2]); |
|||
} |
|||
} |
|||
} else if ( rng.length === 4 ) { |
|||
// Both negative |
|||
if ( isNaN(parseFunc(rng[1])) ) { |
|||
min = -Infinity; |
|||
} else { |
|||
min = 0 - parseFunc(rng[1]); |
|||
} |
|||
if ( isNaN(parseFunc(rng[3])) ) { |
|||
max = 0; |
|||
} else { |
|||
max = 0 - parseFunc(rng[3]); |
|||
} |
|||
} else { |
|||
// No negatives |
|||
if ( isNaN(parseFunc(rng[0])) ) { |
|||
min = 0; |
|||
} else { |
|||
min = parseFunc(rng[0]); |
|||
} |
|||
if ( isNaN(parseFunc(rng[1])) ) { |
|||
max = Infinity; |
|||
} else { |
|||
max = parseFunc(rng[1]); |
|||
} |
|||
} |
|||
// Check min < max |
|||
if ( max < min ) { |
|||
return [ max, min, step, bstep ]; |
|||
} else { |
|||
return [ min, max, step, bstep ]; |
|||
} |
|||
}, |
|||
/** |
|||
* Parse the toggles for an input |
|||
* |
|||
* @param rawdata {string} A string representing the toggles for the widget |
|||
* @param defkey {string} The default key for toggles |
|||
* @returns {object} An object representing the toggles in the format { ['widget value']:[ widget-to-toggle, group-to-toggle, widget-to-toggle2 ] } |
|||
*/ |
|||
parseToggles: function (rawdata,defkey) { |
|||
var tmptogs = rawdata.split(/\s*;\s*/), |
|||
allkeys = [], allvals = [], |
|||
toggles = {}; |
|||
if (tmptogs.length > 0 && tmptogs[0].length > 0) { |
|||
tmptogs.forEach(function (tog) { |
|||
var tmp = tog.split(/\s*=\s*/), |
|||
keys = tmp[0], |
|||
val = []; |
|||
if (tmp.length < 2) { |
|||
keys = [defkey]; |
|||
val = tmp[0].split(/\s*,\s*/); |
|||
} else { |
|||
keys = tmp[0].split(/\s*,\s*/); |
|||
val = tmp[1].split(/\s*,\s*/); |
|||
} |
|||
if (keys.length === 1) { |
|||
var key = keys[0]; |
|||
toggles[key] = {}; |
|||
toggles[key].on = val; |
|||
allkeys.push(key); |
|||
} else { |
|||
keys.forEach( function (key) { |
|||
toggles[key] = {}; |
|||
toggles[key].on = val; |
|||
allkeys.push(key); |
|||
}); |
|||
} |
|||
allvals = allvals.concat(val); |
|||
}); |
|||
allkeys = allkeys.filter(function (item, pos, arr) { |
|||
return arr.indexOf(item) === pos; |
|||
}); |
|||
allkeys.forEach(function (key) { |
|||
toggles[key].off = allvals.filter(function (val) { |
|||
if ( toggles[key].on.includes(val) ) { |
|||
return false; |
|||
} else { |
|||
return true; |
|||
} |
|||
}); |
|||
}); |
|||
// Add all items to default |
|||
toggles.alltogs = {}; |
|||
toggles.alltogs.off = allvals; |
|||
} |
|||
return toggles; |
|||
}, |
|||
/** |
|||
* Form submission handler |
|||
*/ |
|||
submitForm: function () { |
|||
var self = this, |
|||
code = '{{' + self.template, |
|||
formErrors = [], |
|||
apicalls = [], |
|||
paramVals = {}; |
|||
if (self.module !== undefined) { |
|||
if (self.modulefunc === undefined) { |
|||
self.modulefunc = 'main'; |
|||
}//<nowiki> |
|||
code = '{{#invoke:'+self.module+'|'+self.modulefunc; |
|||
}//</nowiki> |
|||
self.submitlayout.setNotices(['Validating fields, please wait.']); |
|||
self.submitlayout.fieldWidget.setDisabled(true); |
|||
// setup template for submission |
|||
self.tParams.forEach(function (param) { |
|||
if ( param.type === 'hidden' || (param.type !== 'group' && param.ooui.isDisabled() === false) ) { |
|||
var val, |
|||
$input, |
|||
// use separate error tracking for each input |
|||
// or every input gets flagged as an error |
|||
error = ''; |
|||
if (param.type === 'fixed' || param.type === 'hidden') { |
|||
val = param.def; |
|||
} else { |
|||
$input = $('#' + helper.getId.call(self, param.name) + ' input'); |
|||
if (param.type === 'buttonselect') { |
|||
val = param.ooui.findSelectedItem(); |
|||
if (val !== null) { |
|||
val = val.getData(); |
|||
} |
|||
} else { |
|||
val = param.ooui.getValue(); |
|||
} |
|||
if (param.type === 'int') { |
|||
val = val.split(',').join(''); |
|||
} else if (param.type === 'check') { |
|||
val = param.ooui.isSelected(); |
|||
if (param.range) { |
|||
val = param.range.split(',')[val ? 0 : 1]; |
|||
} |
|||
} else if (param.type === 'toggleswitch' || param.type === 'togglebutton') { |
|||
if (param.range) { |
|||
val = param.range.split(',')[val ? 0 : 1]; |
|||
} |
|||
} |
|||
// Check input is valid (based on widgets validation) |
|||
if ( !!param.ooui.hasFlag && param.ooui.hasFlag('invalid') && param.type !== 'article') { |
|||
error = param.error; |
|||
} else if ( param.type === 'article' && param.ooui.validateTitle && val.length > 0 ) { |
|||
var api = param.ooui.getApi(), |
|||
prms = { |
|||
action: 'query', |
|||
prop: [], |
|||
titles: [ param.ooui.getValue() ] |
|||
}; |
|||
var prom = new Promise ( function (resolve,reject) { |
|||
api.get(prms).then( function (ret) { |
|||
if ( ret.query.mw.pages && Object.keys(ret.query.mw.pages).length ) { |
|||
var nspaces = param.ooui.namespace.split('|'), allNS = false; |
|||
if (nspaces.indexOf('*') >= 0) { |
|||
allNS = true; |
|||
} |
|||
nspaces = nspaces.map(function (ns) {return parseInt(ns,10);}); |
|||
for (var pgID in ret.query.mw.pages) { |
|||
if ( ret.query.mw.pages.hasOwnProperty(pgID) && ret.query.mw.pages[pgID].missing!== '' ) { |
|||
if ( allNS ) { |
|||
resolve(); |
|||
} |
|||
if ( ret.query.mw.pages[pgID].ns !== undefined && nspaces.indexOf(ret.query.mw.pages[pgID].ns) >= 0 ) { |
|||
resolve(); |
|||
} |
|||
} |
|||
} |
|||
reject(param); |
|||
} else { |
|||
reject(param); |
|||
} |
|||
}); |
|||
}); |
|||
apicalls.push(prom); |
|||
} |
|||
if (error) { |
|||
param.layout.setErrors([error]); |
|||
if (param.ooui.setValidityFlag !== undefined) { |
|||
param.ooui.setValidityFlag(false); |
|||
} |
|||
// TODO: Remove jsInvalid classes? |
|||
$input.addClass('jcInvalid'); |
|||
formErrors.push( param.label[0].textContent + ': ' + error ); |
|||
} else { |
|||
param.layout.setErrors([]); |
|||
if (param.ooui.setValidityFlag !== undefined) { |
|||
param.ooui.setValidityFlag(true); |
|||
} |
|||
// TODO: Remove jsInvalid classes? |
|||
$input.removeClass('jcInvalid'); |
|||
// Save current parameter value |
|||
paramVals[param.name] = val; |
|||
// Save current parameter value for later calculator usage. |
|||
//window.localStorage.setItem(helper.getId.call(self, param.name), val); |
|||
} |
|||
} |
|||
code += '|' + param.name + '=' + val; |
|||
} |
|||
}); |
|||
Promise.all(apicalls).then( function (vals) { |
|||
// All article fields valid |
|||
self.submitlayout.setNotices([]); |
|||
self.submitlayout.fieldWidget.setDisabled(false); |
|||
if (formErrors.length > 0) { |
|||
self.submitlayout.setErrors(formErrors); |
|||
helper.showError.call(self, 'One or more fields contains an invalid value.'); |
|||
return; |
|||
} |
|||
self.submitlayout.setErrors([]); |
|||
// Save all values to localStorage |
|||
if (!rs.hasLocalStorage()) { |
|||
console.warn('Browser does not support localStorage, inputs will not be saved.'); |
|||
} else { |
|||
mw.log('Saving inputs to localStorage'); |
|||
localStorage.setItem( self.localname, JSON.stringify(paramVals) ); |
|||
} |
|||
code += '}}'; |
|||
console.log(code); |
|||
helper.loadTemplate.call(self, code); |
|||
}, function (errparam) { |
|||
// An article field is invalid |
|||
self.submitlayout.setNotices([]); |
|||
self.submitlayout.fieldWidget.setDisabled(false); |
|||
errparam.layout.setErrors([errparam.error]); |
|||
formErrors.push( errparam.label[0].textContent + ': ' + errparam.error ); |
|||
self.submitlayout.setErrors(formErrors); |
|||
helper.showError.call(self, 'One or more fields contains an invalid value.'); |
|||
return; |
|||
}); |
|||
}, |
|||
/** |
|||
* Parse the template used to display the result of the form |
|||
* |
|||
* @param code {string} Wikitext to send to the API for parsing |
|||
*/ |
|||
loadTemplate: function (code) { |
|||
var self = this, |
|||
params = { |
|||
action: 'parse', |
|||
text: code, |
|||
prop: 'text|limitreportdata', |
|||
title: mw.config.get('wgPageName'), |
|||
disablelimitreport: 'true', |
|||
contentmodel: 'wikitext', |
|||
format: 'json' |
|||
}, |
|||
method = 'GET'; |
|||
// experimental support for using VE to parse calc templates |
|||
if (!!mw.mw.util.getParamValue('vecalc')) { |
|||
params = { |
|||
action: 'visualeditor', |
|||
// has to be a mainspace page or VE won't work |
|||
page: 'No page', |
|||
paction: 'parsefragment', |
|||
wikitext: code, |
|||
format: 'json', |
|||
rswcalcautosubmit: self.autosubmit |
|||
}; |
|||
} |
|||
if (code.length > 1900) { |
|||
method = 'POST'; |
|||
} |
|||
$('#' + self.form + ' .jcSubmit') |
|||
.data('oouiButton') |
|||
.setDisabled(true); |
|||
// @todo time how long these calls take |
|||
$.ajax({method:method, url:'/api.php', data:params}) |
|||
.done(function (response) { |
|||
var html; |
|||
if (!!mw.mw.util.getParamValue('vecalc')) { |
|||
// strip body tag |
|||
html = $(response.visualeditor.content).contents(); |
|||
} else { |
|||
html = response.parse.text['*']; |
|||
} |
|||
if (response.parse.limitreportdata) { |
|||
var logs = response.parse.limitreportdata.filter(function(e){return e.name === 'scribunto-limitreport-logs'}); |
|||
if (logs.length>0) { |
|||
var log_str = ['Scribunto logs:']; |
|||
logs.forEach(function(log){ |
|||
var i = 0; |
|||
while (log.hasOwnProperty(''+i)) { |
|||
log_str.push(log[''+i]); |
|||
i++; |
|||
} |
|||
}); |
|||
console.log(log_str.join('\n')); |
|||
} |
|||
} |
|||
helper.dispResult.call(self, html); |
|||
}) |
|||
.fail(function (_, error) { |
|||
$('#' + self.form + ' .jcSubmit') |
|||
.data('oouiButton') |
|||
.setDisabled(false); |
|||
helper.showError.call(self, error); |
|||
}); |
|||
}, |
|||
/** |
|||
* Display the calculator result on the page |
|||
* |
|||
* @param response {String} A string representing the HTML to be added to the page |
|||
*/ |
|||
dispResult: function (html) { |
|||
var self = this; |
|||
$('#' + self.form + ' .jcSubmit') |
|||
.data('oouiButton') |
|||
.setDisabled(false); |
|||
$('#bodyContent') |
|||
.find('#' + this.result) |
|||
.empty() |
|||
.removeClass('jcError') |
|||
.html(html); |
|||
// allow scripts to hook into form submission |
|||
mw.hook('rscalc.submit').fire(); |
|||
// run all standard page-init things so various JS works as expected, including: |
|||
// - sortable tables |
|||
// - collapsible sections |
|||
// - collapsed headers on mobile |
|||
mw.hook('wikipage.content').fire($('#'+this.result)); |
|||
/* |
|||
mw.loader.using('jquery.tablesorter', function () { |
|||
$('table.sortable:not(.jquery-tablesorter)').tablesorter(); |
|||
}); |
|||
mw.loader.using('jquery.makeCollapsible', function () { |
|||
$('.mw-collapsible').makeCollapsible(); |
|||
}); |
|||
*/ |
|||
if ($('.rsw-chartjs-config').length) { |
|||
mw.loader.load('ext.gadget.Charts-core'); |
|||
} |
|||
}, |
|||
/** |
|||
* Sanitise any HTML used in labels |
|||
* |
|||
* @param html {string} A HTML string to be sanitised |
|||
* @returns {jQuery.object} A jQuery object representing the sanitised HTML |
|||
*/ |
|||
sanitiseLabels: function (html) { |
|||
var whitelistAttrs = [ |
|||
// mainly for span/div tags |
|||
'style', |
|||
// for anchor tags |
|||
'href', |
|||
'title', |
|||
// for img tags |
|||
'src', |
|||
'alt', |
|||
'height', |
|||
'width', |
|||
// misc |
|||
'class' |
|||
], |
|||
whitelistTags = [ |
|||
'a', |
|||
'span', |
|||
'div', |
|||
'img', |
|||
'strong', |
|||
'b', |
|||
'em', |
|||
'i', |
|||
'br' |
|||
], |
|||
// parse the HTML string, removing script tags at the same time |
|||
$html = $.parseHTML(html, /* document */ null, /* keepscripts */ false), |
|||
// append to a div so we can navigate the node tree |
|||
$div = $('<div>').append($html); |
|||
$div.find('*').each(function () { |
|||
var $this = $(this), |
|||
tagname = $this.prop('tagName').toLowerCase(), |
|||
attrs, |
|||
array, |
|||
href; |
|||
if (whitelistTags.indexOf(tagname) === -1) { |
|||
mw.log('Disallowed tagname: ' + tagname); |
|||
$this.remove(); |
|||
return; |
|||
} |
|||
attrs = $this.prop('attributes'); |
|||
array = Array.prototype.slice.call(attrs); |
|||
array.forEach(function (attr) { |
|||
if (whitelistAttrs.indexOf(attr.name) === -1) { |
|||
mw.log('Disallowed attribute: ' + attr.name + ', tagname: ' + tagname); |
|||
$this.removeAttr(attr.name); |
|||
return; |
|||
} |
|||
// make sure there's nasty in nothing in href attributes |
|||
if (attr.name === 'href') { |
|||
href = $this.attr('href'); |
|||
if ( |
|||
// disable warnings about script URLs |
|||
// jshint -W107 |
|||
href.indexOf('javascript:') > -1 || |
|||
// the mw sanitizer doesn't like these |
|||
// so lets follow suit |
|||
// apparently it's something microsoft dreamed up |
|||
href.indexOf('vbscript:') > -1 |
|||
// jshint +W107 |
|||
) { |
|||
mw.log('Script URL detected in ' + tagname); |
|||
$this.removeAttr('href'); |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
return $div.contents(); |
|||
}, |
|||
/** |
|||
* Handlers for parameter input types |
|||
*/ |
|||
tParams: { |
|||
/** |
|||
* Handler for 'fixed' inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
fixed: function (param) { |
|||
var layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-fixed'], |
|||
value: param.def |
|||
}; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
param.ooui = new OO.ui.LabelWidget({ label: param.def }); |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for select dropdowns |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
select: function (param, id) { |
|||
var self = this, |
|||
conf = { |
|||
label: 'Select an option', |
|||
options: [], |
|||
name: id, |
|||
id: id, |
|||
value: param.def, |
|||
dropdown: { |
|||
$overlay: true |
|||
} |
|||
}, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-select'] |
|||
}, |
|||
opts = param.range.split(/\s*,\s*/), |
|||
def = opts[0]; |
|||
param.error = 'Not a valid selection'; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
opts.forEach(function (opt, i) { |
|||
var op = { data: opt, label: opt }; |
|||
if (opt === param.def) { |
|||
op.selected = true; |
|||
def = opt; |
|||
} |
|||
conf.options.push(op); |
|||
}); |
|||
param.toggles = helper.parseToggles(param.rawtogs, def); |
|||
param.ooui = new OO.ui.DropdownInputWidget(conf); |
|||
if ( Object.keys(param.toggles).length > 0 ) { |
|||
param.ooui.on('change', function (value) { |
|||
helper.toggle.call(self, value, param.toggles); |
|||
}); |
|||
} |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for button selects |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
buttonselect: function (param, id) { |
|||
var self = this, |
|||
buttons = {}, |
|||
conf = { |
|||
label:'Select an option', |
|||
items: [], |
|||
id: id |
|||
}, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-buttonselect'] |
|||
}, |
|||
opts = param.range.split(/\s*,\s*/), |
|||
def; |
|||
param.error = 'Please select a valid option'; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
opts.forEach(function (opt, i) { |
|||
var opid = opt.replace(/[^a-zA-Z0-9]/g, ''); |
|||
var $opt = helper.sanitiseLabels(opt); |
|||
var txt = $opt.text().trim(); |
|||
if (txt === '') { |
|||
txt = (i+1).toString(); |
|||
} |
|||
buttons[opid] = new OO.ui.ButtonOptionWidget({data:txt, label: new OO.ui.HtmlSnippet($opt), title:txt}); |
|||
conf.items.push(buttons[opid]); |
|||
}); |
|||
if (param.def.length > 0 && opts.indexOf(param.def) > -1) { |
|||
def = param.def; |
|||
} else { |
|||
def = opts[0]; |
|||
} |
|||
param.toggles = helper.parseToggles(param.rawtogs, def); |
|||
param.ooui = new OO.ui.ButtonSelectWidget(conf); |
|||
param.ooui.selectItemByData(def); |
|||
if ( Object.keys(param.toggles).length > 0 ) { |
|||
param.ooui.on('choose', function (button) { |
|||
var item = button.getData(); |
|||
helper.toggle.call(self, item, param.toggles); |
|||
}); |
|||
} |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for comboboxes |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
combobox: function (param, id) { |
|||
var self = this, |
|||
conf = { |
|||
placeholder: 'Enter filter name', |
|||
options: [], |
|||
name: id, |
|||
id: id, |
|||
menu: { filterFromInput: true }, |
|||
value: param.def, |
|||
$overlay: true |
|||
}, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-combobox'] |
|||
}, |
|||
opts = param.range.split(/\s*,\s*/), |
|||
def = opts[0]; |
|||
param.error = 'Not a valid selection'; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
opts.forEach(function (opt) { |
|||
var op = { data: opt, label: opt }; |
|||
if (opt === param.def) { |
|||
op.selected = true; |
|||
def = opt; |
|||
} |
|||
conf.options.push(op); |
|||
}); |
|||
var isvalid = function (val) {return opts.indexOf(val) < 0 ? false : true;}; |
|||
conf.validate = isvalid; |
|||
param.toggles = helper.parseToggles(param.rawtogs, def); |
|||
param.ooui = new OO.ui.ComboBoxInputWidget(conf); |
|||
if ( Object.keys(param.toggles).length > 0 ) { |
|||
param.ooui.on('change', function (value) { |
|||
helper.toggle.call(self, value, param.toggles); |
|||
}); |
|||
} |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for checkbox inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
check: function (param, id) { |
|||
var self = this, |
|||
conf = { |
|||
name: id, |
|||
id: id |
|||
}, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-check'] |
|||
}; |
|||
param.toggles = helper.parseToggles(param.rawtogs, 'true'); |
|||
param.error = 'Unknown error'; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
if ( (param.def === 'true' || param.def === true) || |
|||
(param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { |
|||
conf.selected = true; |
|||
} |
|||
param.ooui = new OO.ui.CheckboxInputWidget(conf); |
|||
if ( Object.keys(param.toggles).length > 0 ) { |
|||
param.ooui.on('change', function (selected) { |
|||
if (selected) { |
|||
helper.toggle.call(self, 'true', param.toggles); |
|||
} else { |
|||
helper.toggle.call(self, 'false', param.toggles); |
|||
} |
|||
}); |
|||
} |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for toggle switch inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
toggleswitch: function (param, id) { |
|||
var self = this, |
|||
conf = { id: id }, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-toggleswitch'] |
|||
}; |
|||
param.toggles = helper.parseToggles(param.rawtogs, 'true'); |
|||
param.error = 'Unknown error'; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
if ( (param.def === 'true' || param.def === true) || |
|||
(param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { |
|||
conf.value = true; |
|||
} |
|||
param.ooui = new OO.ui.ToggleSwitchWidget(conf); |
|||
if ( Object.keys(param.toggles).length > 0 ) { |
|||
param.ooui.on('change', function (selected) { |
|||
if (selected) { |
|||
helper.toggle.call(self, 'true', param.toggles); |
|||
} else { |
|||
helper.toggle.call(self, 'false', param.toggles); |
|||
} |
|||
}); |
|||
} |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for toggle button inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
togglebutton: function (param, id) { |
|||
var self = this, |
|||
conf = { |
|||
id: id, |
|||
label: new OO.ui.HtmlSnippet(param.label) |
|||
}, |
|||
layconf = { |
|||
label:'', |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-togglebutton'] |
|||
}; |
|||
param.toggles = helper.parseToggles(param.rawtogs, 'true'); |
|||
param.error = 'Unknown error'; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
if ( (param.def === 'true' || param.def === true) || |
|||
(param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { |
|||
conf.value = true; |
|||
} |
|||
param.ooui = new OO.ui.ToggleButtonWidget(conf); |
|||
if ( Object.keys(param.toggles).length > 0 ) { |
|||
param.ooui.on('change', function (selected) { |
|||
if (selected) { |
|||
helper.toggle.call(self, 'true', param.toggles); |
|||
} else { |
|||
helper.toggle.call(self, 'false', param.toggles); |
|||
} |
|||
}); |
|||
} |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for hiscore inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
hs: function (param, id) { |
|||
var self = this, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align:'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-hs'] |
|||
}, |
|||
lookups = {}, |
|||
range = param.range.split(';'), |
|||
input1 = new OO.ui.TextInputWidget({type: 'text', id: id, name: id, value:param.def}), |
|||
button1 = new OO.ui.ButtonInputWidget({ label: 'Lookup', id: id+'-button', name: id+'-button', classes: ['jsCalc-field-hs-lookup'], data: {param: param.name} }); |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
var layout = new OO.ui.ActionFieldLayout(input1, button1, layconf); |
|||
var lookupHS = function(event) { |
|||
var $t = $(event.target), |
|||
lookup = self.lookups[button1.getData().param], |
|||
// replace spaces with _ for the query |
|||
name = $('#' + lookup.id + ' input') |
|||
.val() |
|||
// @todo will this break for players with multiple spaces |
|||
// in their name? e.g. suomi's old display name |
|||
.replace(/\s+/g, '_'), |
|||
button = lookup.button; |
|||
button.setDisabled(true); |
|||
$.ajax({ |
|||
url: '/cors/m=hiscore_oldschool/index_lite.ws?player=' + name, |
|||
dataType: 'text', |
|||
async: true, |
|||
timeout: 10000 // msec |
|||
}).done(function (data) { |
|||
var hsdata; |
|||
hsdata = data.trim() |
|||
.split(/\n+/g); |
|||
lookup.params.forEach(function (param) { |
|||
var id = helper.getId.call(self, param.param), |
|||
$input = $('#' + id + ' input'), |
|||
tParam = null, |
|||
val; |
|||
self.tParams.forEach(function(p) { |
|||
if (p.name === param.param) { |
|||
tParam = p; |
|||
} |
|||
}); |
|||
if (tParam === null) { |
|||
return; |
|||
} |
|||
if (isNaN(param.skill)) { |
|||
val = param.skill; |
|||
//tParam.ooui.setValue(param.skill); |
|||
} else { |
|||
val = hsdata[param.skill].split(',')[param.val]; |
|||
//tParam.ooui.setValue(hsdata[param.skill].split(',')[param.val]); |
|||
} |
|||
if (!!tParam.ooui.setValue) { |
|||
tParam.ooui.setValue(val); |
|||
} else if (!!tParam.ooui.selectItemByData) { |
|||
tParam.ooui.selectItemByData(val); |
|||
} else if (tParam.type === 'fixed') { |
|||
tParam.ooui.setLabel(val); |
|||
} |
|||
}); |
|||
// store in localStorage for future use |
|||
if (rs.hasLocalStorage()) { |
|||
self.lsRSN = name; |
|||
localStorage.setItem('rsn', name); |
|||
} |
|||
button.setDisabled(false); |
|||
layout.setErrors([]); |
|||
}) |
|||
.fail(function (xhr, status) { |
|||
button.setDisabled(false); |
|||
var err = 'The player "' + name + '" does not exist, is banned or unranked, or we couldn\'t fetch your hiscores. Please enter the data manually.'; |
|||
console.warn(status); |
|||
layout.setErrors([err]); |
|||
helper.showError.call(self, err); |
|||
}); |
|||
}; |
|||
button1.$element.click(lookupHS); |
|||
input1.$element.keydown(function(event){ |
|||
if (event.which === 13) { |
|||
lookupHS(event); |
|||
event.preventDefault(); |
|||
} |
|||
}); |
|||
// Use rsn loaded from localstorage |
|||
if (self.lsRSN) { |
|||
input1.setValue(self.lsRSN); |
|||
} |
|||
lookups[param.name] = { |
|||
id: id, |
|||
button: button1, |
|||
params: [] |
|||
}; |
|||
range.forEach(function (el) { |
|||
// to catch empty strings |
|||
if (!el) { |
|||
return; |
|||
} |
|||
var spl = el.split(','); |
|||
lookups[param.name].params.push({ |
|||
param: spl[0], |
|||
skill: spl[1], |
|||
val: spl[2] |
|||
}); |
|||
}); |
|||
// merge lookups into one object |
|||
if (!self.lookups) { |
|||
self.lookups = lookups; |
|||
} else { |
|||
self.lookups = $.extend(self.lookups, lookups); |
|||
} |
|||
param.ooui = input1; |
|||
param.oouiButton = button1; |
|||
return layout; |
|||
}, |
|||
/** |
|||
* Handler for Runescape name inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
rsn: function (param, id) { |
|||
var self = this, |
|||
conf = { |
|||
type: 'text', |
|||
name: id, |
|||
id: id, |
|||
placeholder: 'Enter runescape name', |
|||
spellcheck: false, |
|||
maxLength: 12, |
|||
value: param.def |
|||
}, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-string'] |
|||
}; |
|||
param.error = 'Invalid runescape name: RS names must be 1-12 characters long, can only contain letters, numbers, spaces, dashes and underscores. Names containing Mod are also not allowed.'; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
// Use rsn loaded from localstorage, if available |
|||
if (self.lsRSN) { |
|||
conf.value = self.lsRSN; |
|||
} |
|||
var validrsn = function (name) { |
|||
if ( name.search( /[^0-9a-zA-Z\-_\s]/ ) >= 0 ) { |
|||
return false; |
|||
} else { |
|||
if ( name.toLowerCase().search( /(^mod\s|\smod\s|\smod$)/ ) >= 0 ) { |
|||
return false; |
|||
} else { |
|||
return true; |
|||
} |
|||
} |
|||
}; |
|||
conf.validate = validrsn; |
|||
param.ooui = new OO.ui.TextInputWidget(conf); |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for integer inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
int: function (param, id) { |
|||
var self = this, |
|||
rng = helper.genRange(param.range, 'int'), |
|||
conf = { |
|||
min:rng[0], |
|||
max:rng[1], |
|||
step:rng[2], |
|||
showButtons:true, |
|||
buttonStep:rng[3], |
|||
allowInteger:true, |
|||
name: id, |
|||
id: id, |
|||
value: param.def || 0 |
|||
}, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-int'] |
|||
}, |
|||
error = 'Invalid integer. Must be between ' + rng[0] + ' and ' + rng[1]; |
|||
param.toggles = helper.parseToggles(param.rawtogs, 'not0'); |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
if ( rng[2] > 1 ) { |
|||
error += ' and a muiltiple of ' + rng[2]; |
|||
} |
|||
param.error = error; |
|||
param.ooui = new OO.ui.NumberInputWidget(conf); |
|||
if ( Object.keys(param.toggles).length > 0 ) { |
|||
param.ooui.on('change', function (value) { |
|||
helper.toggle.call(self, value, param.toggles); |
|||
}); |
|||
} |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for number inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
number: function (param, id) { |
|||
var self = this, |
|||
rng = helper.genRange(param.range, 'number'), |
|||
conf = { |
|||
min:rng[0], |
|||
max:rng[1], |
|||
step:rng[2], |
|||
showButtons:true, |
|||
buttonStep:rng[3], |
|||
name:id, |
|||
id:id, |
|||
value:param.def || 0 |
|||
}, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-number'], |
|||
}; |
|||
param.toggles = helper.parseToggles(param.rawtogs, 'not0'); |
|||
param.error = 'Invalid interger. Must be between ' + rng[0] + ' and ' + rng[1] + ' and a multiple of ' + rng[2]; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
param.ooui = new OO.ui.NumberInputWidget(conf); |
|||
if ( Object.keys(param.toggles).length > 0 ) { |
|||
param.ooui.on('change', function (value) { |
|||
helper.toggle.call(self, value, param.toggles); |
|||
}); |
|||
} |
|||
return new OO.ui.FieldLayout( param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for article inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
article: function (param, id) { |
|||
var self = this, |
|||
conf = { |
|||
addQueryInput: false, |
|||
excludeCurrentPage: true, |
|||
showMissing: false, |
|||
showDescriptions: true, |
|||
validateTitle: true, |
|||
relative: false, |
|||
id: id, |
|||
name: id, |
|||
placeholder: 'Enter page name', |
|||
value: param.def |
|||
}, |
|||
layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align:'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-article'] |
|||
}, |
|||
validNSnumbers = { '_*':'All', '_-2':'Media', '_-1':'Special', _0:'(Main)', _1:'Talk', _2:'User', _3:'User talk', _4:'RuneScape', _5:'RuneScape talk', _6:'File', _7:'File talk', _8:'MediaWiki', _9:'MediaWiki talk', _10:'Template', _11:'Template talk', |
|||
_12:'Help', _13:'Help talk', _14:'Category', _15:'Category talk', _100:'Update', _101:'Update talk', _110:'Forum', _111:'Forum talk', _112:'Exchange', _113:'Exchange talk', _114:'Charm', _115:'Charm talk', _116:'Calculator', _117:'Calculator talk', _118:'Map', _119:'Map talk', _828:'Module', _829:'Module talk' }, |
|||
validNSnames = { all:'*', media:-2, special:-1, main:0, '(main)':0, talk:1, user:2, 'user talk':3, runescape:4, 'runescape talk':5, file:6, 'file talk':7, mediawiki:8, 'mediawiki talk':9, template:10, 'template talk':11, |
|||
help:12, 'help talk':13, category:14, 'category talk':15, update:100, 'update talk':101, forum:110, 'forum talk':111, exchange:112, 'exchange talk':113, charm:114, 'charm talk':115, calculator:116, 'calculator talk':117, map:118, 'map talk':119, module:828, 'module talk':829 }, |
|||
namespaces = ''; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
if (param.range && param.range.length > 0) { |
|||
var names = param.range.split(/\s*,\s*/), |
|||
nsnumbers = []; |
|||
names.forEach( function (nmspace) { |
|||
nmspace = nmspace.toLowerCase(); |
|||
if ( validNSnumbers['_'+nmspace] ) { |
|||
nsnumbers.push(nmspace); |
|||
} else if ( validNSnames[nmspace] ) { |
|||
nsnumbers.push( validNSnames[nmspace] ); |
|||
} |
|||
}); |
|||
if (nsnumbers.length < 1) { |
|||
conf.namespace = '0'; |
|||
namespaces = '(Main) namespace'; |
|||
} else if (nsnumbers.length < 2) { |
|||
conf.namespace = nsnumbers[0]; |
|||
namespaces = nsnumbers[0] + ' namespace'; |
|||
} else { |
|||
conf.namespace = nsnumbers.join('|'); |
|||
var nsmap = function (num) { |
|||
return validNSnumbers['_'+num]; |
|||
}; |
|||
namespaces = nsnumbers.slice(0, -1).map(nsmap).join(', ') + ' or ' + nsnumbers.slice(-1).map(nsmap)[0] + ' namespaces'; |
|||
} |
|||
} else if ( self.suggestns && self.suggestns.length > 0 ) { |
|||
var nsnumbers = []; |
|||
self.suggestns.forEach( function (nmspace) { |
|||
nmspace = nmspace.toLowerCase(); |
|||
if ( validNSnumbers['_'+nmspace] ) { |
|||
nsnumbers.push(nmspace); |
|||
} else if ( validNSnames[nmspace] ) { |
|||
nsnumbers.push( validNSnames[nmspace] ); |
|||
} |
|||
}); |
|||
if (nsnumbers.length < 1) { |
|||
conf.namespace = '0'; |
|||
namespaces = '(Main) namespace'; |
|||
} else if (nsnumbers.length < 2) { |
|||
conf.namespace = nsnumbers[0]; |
|||
namespaces = nsnumbers[0] + ' namespace'; |
|||
} else { |
|||
conf.namespace = nsnumbers.join('|'); |
|||
var nsmap = function (num) { |
|||
return validNSnumbers['_'+num]; |
|||
}; |
|||
namespaces = nsnumbers.slice(0, -1).map(nsmap).join(', ') + ' or ' + nsnumbers.slice(-1).map(nsmap)[0] + ' namespaces'; |
|||
} |
|||
} else { |
|||
conf.namespace = '0'; |
|||
namespaces = '(Main) namespace'; |
|||
} |
|||
param.error = 'Invalid page or page is not in ' + namespaces; |
|||
param.ooui = new mw.widgets.TitleInputWidget(conf); |
|||
return new OO.ui.FieldLayout( param.ooui, layconf); |
|||
}, |
|||
/** |
|||
* Handler for group type params |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
group: function (param, id) { |
|||
param.ooui = new OO.ui.HorizontalLayout({id: id, classes: ['jsCalc-group']}); |
|||
if (param.label !== param.name) { |
|||
var label = new OO.ui.LabelWidget({ label: new OO.ui.HtmlSnippet(param.label), classes:['jsCalc-grouplabel'] }); |
|||
param.ooui.addItems([label]); |
|||
} |
|||
return param.ooui; |
|||
}, |
|||
/** |
|||
* Default handler for inputs |
|||
* |
|||
* @param param {object} An object containing the configuration of a parameter |
|||
* @param id {String} A string representing the id to be added to the input |
|||
* @returns {OOUI.object} A OOUI object containing the new FieldLayout |
|||
*/ |
|||
def: function (param, id) { |
|||
var layconf = { |
|||
label: new OO.ui.HtmlSnippet(param.label), |
|||
align: 'right', |
|||
classes: ['jsCalc-field', 'jsCalc-field-string'], |
|||
value: param.def |
|||
}; |
|||
param.error = 'Unknown error'; |
|||
if (param.help) { |
|||
layconf.helpInline = param.inlhelp; |
|||
layconf.help = new OO.ui.HtmlSnippet(param.help); |
|||
} |
|||
param.ooui = new OO.ui.TextInputWidget({type: 'text', name: id, id: id}); |
|||
return new OO.ui.FieldLayout(param.ooui, layconf); |
|||
} |
|||
} |
|||
}; |
|||
/** |
|||
* Create an instance of `Calc` |
|||
* and parse the config stored in `elem` |
|||
* |
|||
* @param elem {Element} An Element representing the HTML tag that contains |
|||
* the calculator's configuration |
|||
*/ |
|||
function Calc(elem) { |
|||
var self = this, |
|||
$elem = $(elem), |
|||
lines, |
|||
config; |
|||
// support div tags for config as well as pre |
|||
// be aware using div tags relies on wikitext for parsing |
|||
// so you can't use anchor or img tags |
|||
// use the wikitext equivalent instead |
|||
if ($elem.children().length) { |
|||
$elem = $elem.children(); |
|||
lines = $elem.html(); |
|||
} else { |
|||
// .html() causes html characters to be escaped for some reason |
|||
// so use .text() instead for <pre> tags |
|||
lines = $elem.text(); |
|||
} |
|||
lines = lines.split('\n'); |
|||
config = helper.parseConfig.call(this, lines); |
|||
// Calc name for localstorage, keyed to calc id |
|||
this.localname = calcstorage + '-' + config.form; |
|||
// Load previous parameter values. |
|||
if (!rs.hasLocalStorage()) { |
|||
console.warn('Browser does not support localStorage'); |
|||
} else { |
|||
mw.log('Loading previous calculator values'); |
|||
var calcdata = JSON.parse( localStorage.getItem(this.localname) ) || false; |
|||
if (calcdata) { |
|||
config.tParams.forEach( function(param) { |
|||
if (calcdata[param.name] !== undefined && calcdata[param.name] !== null) { |
|||
param.def = calcdata[param.name]; |
|||
} |
|||
}); |
|||
} |
|||
self.lsRSN = localStorage.getItem('rsn'); |
|||
mw.log(config); |
|||
} |
|||
// merge config in |
|||
$.extend(this, config); |
|||
/** |
|||
* @todo document |
|||
*/ |
|||
this.getInput = function (id) { |
|||
if (id) { |
|||
id = helper.getId.call(self, id); |
|||
return $('#' + id); |
|||
} |
|||
return $('#jsForm-' + self.form).find('select, input'); |
|||
}; |
|||
} |
|||
/** |
|||
* Helper function for getting the id of an input |
|||
* |
|||
* @param id {string} The id of the input as specified by the calculator config. |
|||
* @returns {string} The true id of the input with prefixes. |
|||
*/ |
|||
Calc.prototype.getId = function (id) { |
|||
var self = this, |
|||
inputId = helper.getId.call(self, id); |
|||
return inputId; |
|||
}; |
|||
/** |
|||
* Build the calculator form |
|||
*/ |
|||
Calc.prototype.setupCalc = function () { |
|||
var self = this, |
|||
fieldset = new OO.ui.FieldsetLayout({label: self.name, classes: ['jcTable'], id: 'jsForm-'+self.form}), |
|||
submitButton, submitButtonAction, paramChangeAction, timeout, |
|||
groupkeys = {}; |
|||
// Used to store indexes of elements to toggle them later |
|||
self.indexkeys = {}; |
|||
self.tParams.forEach(function (param, index) { |
|||
// can skip any output here as the result is pulled from the |
|||
// param default in the config on submission |
|||
if (param.type === 'hidden') { |
|||
return; |
|||
} |
|||
var id = helper.getId.call(self, param.name), |
|||
method = helper.tParams[param.type] ? |
|||
param.type : |
|||
'def'; |
|||
// Generate list of items in group |
|||
if (param.type === 'group') { |
|||
var fields = param.range.split(/\s*,\s*/); |
|||
fields.forEach( function (field) { |
|||
groupkeys[field] = index; |
|||
}); |
|||
} |
|||
param.layout = helper.tParams[method].call(self, param, id); |
|||
if (param.type === 'semihidden') { |
|||
param.layout.toggle(false); |
|||
} |
|||
// Add to group or form |
|||
if ( groupkeys[param.name] || groupkeys[param.name] === 0 ) { |
|||
self.tParams[ groupkeys[param.name] ].ooui.addItems([param.layout]); |
|||
} else { |
|||
fieldset.addItems([param.layout]); |
|||
} |
|||
// Add item to indexkeys |
|||
self.indexkeys[param.name] = index; |
|||
}); |
|||
// Run toggle for each field, check validity |
|||
self.tParams.forEach( function (param) { |
|||
if (param.toggles && Object.keys(param.toggles).length > 0) { |
|||
var val; |
|||
if (param.type === 'buttonselect') { |
|||
val = param.ooui.findSelectedItem().getData(); |
|||
} else if (param.type === 'check') { |
|||
val = param.ooui.isSelected() ? 'true' : 'false'; |
|||
} else if (param.type === 'toggleswitch' || param.type === 'togglebutton') { |
|||
val = param.ooui.getValue() ? 'true' : 'false'; |
|||
} else { |
|||
val = param.ooui.getValue(); |
|||
} |
|||
helper.toggle.call(self, val, param.toggles); |
|||
} |
|||
if (param.type === 'number' || param.type === 'int' || param.type === 'rsn') { |
|||
param.ooui.setValidityFlag(); |
|||
} |
|||
}); |
|||
submitButton = new OO.ui.ButtonInputWidget({ label: 'Submit', flags: ['primary', 'progressive'], classes: ['jcSubmit']}); |
|||
submitButtonAction = function (){ |
|||
helper.submitForm.call(self); |
|||
}; |
|||
submitButton.on('click', submitButtonAction); |
|||
submitButton.$element.data('oouiButton', submitButton); |
|||
self.submitlayout = new OO.ui.FieldLayout(submitButton, {label: ' ', align: 'right', classes: ['jsCalc-field', 'jsCalc-field-submit']}); |
|||
fieldset.addItems([ self.submitlayout ]); |
|||
// Auto-submit |
|||
if (['off', 'false', 'disabled'].indexOf(self.autosubmit) === -1) { |
|||
// Add event |
|||
paramChangeAction = function (widget) { |
|||
if ( typeof widget.getFlags === 'undefined' || !widget.getFlags().includes('invalid')) { |
|||
helper.submitForm.call(self); |
|||
} |
|||
}; |
|||
// We only want one of these pending at once |
|||
function timeoutFunc(param) { |
|||
clearTimeout(timeout); |
|||
timeout = setTimeout(paramChangeAction, 500, param); |
|||
} |
|||
self.tParams.forEach( function (param) { |
|||
if (param.type === 'hidden' || param.type === 'hs' || param.type === 'group') { |
|||
return; |
|||
} else if (param.type === 'buttonselect') { |
|||
param.ooui.on('select', timeoutFunc, [param.ooui]); |
|||
} |
|||
param.ooui.on('change', timeoutFunc, [param.ooui]); |
|||
}); |
|||
} |
|||
if (self.configError) { |
|||
fieldset.$element.append('<br>', self.configError); |
|||
} |
|||
$('#bodyContent') |
|||
.find('#' + self.form) |
|||
.empty() |
|||
.append(fieldset.$element); |
|||
// make buttonselects all the same height |
|||
self.tParams.filter(e=>e.type==='buttonselect').forEach(e=>{ |
|||
let m = e.ooui.items.reduce((acc,curr)=>Math.max(acc, curr.$element.find('> a.oo-ui-buttonElement-button').height()), 0); |
|||
e.ooui.items.forEach(e=>e.$element.find('> a.oo-ui-buttonElement-button').height(m)); |
|||
}) |
|||
}; |
|||
/** |
|||
* @todo |
|||
*/ |
|||
function lookupCalc(calcId) { |
|||
return calcStore[calcId]; |
|||
} |
|||
/** |
|||
* @todo |
|||
*/ |
|||
function init() { |
|||
// Initialises class changes |
|||
helper.initClasses(); |
|||
$('.jcConfig').each(function () { |
|||
var c = new Calc(this); |
|||
c.setupCalc(); |
|||
calcStore[c.form] = c; |
|||
// if (c.autosubmit === 'true' || c.autosubmit === true) { |
|||
// helper.submitForm.call(c); |
|||
// } |
|||
}); |
|||
// allow scripts to hook into calc setup completion |
|||
mw.hook('rscalc.setupComplete').fire(); |
|||
} |
|||
$(init); |
|||
rs.calc = {}; |
|||
rs.calc.lookup = lookupCalc; |
Revision as of 17:12, 17 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 () { var hasOwn = Object.prototype.hasOwnProperty; /** * Get option widgets from the server response * Changed to add support for multiple namespaces * * @param {Object} data Query result * @return {OO.ui.OptionWidget[]} Menu items */ mw.widgets.TitleInputWidget.prototype.getOptionsFromData = function (data) { var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects, currentPageName = new mw.Title( mw.config.get( 'wgRelevantPageName' ) ).getPrefixedText(), items = [], titles = [], titleObj = mw.Title.newFromText( this.getQueryValue() ), redirectsTo = {}, pageData = {}, namespaces = this.namespace.split('|').map(function (val) {return parseInt(val,10);}); if ( data.redirects ) { for ( i = 0, len = data.redirects.length; i < len; i++ ) { redirect = data.redirects[ i ]; redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || []; redirectsTo[ redirect.to ].push( redirect.from ); } } for ( index in data.pages ) { suggestionPage = data.pages[ index ]; // When excludeCurrentPage is set, don't list the current page unless the user has type the full title if ( this.excludeCurrentPage && suggestionPage.title === currentPageName && suggestionPage.title !== titleObj.getPrefixedText() ) { continue; } // When excludeDynamicNamespaces is set, ignore all pages with negative namespace if ( this.excludeDynamicNamespaces && suggestionPage.ns < 0 ) { continue; } pageData[ suggestionPage.title ] = { known: suggestionPage.known !== undefined, missing: suggestionPage.missing !== undefined, redirect: suggestionPage.redirect !== undefined, disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined, imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ), description: suggestionPage.description, // Sort index index: suggestionPage.index, originalData: suggestionPage }; // Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true // and we encounter a cross-namespace redirect. if ( this.namespace === null || namespaces.indexOf(suggestionPage.ns) >= 0 ) { titles.push( suggestionPage.title ); } redirects = hasOwn.call( redirectsTo, suggestionPage.title ) ? redirectsTo[ suggestionPage.title ] : []; for ( i = 0, len = redirects.length; i < len; i++ ) { pageData[ redirects[ i ] ] = { missing: false, known: true, redirect: true, disambiguation: false, description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title ), // Sort index, just below its target index: suggestionPage.index + 0.5, originalData: suggestionPage }; titles.push( redirects[ i ] ); } } titles.sort( function ( a, b ) { return pageData[ a ].index - pageData[ b ].index; } ); // If not found, run value through mw.Title to avoid treating a match as a // mismatch where normalisation would make them matching (T50476) pageExistsExact = ( hasOwn.call( pageData, this.getQueryValue() ) && ( !pageData[ this.getQueryValue() ].missing || pageData[ this.getQueryValue() ].known ) ); pageExists = pageExistsExact || ( titleObj && hasOwn.call( pageData, titleObj.getPrefixedText() ) && ( !pageData[ titleObj.getPrefixedText() ].missing || pageData[ titleObj.getPrefixedText() ].known ) ); if ( this.cache ) { this.cache.set( pageData ); } // Offer the exact text as a suggestion if the page exists if ( this.addQueryInput && pageExists && !pageExistsExact ) { titles.unshift( this.getQueryValue() ); } for ( i = 0, len = titles.length; i < len; i++ ) { page = hasOwn.call( pageData, titles[ i ] ) ? pageData[ titles[ i ] ] : {}; items.push( this.createOptionWidget( this.getOptionWidgetData( titles[ i ], page ) ) ); } return items; }; }, /** * Parse the calculator configuration * * @param lines {Array} An array containing the calculator's configuration * @returns {Object} An object representing the calculator's configuration */ parseConfig: function (lines) { var defConfig = { suggestns: [], autosubmit: 'off', name: 'Calculator' }, config = { // this isn't in `defConfig` // as it'll get overridden anyway tParams: [] }, // used for debugging incorrect config names validParams = [ 'form', 'param', 'result', 'suggestns', 'template', 'module', 'modulefunc', 'name', 'autosubmit' ], // used for debugging incorrect param types validParamTypes = [ 'string', 'article', 'number', 'int', 'select', 'buttonselect', 'combobox', 'check', 'toggleswitch', 'togglebutton', 'hs', 'rsn', 'fixed', 'hidden', 'semihidden', 'group' ], configError = false; // parse the calculator's config // @example param=arg1|arg1|arg3|arg4 lines.forEach(function (line) { var temp = line.split('='), param, args; // incorrect config if (temp.length < 2) { return; } // an equals is used in one of the arguments // @example HTML label with attributes // so join them back together to preserve it // this also allows support of HTML attributes in labels if (temp.length > 2) { temp[1] = temp.slice(1,temp.length).join('='); } param = temp[0].trim().toLowerCase(); args = temp[1].trim(); if (validParams.indexOf(param) === -1) { // use console for easier debugging console.log('Unknown parameter: ' + param); configError = true; return; } if (param === 'suggestns') { config.suggestns = args.split(/\s*,\s*/); return; } if (param !== 'param') { config[param] = args; return; } // split args args = args.split(/\s*\|\s*/); // store template params in an array to make life easier config.tParams = config.tParams || []; if (validParamTypes.indexOf(args[3]) === -1 && args[3] !== '' && args[3] !== undefined) { // use console for easier debugging console.log('Unknown param type: ' + args[3]); configError = true; return; } var inlinehelp = false, help = ''; if (args[6]) { var tmphelp = args[6].split(/\s*=\s*/); if (tmphelp.length > 1) { if ( tmphelp[0] === 'inline' ) { inlinehelp = true; // Html etc can have = so join them back together tmphelp[1] = tmphelp.slice(1,tmphelp.length).join('='); help = helper.sanitiseLabels(tmphelp[1] || ''); } else { // Html etc can have = so join them back together tmphelp[0] = tmphelp.join('='); help = helper.sanitiseLabels(tmphelp[0] || ''); } } else { help = helper.sanitiseLabels(tmphelp[0] || ''); } } config.tParams.push({ name: mw.html.escape(args[0]), label: helper.sanitiseLabels(args[1] || args[0]), def: mw.html.escape(args[2] || ''), type: mw.html.escape(args[3] || ''), range: args[4] || '', rawtogs: args[5] || '', inlhelp: inlinehelp, help: help }); }); if (configError) { config.configError = 'This calculator\'s config contains errors. Please report it ' + '<a href="/w/RuneScape:User_help" title="RuneScape:User help">here</a> ' + 'or check the javascript console for details.'; } config = $.extend(defConfig, config); mw.log(config); return config; }, /** * Generate a unique id for each input * * @param inputId {String} A string representing the id of an input * @returns {String} A string representing the namespaced/prefixed id of an input */ getId: function (inputId) { return [this.form, this.result, inputId].join('-'); }, /** * Output an error to the UI * * @param error {String} A string representing the error message to be output */ showError: function (error) { $('#' + this.result) .empty() .append( $('<strong>') .addClass('error') .text(error) ); }, /** * Toggle the visibility and enabled status of fields/groups * * @param item {String} A string representing the current value of the widget * @param toggles {object} An object representing arrays of items to be toggled keyed by widget values */ toggle: function (item, toggles) { var self = this; var togitem = function (widget, show) { var param = self.tParams[ self.indexkeys[widget] ]; if (param.type === 'group') { param.ooui.toggle(show); param.ooui.getItems().forEach(function (child) { if (!!child.setDisabled) { child.setDisabled(!show); } else if (!!child.getField().setDisabled) { child.getField().setDisabled(!show); } }); } else if ( param.type === 'semihidden' ) { if (!!param.ooui.setDisabled) { param.ooui.setDisabled(!show); } } else { param.layout.toggle(show); if (!!param.ooui.setDisabled) { param.ooui.setDisabled(!show); } } }; if (toggles[item]) { toggles[item].on.forEach( function (widget) { togitem(widget, true); }); toggles[item].off.forEach( function (widget) { togitem(widget, false); }); } else if ( toggles.not0 && !isNaN(parseFloat(item)) && parseFloat(item) !== 0 ) { toggles.not0.on.forEach( function (widget) { togitem(widget, true); }); toggles.not0.off.forEach( function (widget) { togitem(widget, false); }); } else if (toggles.alltogs) { toggles.alltogs.off.forEach( function (widget) { togitem(widget, false); }); } }, /** * Generate range and step for number and int inputs * * @param rawdata {string} The string representation of the range and steps * @param type {string} The name of the field type (int or number) * @returns {array} An array containing the min value, max value, step and button step. */ genRange: function (rawdata,type) { var tmp = rawdata.split(/\s*,\s*/), rng = tmp[0].split(/\s*-\s*/), step = tmp[1] || '', bstep = tmp[2] || '', min, max, parseFunc; if (type==='int') { parseFunc = function(x) { return parseInt(x, 10); } } else { parseFunc = parseFloat; } if (type === 'int') { step = 1; if ( isNaN(parseInt(bstep,10)) ) { bstep = 1; } else { bstep = parseInt(bstep,10); } } else { if ( isNaN(parseFloat(step)) ) { step = 0.01; } else { step = parseFloat(step); } if ( isNaN(parseFloat(bstep)) ) { bstep = 1; } else { bstep = parseFloat(bstep); } } // Accept negative values for either range position if ( rng.length === 3 ) { // 1 value is negative if ( rng[0] === '' ) { // First value negative if ( isNaN(parseFunc(rng[1])) ) { min = -Infinity; } else { min = 0 - parseFunc(rng[1]); } if ( isNaN(parseFunc(rng[2])) ) { max = Infinity; } else { max = parseFunc(rng[2]); } } else if ( rng[1] === '' ) { // Second value negative if ( isNaN(parseFunc(rng[0])) ) { min = -Infinity; } else { min = parseFunc(rng[0]); } if ( isNaN(parseFunc(rng[2])) ) { max = 0; } else { max = 0 - parseFunc(rng[2]); } } } else if ( rng.length === 4 ) { // Both negative if ( isNaN(parseFunc(rng[1])) ) { min = -Infinity; } else { min = 0 - parseFunc(rng[1]); } if ( isNaN(parseFunc(rng[3])) ) { max = 0; } else { max = 0 - parseFunc(rng[3]); } } else { // No negatives if ( isNaN(parseFunc(rng[0])) ) { min = 0; } else { min = parseFunc(rng[0]); } if ( isNaN(parseFunc(rng[1])) ) { max = Infinity; } else { max = parseFunc(rng[1]); } } // Check min < max if ( max < min ) { return [ max, min, step, bstep ]; } else { return [ min, max, step, bstep ]; } }, /** * Parse the toggles for an input * * @param rawdata {string} A string representing the toggles for the widget * @param defkey {string} The default key for toggles * @returns {object} An object representing the toggles in the format { ['widget value']:[ widget-to-toggle, group-to-toggle, widget-to-toggle2 ] } */ parseToggles: function (rawdata,defkey) { var tmptogs = rawdata.split(/\s*;\s*/), allkeys = [], allvals = [], toggles = {}; if (tmptogs.length > 0 && tmptogs[0].length > 0) { tmptogs.forEach(function (tog) { var tmp = tog.split(/\s*=\s*/), keys = tmp[0], val = []; if (tmp.length < 2) { keys = [defkey]; val = tmp[0].split(/\s*,\s*/); } else { keys = tmp[0].split(/\s*,\s*/); val = tmp[1].split(/\s*,\s*/); } if (keys.length === 1) { var key = keys[0]; toggles[key] = {}; toggles[key].on = val; allkeys.push(key); } else { keys.forEach( function (key) { toggles[key] = {}; toggles[key].on = val; allkeys.push(key); }); } allvals = allvals.concat(val); }); allkeys = allkeys.filter(function (item, pos, arr) { return arr.indexOf(item) === pos; }); allkeys.forEach(function (key) { toggles[key].off = allvals.filter(function (val) { if ( toggles[key].on.includes(val) ) { return false; } else { return true; } }); }); // Add all items to default toggles.alltogs = {}; toggles.alltogs.off = allvals; } return toggles; }, /** * Form submission handler */ submitForm: function () { var self = this, code = '{{' + self.template, formErrors = [], apicalls = [], paramVals = {}; if (self.module !== undefined) { if (self.modulefunc === undefined) { self.modulefunc = 'main'; }//<nowiki> code = '{{#invoke:'+self.module+'|'+self.modulefunc; }//</nowiki> self.submitlayout.setNotices(['Validating fields, please wait.']); self.submitlayout.fieldWidget.setDisabled(true); // setup template for submission self.tParams.forEach(function (param) { if ( param.type === 'hidden' || (param.type !== 'group' && param.ooui.isDisabled() === false) ) { var val, $input, // use separate error tracking for each input // or every input gets flagged as an error error = ''; if (param.type === 'fixed' || param.type === 'hidden') { val = param.def; } else { $input = $('#' + helper.getId.call(self, param.name) + ' input'); if (param.type === 'buttonselect') { val = param.ooui.findSelectedItem(); if (val !== null) { val = val.getData(); } } else { val = param.ooui.getValue(); } if (param.type === 'int') { val = val.split(',').join(''); } else if (param.type === 'check') { val = param.ooui.isSelected(); if (param.range) { val = param.range.split(',')[val ? 0 : 1]; } } else if (param.type === 'toggleswitch' || param.type === 'togglebutton') { if (param.range) { val = param.range.split(',')[val ? 0 : 1]; } } // Check input is valid (based on widgets validation) if ( !!param.ooui.hasFlag && param.ooui.hasFlag('invalid') && param.type !== 'article') { error = param.error; } else if ( param.type === 'article' && param.ooui.validateTitle && val.length > 0 ) { var api = param.ooui.getApi(), prms = { action: 'query', prop: [], titles: [ param.ooui.getValue() ] }; var prom = new Promise ( function (resolve,reject) { api.get(prms).then( function (ret) { if ( ret.query.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 (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 (html) { var self = this; $('#' + self.form + ' .jcSubmit') .data('oouiButton') .setDisabled(false); $('#bodyContent') .find('#' + this.result) .empty() .removeClass('jcError') .html(html); // allow scripts to hook into form submission mw.hook('rscalc.submit').fire(); // run all standard page-init things so various JS works as expected, including: // - sortable tables // - collapsible sections // - collapsed headers on mobile mw.hook('wikipage.content').fire($('#'+this.result)); /* mw.loader.using('jquery.tablesorter', function () { $('table.sortable:not(.jquery-tablesorter)').tablesorter(); }); mw.loader.using('jquery.makeCollapsible', function () { $('.mw-collapsible').makeCollapsible(); }); */ if ($('.rsw-chartjs-config').length) { mw.loader.load('ext.gadget.Charts-core'); } }, /** * Sanitise any HTML used in labels * * @param html {string} A HTML string to be sanitised * @returns {jQuery.object} A jQuery object representing the sanitised HTML */ sanitiseLabels: function (html) { var whitelistAttrs = [ // mainly for span/div tags 'style', // for anchor tags 'href', 'title', // for img tags 'src', 'alt', 'height', 'width', // misc 'class' ], whitelistTags = [ 'a', 'span', 'div', 'img', 'strong', 'b', 'em', 'i', 'br' ], // parse the HTML string, removing script tags at the same time $html = $.parseHTML(html, /* document */ null, /* keepscripts */ false), // append to a div so we can navigate the node tree $div = $('<div>').append($html); $div.find('*').each(function () { var $this = $(this), tagname = $this.prop('tagName').toLowerCase(), attrs, array, href; if (whitelistTags.indexOf(tagname) === -1) { mw.log('Disallowed tagname: ' + tagname); $this.remove(); return; } attrs = $this.prop('attributes'); array = Array.prototype.slice.call(attrs); array.forEach(function (attr) { if (whitelistAttrs.indexOf(attr.name) === -1) { mw.log('Disallowed attribute: ' + attr.name + ', tagname: ' + tagname); $this.removeAttr(attr.name); return; } // make sure there's nasty in nothing in href attributes if (attr.name === 'href') { href = $this.attr('href'); if ( // disable warnings about script URLs // jshint -W107 href.indexOf('javascript:') > -1 || // the mw sanitizer doesn't like these // so lets follow suit // apparently it's something microsoft dreamed up href.indexOf('vbscript:') > -1 // jshint +W107 ) { mw.log('Script URL detected in ' + tagname); $this.removeAttr('href'); } } }); }); return $div.contents(); }, /** * Handlers for parameter input types */ tParams: { /** * Handler for 'fixed' inputs * * @param param {object} An object containing the configuration of a parameter * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ fixed: function (param) { var layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-fixed'], value: param.def }; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } param.ooui = new OO.ui.LabelWidget({ label: param.def }); return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for select dropdowns * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ select: function (param, id) { var self = this, conf = { label: 'Select an option', options: [], name: id, id: id, value: param.def, dropdown: { $overlay: true } }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-select'] }, opts = param.range.split(/\s*,\s*/), def = opts[0]; param.error = 'Not a valid selection'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } opts.forEach(function (opt, i) { var op = { data: opt, label: opt }; if (opt === param.def) { op.selected = true; def = opt; } conf.options.push(op); }); param.toggles = helper.parseToggles(param.rawtogs, def); param.ooui = new OO.ui.DropdownInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (value) { helper.toggle.call(self, value, param.toggles); }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for button selects * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ buttonselect: function (param, id) { var self = this, buttons = {}, conf = { label:'Select an option', items: [], id: id }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-buttonselect'] }, opts = param.range.split(/\s*,\s*/), def; param.error = 'Please select a valid option'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } opts.forEach(function (opt, i) { var opid = opt.replace(/[^a-zA-Z0-9]/g, ''); var $opt = helper.sanitiseLabels(opt); var txt = $opt.text().trim(); if (txt === '') { txt = (i+1).toString(); } buttons[opid] = new OO.ui.ButtonOptionWidget({data:txt, label: new OO.ui.HtmlSnippet($opt), title:txt}); conf.items.push(buttons[opid]); }); if (param.def.length > 0 && opts.indexOf(param.def) > -1) { def = param.def; } else { def = opts[0]; } param.toggles = helper.parseToggles(param.rawtogs, def); param.ooui = new OO.ui.ButtonSelectWidget(conf); param.ooui.selectItemByData(def); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('choose', function (button) { var item = button.getData(); helper.toggle.call(self, item, param.toggles); }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for comboboxes * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ combobox: function (param, id) { var self = this, conf = { placeholder: 'Enter filter name', options: [], name: id, id: id, menu: { filterFromInput: true }, value: param.def, $overlay: true }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-combobox'] }, opts = param.range.split(/\s*,\s*/), def = opts[0]; param.error = 'Not a valid selection'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } opts.forEach(function (opt) { var op = { data: opt, label: opt }; if (opt === param.def) { op.selected = true; def = opt; } conf.options.push(op); }); var isvalid = function (val) {return opts.indexOf(val) < 0 ? false : true;}; conf.validate = isvalid; param.toggles = helper.parseToggles(param.rawtogs, def); param.ooui = new OO.ui.ComboBoxInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (value) { helper.toggle.call(self, value, param.toggles); }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for checkbox inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ check: function (param, id) { var self = this, conf = { name: id, id: id }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-check'] }; param.toggles = helper.parseToggles(param.rawtogs, 'true'); param.error = 'Unknown error'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if ( (param.def === 'true' || param.def === true) || (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { conf.selected = true; } param.ooui = new OO.ui.CheckboxInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (selected) { if (selected) { helper.toggle.call(self, 'true', param.toggles); } else { helper.toggle.call(self, 'false', param.toggles); } }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for toggle switch inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ toggleswitch: function (param, id) { var self = this, conf = { id: id }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-toggleswitch'] }; param.toggles = helper.parseToggles(param.rawtogs, 'true'); param.error = 'Unknown error'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if ( (param.def === 'true' || param.def === true) || (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { conf.value = true; } param.ooui = new OO.ui.ToggleSwitchWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (selected) { if (selected) { helper.toggle.call(self, 'true', param.toggles); } else { helper.toggle.call(self, 'false', param.toggles); } }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for toggle button inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ togglebutton: function (param, id) { var self = this, conf = { id: id, label: new OO.ui.HtmlSnippet(param.label) }, layconf = { label:'', align: 'right', classes: ['jsCalc-field', 'jsCalc-field-togglebutton'] }; param.toggles = helper.parseToggles(param.rawtogs, 'true'); param.error = 'Unknown error'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if ( (param.def === 'true' || param.def === true) || (param.range !== undefined && param.def === param.range.split(/\s*,\s*/)[0]) ) { conf.value = true; } param.ooui = new OO.ui.ToggleButtonWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (selected) { if (selected) { helper.toggle.call(self, 'true', param.toggles); } else { helper.toggle.call(self, 'false', param.toggles); } }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for hiscore inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ hs: function (param, id) { var self = this, layconf = { label: new OO.ui.HtmlSnippet(param.label), align:'right', classes: ['jsCalc-field', 'jsCalc-field-hs'] }, lookups = {}, range = param.range.split(';'), input1 = new OO.ui.TextInputWidget({type: 'text', id: id, name: id, value:param.def}), button1 = new OO.ui.ButtonInputWidget({ label: 'Lookup', id: id+'-button', name: id+'-button', classes: ['jsCalc-field-hs-lookup'], data: {param: param.name} }); if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } var layout = new OO.ui.ActionFieldLayout(input1, button1, layconf); var lookupHS = function(event) { var $t = $(event.target), lookup = self.lookups[button1.getData().param], // replace spaces with _ for the query name = $('#' + lookup.id + ' input') .val() // @todo will this break for players with multiple spaces // in their name? e.g. suomi's old display name .replace(/\s+/g, '_'), button = lookup.button; button.setDisabled(true); $.ajax({ url: '/cors/m=hiscore_oldschool/index_lite.ws?player=' + name, dataType: 'text', async: true, timeout: 10000 // msec }).done(function (data) { var hsdata; hsdata = data.trim() .split(/\n+/g); lookup.params.forEach(function (param) { var id = helper.getId.call(self, param.param), $input = $('#' + id + ' input'), tParam = null, val; self.tParams.forEach(function(p) { if (p.name === param.param) { tParam = p; } }); if (tParam === null) { return; } if (isNaN(param.skill)) { val = param.skill; //tParam.ooui.setValue(param.skill); } else { val = hsdata[param.skill].split(',')[param.val]; //tParam.ooui.setValue(hsdata[param.skill].split(',')[param.val]); } if (!!tParam.ooui.setValue) { tParam.ooui.setValue(val); } else if (!!tParam.ooui.selectItemByData) { tParam.ooui.selectItemByData(val); } else if (tParam.type === 'fixed') { tParam.ooui.setLabel(val); } }); // store in localStorage for future use if (rs.hasLocalStorage()) { self.lsRSN = name; localStorage.setItem('rsn', name); } button.setDisabled(false); layout.setErrors([]); }) .fail(function (xhr, status) { button.setDisabled(false); var err = 'The player "' + name + '" does not exist, is banned or unranked, or we couldn\'t fetch your hiscores. Please enter the data manually.'; console.warn(status); layout.setErrors([err]); helper.showError.call(self, err); }); }; button1.$element.click(lookupHS); input1.$element.keydown(function(event){ if (event.which === 13) { lookupHS(event); event.preventDefault(); } }); // Use rsn loaded from localstorage if (self.lsRSN) { input1.setValue(self.lsRSN); } lookups[param.name] = { id: id, button: button1, params: [] }; range.forEach(function (el) { // to catch empty strings if (!el) { return; } var spl = el.split(','); lookups[param.name].params.push({ param: spl[0], skill: spl[1], val: spl[2] }); }); // merge lookups into one object if (!self.lookups) { self.lookups = lookups; } else { self.lookups = $.extend(self.lookups, lookups); } param.ooui = input1; param.oouiButton = button1; return layout; }, /** * Handler for Runescape name inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ rsn: function (param, id) { var self = this, conf = { type: 'text', name: id, id: id, placeholder: 'Enter runescape name', spellcheck: false, maxLength: 12, value: param.def }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-string'] }; param.error = 'Invalid runescape name: RS names must be 1-12 characters long, can only contain letters, numbers, spaces, dashes and underscores. Names containing Mod are also not allowed.'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } // Use rsn loaded from localstorage, if available if (self.lsRSN) { conf.value = self.lsRSN; } var validrsn = function (name) { if ( name.search( /[^0-9a-zA-Z\-_\s]/ ) >= 0 ) { return false; } else { if ( name.toLowerCase().search( /(^mod\s|\smod\s|\smod$)/ ) >= 0 ) { return false; } else { return true; } } }; conf.validate = validrsn; param.ooui = new OO.ui.TextInputWidget(conf); return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for integer inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ int: function (param, id) { var self = this, rng = helper.genRange(param.range, 'int'), conf = { min:rng[0], max:rng[1], step:rng[2], showButtons:true, buttonStep:rng[3], allowInteger:true, name: id, id: id, value: param.def || 0 }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-int'] }, error = 'Invalid integer. Must be between ' + rng[0] + ' and ' + rng[1]; param.toggles = helper.parseToggles(param.rawtogs, 'not0'); if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if ( rng[2] > 1 ) { error += ' and a muiltiple of ' + rng[2]; } param.error = error; param.ooui = new OO.ui.NumberInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (value) { helper.toggle.call(self, value, param.toggles); }); } return new OO.ui.FieldLayout(param.ooui, layconf); }, /** * Handler for number inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ number: function (param, id) { var self = this, rng = helper.genRange(param.range, 'number'), conf = { min:rng[0], max:rng[1], step:rng[2], showButtons:true, buttonStep:rng[3], name:id, id:id, value:param.def || 0 }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-number'], }; param.toggles = helper.parseToggles(param.rawtogs, 'not0'); param.error = 'Invalid interger. Must be between ' + rng[0] + ' and ' + rng[1] + ' and a multiple of ' + rng[2]; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } param.ooui = new OO.ui.NumberInputWidget(conf); if ( Object.keys(param.toggles).length > 0 ) { param.ooui.on('change', function (value) { helper.toggle.call(self, value, param.toggles); }); } return new OO.ui.FieldLayout( param.ooui, layconf); }, /** * Handler for article inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ article: function (param, id) { var self = this, conf = { addQueryInput: false, excludeCurrentPage: true, showMissing: false, showDescriptions: true, validateTitle: true, relative: false, id: id, name: id, placeholder: 'Enter page name', value: param.def }, layconf = { label: new OO.ui.HtmlSnippet(param.label), align:'right', classes: ['jsCalc-field', 'jsCalc-field-article'] }, validNSnumbers = { '_*':'All', '_-2':'Media', '_-1':'Special', _0:'(Main)', _1:'Talk', _2:'User', _3:'User talk', _4:'RuneScape', _5:'RuneScape talk', _6:'File', _7:'File talk', _8:'MediaWiki', _9:'MediaWiki talk', _10:'Template', _11:'Template talk', _12:'Help', _13:'Help talk', _14:'Category', _15:'Category talk', _100:'Update', _101:'Update talk', _110:'Forum', _111:'Forum talk', _112:'Exchange', _113:'Exchange talk', _114:'Charm', _115:'Charm talk', _116:'Calculator', _117:'Calculator talk', _118:'Map', _119:'Map talk', _828:'Module', _829:'Module talk' }, validNSnames = { all:'*', media:-2, special:-1, main:0, '(main)':0, talk:1, user:2, 'user talk':3, runescape:4, 'runescape talk':5, file:6, 'file talk':7, mediawiki:8, 'mediawiki talk':9, template:10, 'template talk':11, help:12, 'help talk':13, category:14, 'category talk':15, update:100, 'update talk':101, forum:110, 'forum talk':111, exchange:112, 'exchange talk':113, charm:114, 'charm talk':115, calculator:116, 'calculator talk':117, map:118, 'map talk':119, module:828, 'module talk':829 }, namespaces = ''; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } if (param.range && param.range.length > 0) { var names = param.range.split(/\s*,\s*/), nsnumbers = []; names.forEach( function (nmspace) { nmspace = nmspace.toLowerCase(); if ( validNSnumbers['_'+nmspace] ) { nsnumbers.push(nmspace); } else if ( validNSnames[nmspace] ) { nsnumbers.push( validNSnames[nmspace] ); } }); if (nsnumbers.length < 1) { conf.namespace = '0'; namespaces = '(Main) namespace'; } else if (nsnumbers.length < 2) { conf.namespace = nsnumbers[0]; namespaces = nsnumbers[0] + ' namespace'; } else { conf.namespace = nsnumbers.join('|'); var nsmap = function (num) { return validNSnumbers['_'+num]; }; namespaces = nsnumbers.slice(0, -1).map(nsmap).join(', ') + ' or ' + nsnumbers.slice(-1).map(nsmap)[0] + ' namespaces'; } } else if ( self.suggestns && self.suggestns.length > 0 ) { var nsnumbers = []; self.suggestns.forEach( function (nmspace) { nmspace = nmspace.toLowerCase(); if ( validNSnumbers['_'+nmspace] ) { nsnumbers.push(nmspace); } else if ( validNSnames[nmspace] ) { nsnumbers.push( validNSnames[nmspace] ); } }); if (nsnumbers.length < 1) { conf.namespace = '0'; namespaces = '(Main) namespace'; } else if (nsnumbers.length < 2) { conf.namespace = nsnumbers[0]; namespaces = nsnumbers[0] + ' namespace'; } else { conf.namespace = nsnumbers.join('|'); var nsmap = function (num) { return validNSnumbers['_'+num]; }; namespaces = nsnumbers.slice(0, -1).map(nsmap).join(', ') + ' or ' + nsnumbers.slice(-1).map(nsmap)[0] + ' namespaces'; } } else { conf.namespace = '0'; namespaces = '(Main) namespace'; } param.error = 'Invalid page or page is not in ' + namespaces; param.ooui = new mw.widgets.TitleInputWidget(conf); return new OO.ui.FieldLayout( param.ooui, layconf); }, /** * Handler for group type params * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ group: function (param, id) { param.ooui = new OO.ui.HorizontalLayout({id: id, classes: ['jsCalc-group']}); if (param.label !== param.name) { var label = new OO.ui.LabelWidget({ label: new OO.ui.HtmlSnippet(param.label), classes:['jsCalc-grouplabel'] }); param.ooui.addItems([label]); } return param.ooui; }, /** * Default handler for inputs * * @param param {object} An object containing the configuration of a parameter * @param id {String} A string representing the id to be added to the input * @returns {OOUI.object} A OOUI object containing the new FieldLayout */ def: function (param, id) { var layconf = { label: new OO.ui.HtmlSnippet(param.label), align: 'right', classes: ['jsCalc-field', 'jsCalc-field-string'], value: param.def }; param.error = 'Unknown error'; if (param.help) { layconf.helpInline = param.inlhelp; layconf.help = new OO.ui.HtmlSnippet(param.help); } param.ooui = new OO.ui.TextInputWidget({type: 'text', name: id, id: id}); return new OO.ui.FieldLayout(param.ooui, layconf); } } };/** * Create an instance of `Calc` * and parse the config stored in `elem` * * @param elem {Element} An Element representing the HTML tag that contains * the calculator's configuration */function Calc(elem) { var self = this, $elem = $(elem), lines, config; // support div tags for config as well as pre // be aware using div tags relies on wikitext for parsing // so you can't use anchor or img tags // use the wikitext equivalent instead if ($elem.children().length) { $elem = $elem.children(); lines = $elem.html(); } else { // .html() causes html characters to be escaped for some reason // so use .text() instead for <pre> tags lines = $elem.text(); } lines = lines.split('\n'); config = helper.parseConfig.call(this, lines); // Calc name for localstorage, keyed to calc id this.localname = calcstorage + '-' + config.form; // Load previous parameter values. if (!rs.hasLocalStorage()) { console.warn('Browser does not support localStorage'); } else { mw.log('Loading previous calculator values'); var calcdata = JSON.parse( localStorage.getItem(this.localname) ) || false; if (calcdata) { config.tParams.forEach( function(param) { if (calcdata[param.name] !== undefined && calcdata[param.name] !== null) { param.def = calcdata[param.name]; } }); } self.lsRSN = localStorage.getItem('rsn'); mw.log(config); } // merge config in $.extend(this, config); /** * @todo document */ this.getInput = function (id) { if (id) { id = helper.getId.call(self, id); return $('#' + id); } return $('#jsForm-' + self.form).find('select, input'); };}/** * Helper function for getting the id of an input * * @param id {string} The id of the input as specified by the calculator config. * @returns {string} The true id of the input with prefixes. */Calc.prototype.getId = function (id) { var self = this, inputId = helper.getId.call(self, id); return inputId;};/** * Build the calculator form */Calc.prototype.setupCalc = function () { var self = this, fieldset = new OO.ui.FieldsetLayout({label: self.name, classes: ['jcTable'], id: 'jsForm-'+self.form}), submitButton, submitButtonAction, paramChangeAction, timeout, groupkeys = {}; // Used to store indexes of elements to toggle them later self.indexkeys = {}; self.tParams.forEach(function (param, index) { // can skip any output here as the result is pulled from the // param default in the config on submission if (param.type === 'hidden') { return; } var id = helper.getId.call(self, param.name), method = helper.tParams[param.type] ? param.type : 'def'; // Generate list of items in group if (param.type === 'group') { var fields = param.range.split(/\s*,\s*/); fields.forEach( function (field) { groupkeys[field] = index; }); } param.layout = helper.tParams[method].call(self, param, id); if (param.type === 'semihidden') { param.layout.toggle(false); } // Add to group or form if ( groupkeys[param.name] || groupkeys[param.name] === 0 ) { self.tParams[ groupkeys[param.name] ].ooui.addItems([param.layout]); } else { fieldset.addItems([param.layout]); } // Add item to indexkeys self.indexkeys[param.name] = index; }); // Run toggle for each field, check validity self.tParams.forEach( function (param) { if (param.toggles && Object.keys(param.toggles).length > 0) { var val; if (param.type === 'buttonselect') { val = param.ooui.findSelectedItem().getData(); } else if (param.type === 'check') { val = param.ooui.isSelected() ? 'true' : 'false'; } else if (param.type === 'toggleswitch' || param.type === 'togglebutton') { val = param.ooui.getValue() ? 'true' : 'false'; } else { val = param.ooui.getValue(); } helper.toggle.call(self, val, param.toggles); } if (param.type === 'number' || param.type === 'int' || param.type === 'rsn') { param.ooui.setValidityFlag(); } }); submitButton = new OO.ui.ButtonInputWidget({ label: 'Submit', flags: ['primary', 'progressive'], classes: ['jcSubmit']}); submitButtonAction = function (){ helper.submitForm.call(self); }; submitButton.on('click', submitButtonAction); submitButton.$element.data('oouiButton', submitButton); self.submitlayout = new OO.ui.FieldLayout(submitButton, {label: ' ', align: 'right', classes: ['jsCalc-field', 'jsCalc-field-submit']}); fieldset.addItems([ self.submitlayout ]); // Auto-submit if (['off', 'false', 'disabled'].indexOf(self.autosubmit) === -1) { // Add event paramChangeAction = function (widget) { if ( typeof widget.getFlags === 'undefined' || !widget.getFlags().includes('invalid')) { helper.submitForm.call(self); } }; // We only want one of these pending at once function timeoutFunc(param) { clearTimeout(timeout); timeout = setTimeout(paramChangeAction, 500, param); } self.tParams.forEach( function (param) { if (param.type === 'hidden' || param.type === 'hs' || param.type === 'group') { return; } else if (param.type === 'buttonselect') { param.ooui.on('select', timeoutFunc, [param.ooui]); } param.ooui.on('change', timeoutFunc, [param.ooui]); }); } if (self.configError) { fieldset.$element.append('<br>', self.configError); } $('#bodyContent') .find('#' + self.form) .empty() .append(fieldset.$element); // make buttonselects all the same height self.tParams.filter(e=>e.type==='buttonselect').forEach(e=>{ let m = e.ooui.items.reduce((acc,curr)=>Math.max(acc, curr.$element.find('> a.oo-ui-buttonElement-button').height()), 0); e.ooui.items.forEach(e=>e.$element.find('> a.oo-ui-buttonElement-button').height(m)); })};/** * @todo */function lookupCalc(calcId) { return calcStore[calcId];}/** * @todo */function init() { // Initialises class changes helper.initClasses(); $('.jcConfig').each(function () { var c = new Calc(this); c.setupCalc(); calcStore[c.form] = c; // if (c.autosubmit === 'true' || c.autosubmit === true) { // helper.submitForm.call(c); // } }); // allow scripts to hook into calc setup completion mw.hook('rscalc.setupComplete').fire();}$(init);rs.calc = {};rs.calc.lookup = lookupCalc;