MediaWiki:Gadget-TemplateDataEditor.js

Vikipedi, özgür ansiklopedi

Not: Sayfayı kaydettikten sonra değişiklikleri görebilmek için tarayıcınızın önbelleğinizi temizlemeniz gerekir. Google Chrome, Firefox, Microsoft Edge ve Safari: ⇧ Shift tuşuna basılı tutun ve Yeniden Yükle araç çubuğu düğmesine tıklayın. Ayrıntılar ve diğer tarayıcılara yönelik yönergeler için Vikipedi:Önbelleğinizi atlayın sayfasını inceleyin.

 /**************************************************
Gadget to edit “templatedata” tags for the MediaWiki extension TemplateData without having to edit JSON.

A “TDE” link is added in the edition toolbox when editing a template.
It opens a window allowing all modifications to the template data.

* Authors: Ltrlg (TDE) & Salix alba (TDS)
* Last update: 2013-08-19

== Limitation ==
MediaWiki _allows_ using parameters like '{' (yes, really!)

But here, you can’t use it:
* TDS does not see these parameters
* The default name for a parameter matches /\{[0-9]+\}/ and TDE does not save if if there is any parameter containing '{'.

Of course, I don’t think anybody uses a '{' parameter. So this is not blocking in many cases.

== Translations ==
* el: Xaris333, Geraki
* en: NicoV
* it: Jacopo Werther
* gl: Elisardojm
* ja: Shirayuki
* ko: Kwj2772
* nl: Wolf Lamber
* tr: ToprakM4

== Source ==

<syntaxhighlight lang="javascript">

 **************************************************/

