MediaWiki:Gadget-gConfig.js

Z Wikipedii, wolnej encyklopedii

Uwaga: aby zobaczyć zmiany po opublikowaniu, może zajść potrzeba wyczyszczenia pamięci podręcznej przeglądarki.

  • Firefox / Safari: Przytrzymaj Shift podczas klikania Odśwież bieżącą stronę, lub naciśnij klawisze Ctrl+F5, lub Ctrl+R (⌘-R na komputerze Mac)
  • Google Chrome: Naciśnij Ctrl-Shift-R (⌘-Shift-R na komputerze Mac)
  • Internet Explorer / Edge: Przytrzymaj Ctrl, jednocześnie klikając Odśwież, lub naciśnij klawisze Ctrl+F5
  • Opera: Naciśnij klawisze Ctrl+F5.
/**
 * gConfig is a handy tool to allow users to modify settings of your gadget, with little hassle on your or their side.
 * 
 * Synopsis:
 *  - register your gadget with its settings: gConfig.register('lipsum', 'Lorem ipsum gadget', [...])
 *  - access the configuration: gConfig.get('lipsum', 'setting')
 * 
 * For more examples, see per-function docs below.
 * 
 * See also gConfig.css.
 * 
 * Version: 1.1.1
 * Dual-licensed CC-BY-SA 3.0 or newer, GFDL 1.3 or newer
 * Author: [[w:pl:User:Matma Rex]], patches: [[w:pl:User:Kaligula]], [[w:pl:User:Peter Bowman]], [[w:pl:User:Nux]], [[w:pl:User:Wargo]]
 * See also:
 * https://xtools.wmcloud.org/articleinfo/pl.wikipedia.org/MediaWiki:Gadget-gConfig.js?uselang=pl#top-editors
 * 
 * Bugz and pull requests:
 * https://github.com/Eccenux/wiki-gConfig
 * 
 * Deployed with love using Wikiploy: [[Wikipedia:Wikiploy]]
 */