function TemplateDataEditor($) {

	var
		/* global objects */
		ui, // The interface (instance of Interface)
		td, // The current (or last) editor (instance of TemplateData)
		
		/* unique identifiers (see trait UniqueElement) */
		uniq = 0,
		
		/* traits */
		UniqueElement, DataForm,
		
		/* tde regexps */
		regExpTwoTags = /<templatedata[^>]*>([\s\S]*)<\/templatedata>/,
		regExpOneTag = /<templatedata[^>]*\/>/,
		matchType = 0,
		
		/* indntation */
		defaultIndent = '\t',
		indent = defaultIndent,
		
		/* languages */
		userLanguage = mw.config.get('wgUserLanguage'),
		contentLanguage = mw.config.get('wgContentLanguage'),
		
		/* translations */
		messages = {
			"en": {
				"apply": "Apply",
				"cancel": "Cancel",
				"close": "Close",
				"collapse": "Collapse",
				"colon": ": ",
				"description": "Description of this template",
				"description-placeholder": "Enter a description of this template here",
				"error-description": "An error happened$2:\n\n$1", // $2 is message(error-report) or '' ; $1 is the error
				"error-it-lang-inexistent": "This language ($1) can’t be removed because TDE can’t find it anymore", // $1 is the language code
				"error-name-already-used": "You can’t rename this element because the new name is already used",
				"error-name-inexistent": "This element ($1) can’t be removed because TDE can’t find it anymore", // $1 is the name
				"error-report": " (report it)",
				"error-set-inexistent": "This set ($1) can’t be removed because TDE can’t find it anymore", // $1 is the id of the set
				"error-tds-not-loaded": "The page hasn’t been loaded",
				"expand": "Expand",
				"invalid-name": "“$1” is not a valid key",
				"it-add": "Add a language",
				"it-otherlanguages-show": "Show the languages ($1)", // $1 is the number of foreign languages
				"it-otherlanguages-hide": "Hide the languages", // $1 is the number of foreign languages
				"it-remove": "Remove this language",
				"param-add": "Add a parameter",
				"param-aliases": "Other names",
				"param-default": "Default value",
				"param-deprecated": "Deprecated",
				"param-description": "Description",
				"param-description-placeholder": "Insert a description of this parameter here",
				"param-inherits": "Documentation inherits from",
				"param-label": "Displayed name",
				"param-label-placeholder": "Enter name here",
				"param-name": "Real name",
				"param-remove": "Remove this parameter",
				"param-required": "Required",
				"param-type": "Type",
				"param-type-number": "Number",
				"param-type-string": "Text",
				"param-type-string/line": "Text (one line)",
				"param-type-string/wiki-page-name": "Page title",
				"param-type-string/wiki-user-name": "User name",
				"param-type-unknown": "Unknown",
				"parse-error": "The data can’t be parsed. This is caused in general by a syntax error in the JSON or by the presence of two datasets.",
				"preload-data": "Prefill the data",
				"preload-load": "Run",
				"preload-none": "Do not prefill",
				"preload-running": "Running…",
				"preload-select": "Prefill from",
				"no-data": "No data has been found. Please, add a <templatedata /> tag to be able to edit it.",
				"section-description": "Description",
				"section-params": "Parameters",
				"section-sets": "Sets",
				"set-add": "Add a set",
				"set-label": "Name",
				"set-params": "Parameters",
				"set-remove": "Remove this set",
				"start-tde": "Modify template data",
				"title": "Modify template data",
				"title-documentation": "documentation",
				"use-pipes": " (separated by pipes “|”)"
			},
			"tr": {
				"apply": "Uygula",
				"cancel": "İptal",
				"close": "Çık",
				"collapse": "Collapse",
				"colon": ": ",
				"description": "Şablonun açıklaması",
				"description-placeholder": "Şablonun açıklamasını buraya girin",
				"error-description": "Bir hata oldu$2:\n\n$1", // $2 is message(error-report) or '' ; $1 is the error
				"error-it-lang-inexistent": "Bu dil ($1) kaldırılamıyor çünkü TDE artık bu dili bulamıyor", // $1 is the language code
				"error-name-already-used": "Bu öğe yeniden adlandırılamadı çünkü yeni ad zaten kullanılıyor",
				"error-name-inexistent": "Bu öğe ($1) kaldırılamıyor çünkü TDE artık bu öğeyi bulamıyor", // $1 is the name
				"error-report": " (rapor et)",
				"error-set-inexistent": "Bu ayar ($1) kaldırılamıyor çünkü TDE artık bu ayarı bulamıyor", // $1 is the id of the set
				"error-tds-not-loaded": "Sayfa yüklenmedi",
				"expand": "Genişlet",
				"invalid-name": "“$1” is not a valid key",
				"it-add": "Bir dil ekle",
				"it-otherlanguages-show": "Dilleri ($1) görüntüle", // $1 is the number of foreign languages
				"it-otherlanguages-hide": "Dilleri gizle", // $1 is the number of foreign languages
				"it-remove": "Bu dili kaldır",
				"param-add": "Bir parametre ekle",
				"param-aliases": "Diğer adları",
				"param-default": "Varsayılan değer",
				"param-deprecated": "Reddet",
				"param-description": "Açıklama",
				"param-description-placeholder": "Parametrenin açıklamasını buraya ekleyin",
				"param-inherits": "Documentation inherits from", //Türkçeye çeviremedim - ToprakM
				"param-label": "Görüntülenen isim",
				"param-label-placeholder": "Buraya isim gir",
				"param-name": "Gerçek isim",
				"param-remove": "Bu parametreyi kaldır",
				"param-required": "Zorunlu",
				"param-type": "Tür",
				"param-type-number": "Sayı",
				"param-type-string": "Metin",
				"param-type-string/line": "Metin (bir satır)",
				"param-type-string/wiki-page-name": "Sayfa başlığı",
				"param-type-string/wiki-user-name": "Kullanıcı adı",
				"param-type-unknown": "Bilinmeyen",
				"parse-error": "Veriler ayrıştırılamıyor. Bu genellikle JSON'daki bir sözdizimi hatası veya iki veri kümesinin varlığı nedeniyle oluşur.",
				"preload-data": "Verileri önceden doldurun",
				"preload-load": "Çalıştır",
				"preload-none": "Önceden doldurma",
				"preload-running": "Çalıştırılıyor…",
				"preload-select": "Şuradan doldur:",
				"no-data": "Veri bulunamadı. Lütfen düzenleyebilmek için istediğiniz yere <templatedata /> etiketi ekleyin.",
				"section-description": "Açıklama",
				"section-params": "Parametreler",
				"section-sets": "Setler",
				"set-add": "Bir set ekle",
				"set-label": "İsim",
				"set-params": "Parametreler",
				"set-remove": "Bu seti kaldır",
				"start-tde": "Şablon verisini değiştir",
				"title": "Şablon verisini değiştir",
				"title-documentation": "belgeleme",
				"use-pipes": " (pipe “|” ile ayırılabilir)"
			}
		},
		documentations = { // Local pages (but full link for default)
			"default": '//tr.wikipedia.org/wiki/Vikipedi:Araçlar/TemplateDataEditor'
		};
	
	////////// Translation //////////
	
	function messageLang(name, lang) {
		var
			res,
			i,
			T = [];
		
		if( name == '' ) {
			return '';
		}
		
		if( lang == 'qqx' ) {
			res = '(-tde-' + name;
			if( arguments.length > 2 ) {
				res += ': ';
				for(i=2; i<arguments.length; i++) {
					T.push(arguments[i]);
				}
				res += T.join(', ');
			}
			return res + ')';
		} else {
			if( messages[lang] && messages[lang][name] ) {
				res = messages[lang][name];
			} else if( messages.en[name] ) {
				res = messages.en[name];
			} else {
				arguments[1] = 'qqx';
				return messageLang.apply(null, arguments);
			}
		
			// Replace vars
			for(i=arguments.length-2; i>0; i--) {
				res = res.replace(new RegExp('\\$'+i, 'g'), arguments[i+1]);
			}
			
			return res;
		}
	}
	
	function message(name) {
		var args = Array.prototype.slice.call(arguments);
		args.shift();
		args.unshift(name, userLanguage);
		return messageLang.apply(null, args);
	}
	
	function documentationLink() {
		var wiki = mw.config.get('wgDBname');
		if( documentations.hasOwnProperty( wiki ) ) {
			return mw.util.getUrl( documentations[wiki] );
		} else {
			return documentations['default'];
		}
	}
	
	////////// Getting & setting text (compatibility with WikEd) //////////
	
	function getText() {
		if( window.wikEd && window.wikEd.useWikEd ) {
			WikEdUpdateTextarea();
		}
		return $('#wpTextbox1').val();
	}
	
	function setText(value) {
		$('#wpTextbox1').val(value);
		if( window.wikEd && window.wikEd.useWikEd ) {
			WikEdUpdateFrame();
		}
	}
	
	////////// Usefull functions //////////
	
	function userError() {
		var e = new Error( message.apply(null, arguments) );
		e.userError = true;
		return e;
	}
	
	function scriptError() {
		var e = new Error( message.apply(null, arguments) );
		e.userError = false;
		return e;
	}
	
	function alertError(e) {
		alert(message(
			'error-description',
			e.message,
			e.userError ? '' : message('error-report')
		));
		console.error('“' + e.message + '”\nError thrown by ' + e.fileName + ' on line ' + e.lineNumber);
	}
	
	function ucfirst(str) {
		return str[0].toUpperCase() + str.substring(1, str.length);
	}
	
	function norm(str) {
		return ucfirst( str.replace('_', ' ') );
	}
		
	function trim(str) {
		return str.replace(/^\s*(\S.*\S|\S)\s*$/, '$1');
	}

	function trimArray(Arr) {
		var i = 0;
		for(; i<Arr.length; i++) {
			Arr[i] = trim(Arr[i]);
		}
		return Arr;
	}
	
	function strToArr(str) {
		return trimArray( str.split('|') );
	}
	
	function arrToStr(arr) {
		return arr.join(' | ');
	}
	
	function $clear() {
		return $('<div>').addClass('tde-clear');
	}
	
	function selectValue(select) { // Select is a jQuery object $('<select>') or a DOM select node
		var res;
		$(select).children('option').each(function(){
			if( $(this).prop('selected') ) {
				res = $(this).val();
				return false;
			}
		});
		return res;
	}
	
	function getIndent(text) { // Should work fine with any well-indented JSON
		var
			lines = text.split('\n'),
			i,
			maxLength = Infinity,
			indent = defaultIndent,
			localIndent;
		
		for(i=0; i<lines.length; i++) {
			try {
				localIndent = /^(\s*)\S/.exec(lines[i])[1];
				if( localIndent.length < maxLength && localIndent.length != 0 ) {
					indent = localIndent;
					maxLength = localIndent.length;
				}
			} catch(e) {
				// Nothing to do, just a line without \S
			}
		}
		return indent;
	}
	
	function noCurlyBraceKey(object) {
		for(var i in object) {
			if( /[{}]/.test(i) ) {
				throw userError('error-invalid-name', i);
			}
		}
	}
	
	function arrayRemoveElement(array, i) {
		var
			newLength = array.length-1,
			j;
		
		for(j=i; j<array.length-1; j++) {
			array[j] = array[j+1];
		}
		
		array.length = newLength;
	}
	
	////////// TemplateDataSkeleton (partial, adapted) //////////
	
	function TemplateDataSkeletonFromText(text) {
		
		var
			pat = /\{\{\{([^\{\|\}\n<]+)(.)/g,  // '{{{' then any char other than {|}\n<
			matches, name, newReq, oldReq,
			params = {};
		
		while( (matches=pat.exec(text)) != null ) {
			name = trim(matches[1]);
			newReq = ( matches[2]== '}' );
			oldReq = ( params[name] == null || params[name].required ); 
			
			params[name] = {
				required: newReq && oldReq,
				label: norm(name)
			};
			
			pat.lastIndex--; // need to backtrack one character
		}
		
		return { params: params };
	}
	
	function TemplateDataSkeleton(page, cb) {
		
		function error() {
			alertError( scriptError('tds-not-loaded') );
			cb({});
		}
		
		$.ajax({
			url: mw.util.wikiScript('api'),
			data: {
				action: 'query',
				prop: 'revisions',
				titles: page,
				rvprop: 'content',
				format: 'json'
			},
			dataType: 'json',
			error: error,
			success: function( data ) {
				try {
					var pageId = Object.keys(data.query.pages)[0];
					cb( TemplateDataSkeletonFromText(data.query.pages[pageId].revisions[0]['*'] || '') ); // '' if missing page
				} catch(e) {
					error();
				}
			}
		});
		
	}
	
	////////// Actions //////////
	
	function action(_options) {
		
		var
			options = $.extend({
				type: null,
				desc: '',
				fn: function(){},
				aClass: '',
				aId: ''
			}, _options),
			img = action.images[options.type];
		
		return $('<a>')
			.attr({
				title: options.desc,
				href: '#',
				'class': options.aClass,
				id: options.aId
			})
			.click(function(){
				options.fn.call(this);
				return false;
			})
			.append($('<img>')
				.attr({
					alt: options.desc,
					src: '//upload.wikimedia.org/wikipedia/commons/thumb/'
						+ img.hashStart[0] + '/'
						+ img.hashStart + '/'
						+ img.name + '/'
						+ '24px-' + img.name + '.png'
				})
			);
	}
	
	action.images = {
		add: {
			hashStart: '8b',
			name: 'VisualEditor_-_Icon_-_Add-item.svg'
		},
		remove:  {
			hashStart: '0e',
			name: 'VisualEditor_-_Icon_-_Remove-item.svg'
		},
		close:  {
			hashStart: '8d',
			name: 'VisualEditor_-_Icon_-_Close.svg'
		},
		expand:  {
			hashStart: '2f',
			name: 'VisualEditor_-_Icon_-_Expand.svg'
		},
		collapse:  {
			hashStart: '32',
			name: 'VisualEditor_-_Icon_-_Collapse.svg'
		},
		expand_inline:  {
			hashStart: 'd3',
			name: 'VisualEditor_-_Icon_-_Move-ltr.svg'
		},
		collapse_inline:  {
			hashStart: '4e',
			name: 'VisualEditor_-_Icon_-_Move-rtl.svg'
		}
	};
	
	////////// class Interface //////////
	
	function Interface() {
		
		var that = this;
		
		this.$title = $('<h2>')
			.text( message('title') )
			.append(document.createTextNode(' ('))
			.append($('<a>')
				.attr('href', documentationLink() )
				.text( message('title-documentation') )
			)
			.append(document.createTextNode(')'));
		
		this.$body = $('<div>').attr('id', 'tde-body');
		
		this.$buttonContainer = $('<div>').addClass('tde-buttons');
		
		this.$cont = $('<div>')
			.attr('id', 'tde')
			.append($('<div>').attr('id', 'tde-mask'))
			.append($('<div>')
			.attr('id', 'tde-dialog')
			.append( this.$title )
			.append( action({type: 'close', desc: message('close'), fn: function() { that.close(); }, aId: 'tde-close'}) )
			.append( this.$body )
			.append( this.$buttonContainer )
			)
			.hide();
		
		$(document.body).append(this.$cont);
	}
	
	Interface.prototype = {
		clear: function() {
			this.$body.children().remove();
			this.deleteButtons();
		},
		
		close: function() {
			this.$cont.fadeOut();
		},
		
		open: function() {
			this.$cont.fadeIn();
		},
		
		addCancelButton: function() {
			var that = this;
			this.addButton('cancel', function(){
				that.close();
			});
		},
		
		addButton: function(msg, fn) {
			this.$buttonContainer.append($('<input>')
				.attr('type', 'button')
				.val( message(msg) )
				.click(fn)
			);
		},
		
		deleteButtons: function() {
			this.$buttonContainer.children().remove();
		},
		
		replaceButton: function(fn) {
			this.deleteButtons();
			this.$buttonContainer.append($('<input>')
				.attr({
					id: 'tde-apply',
					type: 'button'
				})
				.val( message('apply') )
				.click(fn)
			);
		}
	};
	
	ui = new Interface;
	
	////////// class Renamer //////////
	
	function Renamer(id, name, parent, readonly) {
		this.name = name;
		this.parent = parent;
		this.readonly = !! readonly;
		
		var that = this;
		
		this.$input = $('<input>')
			.addClass('tde-renamer-name-input')
			.attr({
				type: 'text',
				id: id
			})
			.prop('readonly', this.readonly)
			.blur(function(){
				that.exec();
			})
			.val(name);
	}
	
	Renamer.prototype = {
		getNode: function() {
			return this.$input;
		},
		
		exec: function() {
			var newName = this.$input.val();
			try {
				this.parent.renameElement(this.name, newName);
				this.name = newName;
			} catch(e) {
				alertError( e );
			}
			this.$input.val(this.name);
		}
	};
	
	////////// trait UniqueElement //////////
	
	UniqueElement = {
	
		defineUniq: function() {
			this.uniq = uniq;
			uniq++;
		}
	
	};
	
	////////// abstract class InterfaceText use UniqueElement //////////
	
	function InterfaceText() { /* Call to this.construct from subclasses */ }
	
	InterfaceText.prototype = $.extend({}, UniqueElement, {
		construct: function(values, labelText, placeholderMessage, $cont) {
			this.defineUniq();
			if( $.type(values) != 'object' ) {
				this.data = {};
				this.data[contentLanguage] = values;
			} else {
				this.data = values;
				if( typeof this.data[contentLanguage] == 'undefined' ) {
					this.data[contentLanguage] = '';
				}
			}
			this.numberOtherLanguages = Object.keys( this.data ).length - 1;
			this.label = labelText + (this.useColon ? message('colon') : '');
			this.placeholder = placeholderMessage;
			this.$cont = $cont.addClass('tde-it');;
			this.createContent();
			this.createInputs();
			this.hideOther();
		},
		
		getClasses: function(lang) {
			return 'tde-it-lang tde-it-lang-' + (lang || contentLanguage);
		},
		
		createInputs: function() {
			var i;
			for( i in this.data ) {
				this.createInput(i, i == contentLanguage);
			}
		},
		
		onChange: function(domInput) {
			this.data[
				/(^|\s)tde-it-lang-(\S+)(\s|$)/.exec( $(domInput).closest('.tde-it-lang').attr('class') )[2]
			] = domInput.value;
		},
		
		input: function($input, lang) {
			var that = this;
			return $input
				.val( this.data[lang] )
				.addClass('tde-it-input')
				.attr('placeholder', messageLang(this.placeholder, lang))
				.change(function(){
					that.onChange(this);
				});
		},
		
		getPlaceholder: function(lang) {
			return messageLang(this.placeholder, lang);
		},
		
		hideOther: function() {
			this.$expand.show();
			this.$collapse.hide();
			this.$cont.find('.tde-it-lang').each(function(){
				if(
					! $(this).hasClass('tde-it-lang-'+contentLanguage)
					&& ! $(this).hasClass('tde-it-lang-'+userLanguage)
				) {
					$(this).hide();
				}
			});
			this.$add.hide();
		},
		
		showOther: function() {
			this.$expand.hide();
			this.$collapse.show();
			this.$cont.find('.tde-it-lang').css('display', ''); // .show() does .css('display', 'inline'), but here we need inline-block, as declared in the stylesheet
			this.$add.show();
		},
		
		updateNumber: function() {
			this.$expand.attr('title', message('it-otherlanguages-show', this.numberOtherLanguages));
			this.$expand.find('img').attr('alt', message('it-otherlanguages-show', this.numberOtherLanguages));
			this.$collapse.attr('title', message('it-otherlanguages-hide', this.numberOtherLanguages));
			this.$collapse.find('img').attr('alt', message('it-otherlanguages-hide', this.numberOtherLanguages));
		},
		
		actionSuffix: '',
		useColon: true,
		
		createToggleLinks: function(){
			var that = this;
			
			this.$expand = action({
				type: 'expand' + this.actionSuffix,
				aClass: 'tde-it-expand',
				fn: function(){
					that.showOther();
					return false;
				}
			});
			
			this.$collapse = action({
				type: 'collapse' + this.actionSuffix,
				aClass: 'tde-it-collapse',
				fn: function(){
					that.hideOther();
					return false;
				}
			});
			
			this.updateNumber();
			return $().add(this.$collapse).add(this.$expand);
		},
		
		createAddLink: function() {
			var that = this;
			this.$add = action({
				type: 'add',
				desc: message('it-add'),
				fn: function(){
					that.addInput();
				},
				aClass: 'tde-add-language'
			});
			return this.$add;
		},
		
		getLangDiv: function(lang) {
			var res;
			this.$cont.find('.tde-it-lang').each(function(){
				if( $(this).hasClass('tde-it-lang-'+lang) ) {
					res = $(this);
					return false;
				}
			});
			return res;
		},
		
		getValueInput: function(lang) {
			return this.getLangDiv(lang).find('.tde-it-input');
		},
		
		renameElement: function(from, to) {
			if( this.data.hasOwnProperty(to) ) {
				if( to != from ) {
					throw userError('error-name-already-used');
				}
			} else if( this.data.hasOwnProperty(from) ) {
				this.getLangDiv(from).attr('class', this.getClasses(to));
				this.getValueInput(to).attr('placeholder', this.getPlaceholder(to)); // "this.getValueInput(to)": "to" because the class have been modified by the line before
				this.data[to] = this.data[from];
				delete this.data[from];
			} else {
				throw scriptError('error-it-lang-inexistent', from);
			}
		},
		
		removeElement: function(lang) {
			if( this.data.hasOwnProperty(lang) ) {
				delete this.data[lang];
				this.getLangDiv(lang).remove();
			} else {
				throw scriptError('error-it-lang-inexistent', lang);
			}
		},
		
		$removeLang: function(readonly) {
			var that = this;
			if( readonly ) {
				return null;
			} else {
				return action({
					type: 'remove',
					desc: message('it-remove'),
					fn: function(){
						var $lang = $(this).closest('.tde-it-lang');
						try {
							that.removeElement( $lang.find('.tde-renamer-name-input').val() );
							$lang.remove();
						} catch(e) {
							alertError(e);
						}
					}
				});
			}
		},
		
		untitledId: 0,
		
		addInput: function() {
			this.untitledId++;
			var lang = '{' + this.untitledId + '}';
			this.data[lang] = '';
			this.createInput(lang, false);
		},
		
		getData: function() {
			noCurlyBraceKey( this.data );
			if( Object.keys(this.data).length == 1 ) {
				return this.data[contentLanguage] || undefined;
			} else {
				// TODO delete empty strings
				return this.data;
			}
		},
		
		/* abstract */ createContent: function() { },
		/* abstract */ createInput: function(lang, readonly) { }
	});
	
	////////// class InterfaceTextBlock extends InterfaceText //////////
	
	function InterfaceTextBlock() {
		this.construct.apply(this, arguments);
		this.$cont.addClass('tde-it-block');
	}
	
	InterfaceTextBlock.prototype = $.extend(new InterfaceText(), {
		createContent: function() {
			
			this.$tbody = $('<tbody>');
			
			var $caption = $('<caption>')
				.text(this.label)
				.append(this.createToggleLinks());
			
			this.$cont
				.append($('<table>')
					.append( $caption )
					.append( this.$tbody )
				)
				.append( this.createAddLink() )
				.append( $clear() );
			
		},
		
		useColon: false,
		
		createInput: function(lang, readonly) {
			this.$tbody.append($('<tr>')
				.attr('class', this.getClasses(lang))
				.append($('<th>')
					.attr('scope', 'row')
					.append( new Renamer(null, lang, this, readonly).getNode() )
				)
				.append($('<td>')
					.append( this.input($('<textarea>'), lang) )
				)
				.append($('<td>')
					.append( this.$removeLang(readonly) )
				)
			);
		}
	});
	
	////////// class InterfaceTextInline extends InterfaceText //////////
	
	function InterfaceTextInline() {
		this.construct.apply(this, arguments);
		this.$cont.addClass('tde-it-inline');
	}
	
	InterfaceTextInline.prototype = $.extend(new InterfaceText(), {
		createContent: function() {
			
			this.$span = $('<span>');
			
			this.$cont
				.text( this.label )
				.append( this.$span )
				.append( this.createAddLink() )
				.append( this.createToggleLinks() );
			
		},
		
		actionSuffix: '_inline',
		
		createInput: function(lang, readonly) {
			this.$span.append($('<span>')
				.attr('class', this.getClasses(lang))
				.append( new Renamer(null, lang, this, readonly).getNode() )
				.append( document.createTextNode( message('colon') ) )
				.append( this.input($('<input>').attr('type', 'text'), lang) )
				.append( this.$removeLang(readonly) )
			);
		}
	});
	
	////////// trait DataForm //////////
	
	DataForm = {
		
		getCont: function( name ) {
			return this['$'+name];
		},
		
		newline: function(cont) {
			this.getCont(cont).append('<br>');
			return this;
		},
		
		addInput: function(cont, type, key, label) {
			var
				$cont = this.getCont(cont),
				that = this,
				inputClass = 'tde-' + this.type + '-' + key,
				labelClass = inputClass + '-label',
				id = inputClass + '-' + this.uniq,
				$input;
			
			function addLabel(colon) {
				$cont
					.append($('<label>')
						.addClass(labelClass)
						.attr('for', id)
						.text(
							label
							+ ( type == 'array' ? message('use-pipes') : '' )
							+ ( colon ? message('colon') : '' ) )
					);
			}
			
			function addInput() {
				$cont
					.append( $input );
			}
			
			switch(type) {
				case 'text':
					$input = $('<input>')
						.addClass(inputClass)
						.attr({
							type: 'text',
							id: id
						})
						.val( this.data[key] || '' )
						.change(function(){ that.change(key, this.value); });
					addLabel(true);
					addInput();
					break;
				case 'array':
					$input = $('<input>')
						.addClass(inputClass)
						.attr({
							type: 'text',
							id: id
						})
						.val( arrToStr( this.data[key] || [] ) )
						.change(function(){ that.changeArray(key, this.value); });
					addLabel(true);
					addInput();
					break;
				case 'checkbox':
					$input = $('<input>')
						.addClass(inputClass)
						.attr({
							type: 'checkbox',
							id: id
						})
						.prop('checked', this.data[key])
						.change(function(){ that.change(key, this.checked); });
					addInput();
					addLabel(false);
					break;
				case 'typeSelect':
					$input = $('<select>')
						.addClass(inputClass)
						.attr('type', 'text')
						.attr('id', id)
						.append( Param.type('unknown', this.data.type) )
						.append( Param.type('number', this.data.type) )
						.append( Param.type('string', this.data.type) )
						.append( Param.type('string/line', this.data.type) )
						.append( Param.type('string/wiki-user-name', this.data.type) )
						.append( Param.type('string/wiki-page-name', this.data.type) )
						.change(function(){ that.changeType(this); });
					addLabel(true);
					addInput();
					break;
			}
			this['input-'+key] = $input;
			return this;
		},
		
		addDescription: function(cont) {
			var $div = $('<div>');
			
			this.getCont(cont).append($div);
			
			this.description = new InterfaceTextBlock(
				this.data.description || '',
				message(this.type + '-description'),
				this.type + '-description-placeholder',
				$div
			);
			
			return this;
		},
		
		addLabel: function(cont) {
			var $span = $('<span>');
			
			this.getCont(cont).append($span);
			
			this.label = new InterfaceTextInline(
				this.data.label || '',
				message(this.type + '-label'),
				this.type + '-label-placeholder',
				$span
			);
			
			return this;
		},
		
		change: function(key, newValue) {
			if( key == 'deprecated' ) {
				this.data[key] = newValue || false;
			} else {
				this.data[key] = newValue;
			}
		},
		
		changeArray: function(key, str) {
			this.change(key, strToArr(str));
		},
		
		changeType: function(domSelect) {
			this.change('type', selectValue(domSelect));
		}
		
	};
	
	////////// class Param uses UniqueElement, DataForm //////////
	
	function Param(params, name, $list, data) {
		this.defineUniq();
		
		var
			that = this;
		
		this.params = params;
		this.name = name;
		this.$cont = $('<li>');
		this.data = data;
		
		this.$head = $('<div>');
		this.$body = $('<div>');
		
		this.$expand = action({type: 'expand', desc: message('expand'), fn: function() { that.expand(); }, aClass: 'tde-param-expand'});
		this.$collapse = action({type: 'collapse', desc: message('collapse'), fn: function() { that.collapse(); }, aClass: 'tde-param-collapse'});
		
		if(
			( data.required || data.description == '' )
			&& ! data.inherits
		) {
			this.expand();
		} else {
			this.collapse();
		}
		
		this.$cont
			.append( action({type: 'remove', desc: message('param-remove'), fn: function() { that.remove(); }, aClass: 'tde-remove-line'}) )
			.append( this.$expand )
			.append( this.$collapse )
			.append( this.$head )
			.append( this.$body )
			.append( $clear() );
		
		this.$head
			.append($('<label>')
				.attr('for', 'tde-paramName-'+this.uniq)
				.text( message('param-name') + message('colon') )
			)
			.append( (new Renamer('tde-paramName-'+this.uniq, name, params)).getNode() );
		
		this
			.addInput('head', 'checkbox', 'required', message('param-required'))
			.addInput('head', 'text', 'inherits', message('param-inherits'))
			.addLabel('body')
			.newline('body')
			.addInput('body', 'typeSelect', 'type', message('param-type'))
			.addInput('body', 'text', 'default', message('param-default'))
			.newline('body')
			.addInput('body', 'text', 'deprecated', message('param-deprecated'))
			.newline('body')
			.addInput('body', 'array', 'aliases', message('param-aliases'))
			.addDescription('body');
		
		$list.append( this.$cont );
	}
	
	Param.prototype = $.extend({}, UniqueElement, DataForm, {
		
		type: 'param',
		
		expand: function() {
			this.$collapse.show();
			this.$expand.hide();
			this.$body.show();
		},
		
		collapse: function() {
			this.$collapse.hide();
			this.$expand.show();
			this.$body.hide();
		},
		
		remove: function() {
			try {
				this.params.removeElement(this.name);
				this.$cont.remove();
			} catch(e) {
				alertError( e );
			}
		},
		
		getCont: function( name ) {
			return this['$'+name];
		},
		
		getData: function() {
			this.data.label = this.label.getData();
			this.data.description = this.description.getData();
			return this.data;
		}
	});
	
	Param.type = function(type, currentType) {
		return $('<option>')
			.val(type)
			.text( message('param-type-' + type) )
			.prop('selected', currentType == type);
	};
	
	////////// class Params //////////
	
	function Params($list, data) {
		this.$list = $list;
		this.data = data;
		this.params = {};
		
		var i, that = this;
		
		for( i in data ) {
			this.createLi(i);
		}
		
		this.$list.first().after(
			action({type: 'add', fn: function(){that.addItem();return false;}, desc: message('param-add'), aClass: 'tde-add-line'})
		);
	}
	
	Params.prototype = {
		
		untitledId: 0,
		
		addItem: function() {
			this.untitledId++;
			var name = '{' + this.untitledId + '}';
			this.data[name] = {};
			this.createLi(name);
		},
		
		createLi: function(name) {
			this.params[name] = new Param(this, name, this.$list, this.data[name]);
		},
		
		renameElement: function(from, to) {
			if( this.data.hasOwnProperty(to) ) {
				if( to != from ) {
					throw userError('error-name-already-used');
				}
			} else if( this.data.hasOwnProperty(from) ) {
				this.data[to] = this.data[from];
				this.params[to] = this.params[from];
				delete this.data[from];
				delete this.params[from];
			} else {
				throw scriptError('error-name-inexistent', from);
			}
		},
		
		removeElement: function(name) {
			if( this.data.hasOwnProperty(name) ) {
				delete this.data[name];
				delete this.params[name];
			} else {
				throw scriptError('error-name-inexistent', name);
			}
		},
		
		getData: function() {
			for( var i in this.data ) {
				this.data[i] = this.params[i].getData();
			}
			noCurlyBraceKey( this.data );
			return this.data;
		}
	};
	
	////////// class Set uses UniqueElement, DataForm //////////
	
	function Set(sets, $list, data) {
		this.defineUniq();
		
		var
			that = this;
		
		this.sets = sets;
		this.$cont = $('<li>');
		this.data = data;
		
		this.$body = $('<div>');
		
		this.$cont
			.append( action({type: 'remove', desc: message('set-remove'), fn: function() { that.remove(); }, aClass: 'tde-remove-line'}) )
			.append( this.$body )
			.append( $clear() );
		
		this
			.addLabel('body')
			.newline('body')
			.addInput('body', 'array', 'params', message('set-params'));
		
		$list.append( this.$cont );
	}
	
	Set.prototype = $.extend({}, UniqueElement, DataForm, {
		
		type: 'set',
		
		remove: function() {
			try {
				this.sets.removeElement(this);
				this.$cont.remove();
			} catch(e) {
				alertError( e );
			}
		},
		
		getData: function() {
			this.data.label = this.label.getData();
			return this.data;
		}
	});
	
	Param.type = function(type, currentType) {
		return $('<option>')
			.val(type)
			.text( message('param-type-' + type) )
			.prop('selected', currentType == type);
	};
	
	////////// class Sets //////////
	
	function Sets($list, data) {
		this.$list = $list;
		this.data = data;
		this.sets = [];
		
		var i, that = this;
		
		for(i = 0; i<data.length; i++ ) {
			this.createLi(i);
		}
		
		this.$list.first().after(
			action({type: 'add', fn: function(){that.addItem();return false;}, desc: message('set-add'), aClass: 'tde-add-line'})
		);
	}
	
	Sets.prototype = {
		
		addItem: function() {
			this.data.push({});
			this.createLi(this.data.length-1);
		},
		
		createLi: function(i) {
			this.sets[i] = new Set(this, this.$list, this.data[i]);
		},
		
		removeElement: function(set) {
			var i = this.sets.indexOf(set);
			if( i >= 0 ) {
				arrayRemoveElement(this.data, i);
				arrayRemoveElement(this.sets, i);
			} else {
				throw scriptError('error-set-inexistent', name);
			}
		},
		
		getData: function() {
			for(var i=0; i<this.data.length; i++) {
				this.data[i] = this.sets[i].getData();
			}
			return this.data;
		}
	};
	
	////////// class TemplateData //////////
	
	function TemplateData(data) {
		
		this.data = data;
		this.cleanData();
		
		ui.clear();
		
		ui.$title
			.text( message('title') )
			.append(document.createTextNode(' ('))
			.append($('<a>')
				.attr('href', documentationLink() )
				.text( message('title-documentation') )
			)
			.append(document.createTextNode(')'));
		
		this.dataToUi();
		
		ui.open();
	}
	
	TemplateData.regexpDouble = /<templatedata[^>]*>([\s\S]*)<\/templatedata>/;
	TemplateData.regexpSimple = /<templatedata[^>]*\/>/;
	
	TemplateData.prototype = {
		
		cleanData: function() {
			if( typeof this.data.description == 'undefined' ) this.data.description = '';
			if( $.type(this.data.params) != 'object' ) this.data.params = {};
			if( $.type(this.data.sets) != 'array' ) this.data.sets = [];
		},
		
		dataToUi: function() {
			this.descriptionToUi();
			this.paramsToUi();
			this.setsToUi();
		},
		
		descriptionToUi: function() {
			var $div = $('<div>').attr('id', 'tde-desc-cont');
			
			ui.$body
				.append($('<h3>')
					.text( message('section-description') )
				)
				.append($div);
			
			this.description = new InterfaceTextBlock(this.data.description, message('description'), 'description-placeholder', $div);
		},
		
		paramsToUi: function() {
			var $list = $('<ul>');
			
			ui.$body.append($('<h3>')
					.text( message('section-params') )
				)
				.append($list);
			
			this.params = new Params($list, this.data.params);
		},
		
		setsToUi: function() {
			var $list = $('<ul>');
			
			ui.$body.append($('<h3>')
					.text( message('section-sets') )
				)
				.append($list);
			
			this.sets = new Sets($list, this.data.sets);
		},
		
		getData: function() {
			this.data.description = this.description.getData();
			this.data.params = this.params.getData();
			this.data.sets = this.sets.getData();
			if( this.data.sets.length == 0 ) {
				delete this.data.sets;
			}
			return this.data;
		}
	};
	
	///////// Starting /////////
	
	function write() {
		try {
			var
				newContent = '<templatedata>\n' + JSON.stringify(td.getData(), null, indent) + '\n</templatedata>',
				text = getText();
		
			switch(matchType) {
				case 1:
					text = text.replace(regExpOneTag, newContent);
					break;
				case 2:
					text = text.replace(regExpTwoTags, newContent);
					break;
			}
			
			setText(text);
			ui.close();
		} catch(e) {
			alertError(e);
		}
	}
	
	function startWithData(data) {
		td = new TemplateData(data);
		ui.addCancelButton();
		ui.addButton('apply', write);
	}
	
	function startWithTds(text) {
		var
			T = mw.config.get('wgPageName').replace('_', ' ').split('/'),
			i,
			page = '',
			$preload = $('<div>').attr('id', 'tde-preload'),
			$select = $('<select>').attr('id', 'tde-preload-select');
		
		function $option(val, _label) {
			var label = _label || val;
			return $('<option>')
				.val( val )
				.text( label );
		}
		
		for(i=0; i<T.length; i++) {
			page += (page ? '/' : '') + T[i];
			T[i] = page;
		}
		
		$select.append( $option('', message('preload-none')) );
		
		for(i=T.length-1; i>-1; i-- ) {
			$select.append( $option( T[i] ) );
		}
		
		ui.clear();
		
		ui.$title.text( message('preload-data') );
		
		$preload
			.append( $('<label>')
				.attr('for', 'tde-preload-select')
				.text( message('preload-select') + message('colon') )
			)
			.append($select);
		
		ui.$body.html( $preload );
		
		ui.addCancelButton();
		ui.addButton('preload-load', function(){
			
			var template = selectValue($select);
			
			if( ! template ) {
				startWithData({});
			} else if( template == T[T.length-1] ) { // The current template
				startWithData(
					TemplateDataSkeletonFromText(text)
				);
			} else {
				$preload
					.text( message('preload-running') )
					.addClass('tde-preload-loading');
			
				ui.deleteButtons();
			
				TemplateDataSkeleton(template, startWithData);
			}
			
		});
		
		ui.open();
	}
	
	function startTDE() {
		var
			text = getText(),
			content,
			data = null;
		
		if( regExpTwoTags.test(text) ) {
			matchType = 2;
			content = regExpTwoTags.exec( text )[1];
			indent = getIndent(content);
			
			if( /^\s*(\{\s*\})?\s*$/.test(content) ) {
				startWithTds(text);
			} else {
				try {
					data = JSON.parse( content );
				} catch(e) {
					data = null;
				}
			
				if( data != null ) {
					startWithData(data);
				} else {
					alertError(userError('parse-error'));
				}
			}
			
		} else if( regExpOneTag.test(text) ) {
			matchType = 1;
			indent = defaultIndent;
			startWithTds(text);
		} else {
			matchType = 0;
			alertError(userError('no-data'));
		}
		
	}
	
	////////// Add a link to start TDE //////////
	
	function addTdeLink() {
		var
			$img = $('<img>').attr('alt', message('start-tde')),
			$link = $('<a>')
				.attr({
					href: '#',
					title: message('start-tde')
				})
				.append($img)
				.click(function(){
					startTDE();
					return false;
				});
		
		if( mw.user.options.get('usebetatoolbar') ) {
			$img.attr('src', '//upload.wikimedia.org/wikipedia/commons/d/d8/TemplateData_-_Icon_-_Beta_toolbar.png');
			mw.loader.using('ext.wikiEditor', function(){
				$('#wikiEditor-ui-toolbar .section-main .group-insert').before($('<div>')
					.addClass('group group-tde')
					.append($link)
				);
			});
		} else {
			$img.attr('src', '//upload.wikimedia.org/wikipedia/commons/6/63/TemplateData_-_Icon_-_Old_toolbar.png');
			$('#toolbar').append($link);
		}
	}
	
	addTdeLink();
	
	/*
	$(
		mw.util.addPortletLink('p-tb', '#', 'TemplateData', 'tde-toolbox', message('toolbox-label'))
	).click(function(){
		startTDE();
		return false;
	});
	*/
}

if( [ 2, 10 ].indexOf( mw.config.get('wgNamespaceNumber') ) !== -1 && [ 'edit', 'submit' ].indexOf( mw.config.get('wgAction') ) !== -1 ) {
	mw.loader.load(
		'//tr.wikipedia.org/w/index.php?title=MediaWiki:Gadget-TemplateDataEditor.css&action=raw&ctype=text/css',
		'text/css'
	);
	mw.loader.using('mediawiki.util', function () {
		$(document).ready(TemplateDataEditor);
	});
}

// </syntaxhighlight> {{catégorisation JS|TemplateDataEditor}}