(function(mw, $){
	mw.loader.using(['mediawiki.cookie', 'mediawiki.api', 'mediawiki.jqueryMsg'], function(){
		mw.messages.set({
			'gConfig-prefs-page-info': "<p>Na tej stronie możesz zmienić ustawienia włączonych gadżetów.</p><p>Informacje i dokumentacja: [[{{ns:Project}}:Narzędzia/gConfig]].</p>",
			'gConfig-prefs-page-title': "Preferencje gadżetów",
			'gConfig-prefs-no-gadgets': "Obecnie nie masz włączonych żadnych gadżetów korzystających z gConfiga.",
			'gConfig-prefs-save': "Zapisz",
			'gConfig-prefs-saving': "Zapisywanie...",
			'gConfig-prefs-saved': "Zapisano!",
			'gConfig-prefs-invalid-values': "Nieprawidłowe wartości.",
			'gConfig-prefs-legacy-setting': "To ustawienie jest w tej chwili wpisane na stałe w jednym z twoich plików .js. Usuń je stamtąd, aby stało się modyfikowalne.",
			'gConfig-prefs-not-an-integer': "Podana wartość nie jest liczbą całkowitą.",
			'gConfig-prefs-out-of-range-min': "Podana wartość jest mniejsza od minimalnej dozwolonej.",
			'gConfig-prefs-out-of-range-max': "Podana wartość jest większa od maksymalnej dozwolonej."
		});
		
		// Global gConfig object.
		var gConfig = {};
		// Data of all managed gadgets and settings. 
		gConfig.data = {};
		// Current values of gadgets' settings.
		gConfig.settings = {};
		
		var api = new mw.Api();
		
		// generate internal name for this setting.
		// used as input names, storage names...
		function internalName(gadget, setting)
		{
			return 'gconfig-'+gadget+'-'+setting;
		}
		// parse internal name. returns array of [gadget, setting].
		function parseInternalName(name)
		{
			var match = name.match(/^gconfig-([a-zA-Z0-9_]+)-(.+)$/);
			var gadget = match[1], setting = match[2];
			return [gadget, setting];
		}

		var _emptyValue = '__(EMPTY)__';
		
		// saves settings in user prefs
		// settings - array of arrays: [gadget, settingName, value]
		// calls saveSettingsCallback after every successful request
		function saveSettings(settings, callback)
		{
			var grouped = {};
			
			for(var i=0; i<settings.length; i++) {
				var name = internalName(settings[i][0], settings[i][1]);
				var value = settings[i][2];

				// saving empty values in MW options doesn't seem to work
				if (typeof value === 'string' && !value.length) {
					value = _emptyValue;
				}
				
				grouped['userjs-'+name] = value;

				// remove legacy cookies
				$.removeCookie(name, {path: '/', secure: true});
			}

			api.saveOptions(grouped).then(function(){
				callback();
			});
			
			return true;
		}
		
		// reads raw setting from mw.user.options or cookies.
		// returns undefined if it's not saved anywhere.
		function readRawSetting(gadget, settingName)
		{
			var name = internalName(gadget, settingName);
			var value = mw.user.options.get('userjs-'+name);

			if (value === _emptyValue) {
				value = '';
			}

			return value;
		}
		
		// validates and canonicalizes setting's values.
		// doesn't catch errors raised by validation().
		// can throw further errors (for numeric values: not an int, out of range)
		function validateAndCanonicalize(value, type, validation)
		{
			if(type == 'boolean') {
				value = (value ? '1' : '');
			} else if(type == 'string') {
				value = '' + value;
			} else if(type == 'integer') {
				if(parseInt(value, 10) != parseFloat(value)) throw mw.msg('gConfig-prefs-not-an-integer');
				value = parseInt(value, 10);
			} else if(type == 'numeric') {
				value = parseFloat(value);
			}
			
			if(typeof validation == 'function') {
				value = validation(value);
			} else if($.isArray(validation) && (type == 'integer' || type == 'numeric')) {
				var min = validation[0], max = validation[1];
				if(value < min) throw mw.msg('gConfig-prefs-out-of-range-min');
				if(value > max) throw mw.msg('gConfig-prefs-out-of-range-max');
			}
			
			return value;
		}
		
		// List of all registered gadgets.
		gConfig.registeredGadgets = [];
		// Map of internal gadget names => gadget infos (e.g. user-visible gadget names, links).
		gConfig.gadgetsInfo = {};
		// List of internal names of settings which were loaded using the legacy method.
		gConfig.legacySettings = [];
		
		// Register configuration for a new gadget.
		// 
		// * gadget is an internal name, must consist only of ASCII letters, numbers or underscore.
		// * gadgetInfo is an object with following keys:
		//   * name [required]: user-visible name, shown in preferences' headings.
		//   * descriptionPage: name of gadget's description page.
		// * settings is an array of configuration options for this gadget. Each option is an object with the following keys:
		//   * name [required]: internal name of this setting, not shown anywhere
		//   * desc [required]: description shown on the prefs page
		//   * descMode: how to treat the description text. 'plain' (default) for plain text, 'wikitext' for basic wikicode
		//     parsing (links, text formatting) using jqueryMsg
		//   * type [required]: boolean / integer / numeric / string, each type is handled differently on the prefs page and validated
		//   * deflt [required]: default value
		//   * validation: either an array [min, max] (for numeric/integer types), or a function that performs the validation.
		//     The function will receive value inputted by user as first (and only) parameter, and to indicate that the value
		//     is unacceptable must throw an error; the message used will be displayed on the prefs page to the user.
		//     It may also merely process values - it's return value will be used as the final value for the pref.
		//   * legacy: intended for migration of old scripts to gConfig. Can be either an array of [object, property] or
		//     just object, property will be assumed to be the same as setting's name. If object[property] will not be undefined,
		//     it's value will be taken as the value for this pref and the pref will be marked as legacy and become non-editable.
		// 
		// A lengthy example:
		//   gConfig.register(
		//     'lipsum',
		//     {
		//       name: 'Lorem ipsum gadget',
		//       descriptionPage: 'Project:Lorem ipsum gadget'
		//     },
		//     [
		//       {
		//         name: 'boolean',
		//         desc: 'Boolean value.',
		//         type: 'boolean',
		//         deflt: true
		//       }, {
		//         name: 'integer',
		//         desc: 'Integral number between 0 and 30.',
		//         type: 'integer',
		//         deflt: 20,
		//         validation: [0, 30]
		//       }, {
		//         name: 'float',
		//         desc: '[[Floating-point number]] between -1 and 1.',
		//         descMode: 'wikitext',
		//         type: 'numeric',
		//         deflt: 0.5,
		//         validation: [-1, 1]
		//       }, {
		//         name: 'string',
		//         desc: 'Text value.',
		//         type: 'string',
		//         deflt: 'test'
		//       }, {
		//         name: 'evenonly-passive',
		//         desc: 'Even numbers only. Will be rounded down if an odd number is given.',
		//         type: 'integer',
		//         deflt: 0,
		//         validation: function(n){ return n%2!=0 ? n-1 : n; }
		//       }, {
		//         name: 'evenonly-agressive',
		//         desc: 'Even numbers only. Will prevent saving if an odd number is given.',
		//         type: 'integer',
		//         deflt: 0,
		//         validation: function(n){ if(n%2!=0){ throw 'Requires an even number!' }; return n; }
		//       }
		//     ]
		//   );
		gConfig.register = function(gadget, gadgetInfo, settings)
		{
			gConfig.data[gadget] = settings;
			gConfig.settings[gadget] = {};
			
			for(var i=0; i<settings.length; i++) {
				var sett = settings[i];
				var value;
				
				// do some basic input validation to prevent nondescriptive errors later
				var errorMessage = "missing % in setting #"+i+" for "+gadget;
				if(!sett.name) throw errorMessage.replace('%', 'name');
				if(!sett.desc) throw errorMessage.replace('%', 'desc');
				if(!sett.type) throw errorMessage.replace('%', 'type');
				if(sett.deflt == undefined) throw errorMessage.replace('%', 'deflt');
				
				var isLegacy = false;
				if(sett.legacy) {
					var object, property;
					if($.isArray(sett.legacy)) { // [object, 'prop name']
						object = sett.legacy[0]; property = sett.legacy[1];
					} else { // object, prop name = sett.name
						object = sett.legacy; property = sett.name;
					}
					
					if(object[property] != undefined) {
						try {
							value = validateAndCanonicalize(object[property], sett.type, sett.validation);
							gConfig.legacySettings.push( internalName(gadget, sett.name) );
							isLegacy = true;
						// eslint-disable-next-line no-empty
						} catch(er) {} // if validation error, ignore this
					}
				}
				if(!isLegacy) {
					value = readRawSetting(gadget, sett.name);
					if(value == undefined) value = sett.deflt;
					value = validateAndCanonicalize(value, sett.type, sett.validation);
				}
				
				gConfig.settings[gadget][sett.name] = value;
			}
			
			gConfig.registeredGadgets.push(gadget);
			gConfig.gadgetsInfo[gadget] = (typeof gadgetInfo == 'string')
				? {name: gadgetInfo}
				: gadgetInfo;
			
			specialPage();
		}
		
		// Return the current value for given setting.
		// 
		// Do note that while integer, numeric and string values will always be of the corresponding JavaScript type,
		// boolean values need not be true/false, but merely truthy/falsy.
		gConfig.get = function(gadget, setting)
		{
			return gConfig.settings[gadget][setting];
		}
		
		// Set the current value for given setting. It is not validated.
		// 
		// For the value to be actually saved, you need to call gConfig.synchronise().
		gConfig.set = function(gadget, setting, value)
		{
			return gConfig.settings[gadget][setting] = value;
		}
		
		
		var synchroRunning = false;
		var synchroDelayedCallbacks = [];
		
		// Asynchronously saves current values of all settings.
		gConfig.synchronise = function(callback)
		{
			// a lot of ugly elaborate code to make sure bad things don't happen
			// if synchronise() is called when a synchro is already running.
			
			if(synchroRunning) {
				synchroDelayedCallbacks.push(callback);
				return;
			}
			synchroRunning = true;
			
			var meat = function(){
				var toSave = [];
				for(var i=0; i<gConfig.registeredGadgets.length; i++) {
					var gadget = gConfig.registeredGadgets[i];
					for(var j=0; j<gConfig.data[gadget].length; j++) {
						var setting = gConfig.data[gadget][j].name;
						toSave.push([gadget, setting, gConfig.get(gadget, setting)]);
					}
				}
				
				saveSettings(toSave, function(){
					synchroRunning = false;
					callback();
					if(synchroDelayedCallbacks.length > 0) {
						// this means there were calls to synchronise() while we were working.
						// we need to synchronise again, then call the callbacks.
						var cbs = synchroDelayedCallbacks;
						synchroDelayedCallbacks = [];
						gConfig.synchronise(function(){
							for(var i=0; i<cbs.length; i++) cbs[i]();
						})
					}
				});
			}
			
			meat();
		}
		
		function inputFor(value, type, validation)
		{
			var input = null;
			
			if(type == 'boolean') {
				input = $('<input type=checkbox>').prop('checked', !!value);
			} else if(type == 'string') {
				input = $('<input type=text>').prop('value', value);
			} else if(type == 'integer' || type == 'numeric') {
				input = $('<input type=number>').attr('step', (type == 'integer' ? 1 : 'any'))
				if(validation && $.isArray(validation)) {
					var min = validation[0], max = validation[1];
					input.attr({min: min, max: max});
				}
				input.prop('value', value);
			}
			
			return input;
		}
		
		var nowSaving = false;
		function specialPage()
		{
			if(mw.config.get('wgTitle') != "GadgetPrefs" || mw.config.get('wgCanonicalNamespace') != "Special") return false;
			
			var onsubmit = function(e){
				if(!nowSaving) {
					nowSaving = true;
					$content.find('.gconfig-pref-error').empty(); // remove infos about invalid values, if any
					
					var toSave = [];
					var errors = [];
					var $inputs = $content.find('input');
					
					for(var i=0; i<$inputs.length; i++) {
						var input = $inputs[i];
						if(input.type == 'submit') continue;
						
						var name = parseInternalName(input.name);
						var gadget = name[0], setting = name[1];
						
						var value = (input.type=='checkbox' ? input.checked : input.value);
						try {
							value = validateAndCanonicalize( value, $(input).data('gconfig-type'), $(input).data('gconfig-validation') );
						}
						catch(err) {
							errors.push([input.name, err]);
							continue;
						}
						
						toSave.push([gadget, setting, value]);
					}
					
					if(errors.length > 0) {
						$('#gconfig-save-status').attr('class', 'gconfig-save-error').text( mw.msg('gConfig-prefs-invalid-values') );
						// eslint-disable-next-line no-redeclare
						for(var i=0; i<errors.length; i++) {
							var id = errors[i][0], info = errors[i][1];
							$('#'+id).closest('tr').find('.gconfig-pref-error').text(info)
						}
						nowSaving = false;
					}
					else {
						$('#gconfig-save-status').attr('class', '').text( mw.msg('gConfig-prefs-saving') );
						saveSettings(toSave, function(){
							nowSaving = false;
							$('#gconfig-save-status').attr('class', 'gconfig-save-success').text( mw.msg('gConfig-prefs-saved') );
						})
					}
				}
				
				e.preventDefault();
				return false;
			}
			
			var $content;
			if(gConfig.registeredGadgets.length > 0) {
				$content = $('<table>');
				for(var i=0; i<gConfig.registeredGadgets.length; i++) {
					var gadget = gConfig.registeredGadgets[i];
					
					var gadgetName = (typeof gConfig.gadgetsInfo[gadget].descriptionPage == 'string')
						? $('<a>').attr({
								href:  '/wiki/'+encodeURIComponent(gConfig.gadgetsInfo[gadget].descriptionPage),
								title: gConfig.gadgetsInfo[gadget].descriptionPage
							}).text(gConfig.gadgetsInfo[gadget].name)
						: $( document.createTextNode(gConfig.gadgetsInfo[gadget].name) );
					
					$content.append(
						$('<tr>').append(
							$('<td>').attr('colspan', 2).append(
								$('<h2>').append(gadgetName)
							)
						)
					);
					
					for(var j=0; j<gConfig.data[gadget].length; j++) {
						var setting = gConfig.data[gadget][j];
						var inputName = internalName(gadget, setting.name);
						
						var $input = inputFor( gConfig.get(gadget, setting.name), setting.type, setting.validation );
						$input.attr('name', inputName).attr('id', inputName);
						$input.data({ 'gconfig-type': setting.type, 'gconfig-validation': setting.validation });
						
						var isLegacy = !!($.inArray(inputName, gConfig.legacySettings) != -1);
						mw.messages.set('gConfig-prefs-settingdesc-'+gadget+'-'+setting.name, setting.desc);
						var settingDesc = setting.descMode == 'wikitext'
							? mw.message('gConfig-prefs-settingdesc-'+gadget+'-'+setting.name).parseDom()
							: $( document.createTextNode(setting.desc) );
						
						$content.append(
							$('<tr>').append(
								$('<td>').append( $input.prop('disabled', !!isLegacy) ),
								$('<td>').append(
									$('<p>').addClass('gconfig-pref-label').append( $('<label>').attr('for', inputName).append(settingDesc) ),
									$('<p>').addClass('gconfig-pref-legacy-note').text( isLegacy ? mw.msg('gConfig-prefs-legacy-setting') : '' ),
									$('<p>').addClass('gconfig-pref-error')
								)
							)
						)
					}
				}
				
				// save button
				$content.append(
					$('<tr>').append(
						$('<td>'),
						$('<td>').append(
							$('<input type=submit>').attr('id', 'gconfig-save-button').attr('value', mw.msg('gConfig-prefs-save') ),
							$('<p>').attr('id', 'gconfig-save-status')
						)
					)
				)
			}
			else {
				$content = $('<p>').text( mw.msg('gConfig-prefs-no-gadgets') );
			}
			
			var $form = $('<form>').attr('id', 'gconfig-form').append( $content );
			$form.on('submit', onsubmit);
			$form.on('invalid', onsubmit); // we do our own validation - stop the browser from showing its error messages
			
			var $info = mw.message('gConfig-prefs-page-info').parseDom();
			document.title = mw.msg('gConfig-prefs-page-title');
			$('h1').first().text( mw.msg('gConfig-prefs-page-title') );
			$('#mw-content-text').empty().append($info, $form);

			return true;	// done
		}
		
		window.gConfig = gConfig;
		// usage: mw.hook('userjs.gConfig.ready').add(function (gConfig) {});
		mw.hook('userjs.gConfig.ready').fire(gConfig);

		// prepare GadgetPrefs page
		if (specialPage()) {
			mw.hook('userjs.gConfig.specialPage').fire(gConfig);
		}
	})
})(mediaWiki, jQuery);