User:Rillke/globalMessageDelivery.js

From Wikimedia Commons, the free media repository
Jump to navigation Jump to search
Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
/**
 * WARNING: This script was never finished.
 * See https://meta.wikimedia.org/wiki/MassMessage instead.
 *
 * GlobalMessage delivery
 * JavaScript Application
 * 
 * A dialog containing options, capable sending
 * localized messages within the Wikimedia Empire
 * to predifined addressees
 * 
 *
 * @rev 1 (2013-01-19)
 * @author [[:commons:User:Rillke]], 2013
 * @license GPL v.3
 */
// List the global variables for jsHint-Validation. Please make sure that it passes http://jshint.com/
// Scheme: globalVariable:allowOverwriting[, globalVariable:allowOverwriting][, globalVariable:allowOverwriting]
/*global jQuery:false, mediaWiki:false*/

// Set jsHint-options. You should not set forin or undef to false if your script does not validate.
/*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, curly:false, browser:true*/

( function ( $, mw ) {
"use strict";

// Style defs
// TODO: Credits for icon authors
mw.util.addCSS(
	'li.gmd-doing { padding-left:16px; background:url(\'' + 
		'//upload.wikimedia.org/wikipedia/commons/thumb/4/41/Crystal_Clear_action_loopnone.png/16px-Crystal_Clear_action_loopnone.png' + 
		'\') no-repeat left; font-weight:bold; } ' +
	'li.gmd-done { padding-left:16px; background:url(\'' + 
		'//upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Crystal_Project_success.png/16px-Crystal_Project_success.png' + 
		'\') no-repeat left; } ' +
	'li.gmd-failed { padding-left:16px; background-color: #F2CBCB; background:url(\'' + 
		'//upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Crystal_Project_cancel.png/16px-Crystal_Project_cancel.png' + 
		'\') no-repeat left; } ' +
	'li.gmd-unknown { padding-left:16px; background-color: #F2EECB; background:url(\'' +
		'//upload.wikimedia.org/wikipedia/commons/thumb/a/af/Crystal_Clear_app_error-info.png/16px-Crystal_Clear_app_error-info.png' + 
		'\') no-repeat left; } ' +
	'li.gmd-ratelimited { padding-left:16px; background:url(\'' +
		'//upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Time_Trial.svg/16px-Time_Trial.svg.png' + 
		'\') no-repeat left; } '
);

// A bunch of helper functions
function isNumber(n) {
	return !isNaN(parseInt(n, 10)) && isFinite(n);
}
function firstItem(o) { for (var i in o) { if (o.hasOwnProperty(i)) { return o[i]; } } }
function firstItemName(o) { for (var i in o) { if (o.hasOwnProperty(i)) { return i; } } }
var CORSsupported = false;
var testCORS = function(done) {
	if (CORSsupported) return done();
	doCORSReq({
		action: 'query',
		meta: 'userinfo'
	}, 'www.mediawiki.org', function(data, textStatus, jqXHR) {
		if (!data.query.userinfo.id) {
			CORSsupported = 'CORS supported but you are not globally logged-in. Please accept Cookies from third parties while signing-in.';
		} else {
			CORSsupported = 'OK';
		}
		done(CORSsupported);
	}, function(jqXHR, textStatus, errorThrown) {
		CORSsupported = 'CORS not supported: ' +  textStatus + ' \nError: ' + errorThrown;
		done(CORSsupported);
	});
};
var doCORSReq = function(params, wiki, cb, errCb, method) {
	$.support.cors = true;
	// Format parameter first!
	var newParams = {
		format: 'json',
		origin: document.location.protocol + '//' + document.location.hostname
	};
	params = $.extend(newParams, params);
	$.ajax({
		'url': '//' + wiki + '/w/api.php',
		'data': params,
		'xhrFields': {
			'withCredentials': true
		},
		'type': method || 'GET',
		'success': function(r) {
			cb(r, wiki);
		},
		'error': errCb,
		'dataType': 'json'
	});
};


var gmd;

gmd = {
	install: function() {
		var $p = mw.util.addPortletLink('p-tb', '#globalMessageDelivery', 'Gl. Message Delivery', 't-gmd');
		if (!$p) return gmd.log("Can't install. mw.util.addPortletLink returned ", $p);
		$p = $($p);
		$p.click($.proxy(gmd.launch, gmd));
	},
	$dlg: $('<div>').attr({
		title: "Global Message Delivery - Distribute localized messages to a list of receivers"
	}),
	config: {
		dlg: {
			width: 850,
			height: ($(window).height() > 770 ? 'auto' : $(window).height())
		},
		distroPrefix: 'Rillke/gmd/',
		distroNs: 2,
		localapi: mw.util.wikiScript('api')
	},
	launch: function(e) {
		// prevent jumping to top
		if (e) e.preventDefault();
		
		this.queryAPI({
			action: 'sitematrix'
		}, 'gotSitematrix');
	},
	gotSitematrix: function(r) {
		gmd.sitematrix = {};
		gmd.knownLangCodes = {};
		$.each(r.sitematrix, function (i, sites) {
			if (!isNumber(i)) return;
			$.each(sites.site, function (x, s) {
				var domain = s.url.replace(/^https?\:\/\//, '');
				gmd.sitematrix[domain] = {
					api: s.url.replace('http://', '//') + '/w/api.php',
					dbname: s.dbname,
					domain: domain,
					langcode: sites.code,
					typecode: s.code
				};
			});
			gmd.knownLangCodes[sites.code] = true;
		});
		$.each(r.sitematrix.specials, function (i, s) {
			var domain = s.url.replace(/^https?\:\/\//, '');
			gmd.sitematrix[domain] = {
				api: s.url.replace('http://', document.location.protocol + '//') + '/w/api.php',
				dbname: s.dbname,
				domain: domain,
				specialcode: s.code
			};
		});
		gmd.secureCall("dialog");
	},
	dialog: function() {
		var _this = gmd,
			dlgButtons = {},
			$submitButton,
			running = false,
			abort = false;
		
		var $CORSStatus = $('<span>').text("CORS is required for this tool. The CORS status of you system: ").appendTo(this.$dlg),
			$recips = $('<div>').appendTo(this.$dlg),
			$recipsH = $('<h2>').text("1) Select recipient list").appendTo($recips),
			$recipsSel = $('<select>').appendTo($recips),
			$msg = $('<div>').appendTo(this.$dlg),
			$msgH = $('<h2>').text("2) Enter message location (e.g. User:Rillke/messages)").appendTo($msg),
			$msgLoc = $('<input size="120">').attr({
				'placeholder': "Page containing message which has subpages whose names are the langcodes"
			}).appendTo($msg),
			$settings = $('<div>').appendTo(this.$dlg),
			$settingsH = $('<h2>').text("3) Adjust whom to notify").appendTo($settings),
			$settingsMissingI18nWrap = $('<div>').appendTo($settings),
			$settingsMissingI18nL = $('<label>').text("If no translation is available, ").attr({
				'for': 'gmdSettMissingI18n'
			}).appendTo($settingsMissingI18nWrap),
			$settingsMissingI18n = $('<select id="gmdSettMissingI18n">').appendTo($settingsMissingI18nWrap),
			$settingsDefaultLWrap = $('<div>').appendTo($settings),
			$settingsDefaultLL = $('<label>').text("Default language: ").attr({
				'for': 'gmdSettDefL'
			}).appendTo($settingsDefaultLWrap),
			$settingsDefaultL = $('<select id="gmdSettDefL" disabled="disabled">').appendTo($settingsDefaultLWrap),
			$msgOptions = $('<div>').appendTo(this.$dlg),
			$msgOptionsH = $('<h2>').text("4) Message options").prependTo($msgOptions),
			$msgOptionsEditSummaryWrap = $('<div>').appendTo($msgOptions),
			$msgOptionsEditSummaryL = $('<label>').text("Edit summary: ").attr({
				'for': 'gmdOptEditSummary'
			}).appendTo($msgOptionsEditSummaryWrap),
			$msgOptionsEditSummary = $('<input size="120" maxlength="240" id="gmdOptEditSummary">').attr({
				'placeholder': "Append to edit summary (h2 headings are automatically detected)"
			}).appendTo($msgOptionsEditSummaryWrap),
			$msgOptionsPrependWrap = $('<div>').appendTo($msgOptions),
			$msgOptionsPrependL = $('<label>').text("Prepend text (e.g. a heading)").attr({
				'for': 'gmdOptTxtPrepend'
			}).appendTo($msgOptionsPrependWrap),
			$msgOptionsPrepend = $('<textarea>').css({
				height: '40px',
				width: '99%'
			}).attr({
				'placeholder': "Prepend text",
				'id': 'gmdOptTxtPrepend'
			}).appendTo($msgOptionsPrependWrap),
			$msgOptionsAppendWrap = $('<div>').appendTo($msgOptions),
			$msgOptionsAppendL = $('<label>').text("Append text (e.g. ''[[User:Rillke|Rillke]]''<sup>[[User talk:Rillke|(q?)]]</sup> 17:42, 26 January 2013 (UTC))").attr({
				'for': 'gmdOptTxtAppend'
			}).appendTo($msgOptionsAppendWrap),
			$msgOptionsAppend = $('<textarea>').css({
				height: '40px',
				width: '99%',
				'id': 'gmdOptTxtAppend'
			}).attr({
				'placeholder': "Append text"
			}).appendTo($msgOptionsAppendWrap),
			$confirmSection = $('<div>').css('min-height', '300px').appendTo(this.$dlg),
			$confirmSectionH = $('<h2>').text("5) Get a preview").appendTo($confirmSection),
			$confirmSectionWait = $('<div>').css({ 'max-height': 280, 'overflow': 'auto', 'text-align': 'center', 'font-size': '2em', 'margin-top': '100px' }).text("No preview available. Fill-in all fields, please.").appendTo($confirmSection),
			$confirmSectionList = $('<ul>').css({ 'max-height': 280, 'overflow': 'auto', background: 'white' });
		
		// Fill selects
		$('<option>').attr('value', 'defLang').text("use the default language").appendTo($settingsMissingI18n);
		$('<option>').attr('value', 'dontDistribute').text("do not distribute the message to this location").appendTo($settingsMissingI18n);
		
		var msgXHR,
			lastVal;
		
		var _msgLocationChange = function() {
			var pgn  = $msgLoc.val(), // page name
				pgnc = pgn.replace(/\/$/, '') + '/',
				nsi  = gmd.namspaceInfo(pgnc);

			gmd.currentI18nStore = nsi;
			$confirmSectionList.children().remove();
			$confirmSectionWait.appendTo($confirmSection);
			$confirmSectionWait.text("Fetching message languages...");
			_abortIfRunning();
			

			msgXHR = gmd.queryAPI({
				action: 'query',
				generator: 'allpages',
				gapprefix: nsi.title,
				gapnamespace: nsi.ns,
				gapfilterredir: 'nonredirects',
				gaplimit: 100,
				prop: 'revisions',
				rvprop: 'content'
			}, $.proxy(gmd._gotTranslations, gmd, $settingsDefaultL, $confirmSectionWait, _updateUI), undefined, 'GET', true);
		};
		var _abortIfRunning = function() {
			if (running) {
				gmd.log("Aborting parsing distro list.");
				abort = true;
			}
		};
		var _updateUI = function() {
			_abortIfRunning();
			_parseDistroList(function() {
				gmd.log('_parseDistroList');
				$confirmSectionList.detach().children().remove();
				$confirmSectionWait.appendTo($confirmSection);
				$confirmSectionWait.text("Compiling message list...");
				if (abort) {
					abort = false;
					return;
				}
				running = true;
				
				var $chks = $(),
					i = 0, 
					l = gmd.distroList.length;

				// FIXME: whileAsync is unsupported
				$.whileAsync({
					delay: 2,
					bulk: 100,
					test: function () {
						return (i < l && !abort);
					},
					loop: function () {
						var di = gmd.distroList[i],
							id = 'distroItem' + i;
							
						gmd.log('_addingCheckbox');
						di.$chk = $('<input type="checkbox" checked="checked" id="' + id + '"/>');
						$chks = $chks.add(di.$chk);
						di.$li = $('<li>').append(di.$chk, $('<label>').text(di.lang + ' to ').attr({ 'for': id })
							.append(di.tolink.attr('target', '_blank'))).appendTo($confirmSectionList);
							
						i++;
					},
					end: function () {
						if (abort) {
							$confirmSectionWait.text("Aborted...");
						} else {
							$confirmSectionWait.detach();
							$confirmSectionList.appendTo($confirmSection);
							$chks.checkboxShiftClick();							
						}
						abort = false;
						running = false;
					}
				});
			});
		};
		$settingsDefaultL.change(_updateUI);
		$settingsMissingI18n.change(_updateUI);
		$recipsSel.change(_updateUI);
		
		$settingsMissingI18n.change(function() {
			if ($(this).val() === 'dontDistribute') {
				$settingsDefaultL.attr('disabled', 'disabled');
			} else {
				$settingsDefaultL.removeAttr('disabled');
			}
		});
		
		var to = 0;
		$msgLoc.on('change keyup input', function() {
			// Check whether there was indeed a change
			var newVal = $(this).val();
			if (newVal === lastVal) return;
			lastVal = newVal;
			
			$settingsDefaultL.attr('disabled', 'disabled');
			
			if (msgXHR) msgXHR.abort();
			gmd.i18nStore = {};
			clearTimeout(to);
			to = setTimeout(_msgLocationChange, 1000);
		});
		
		this.queryAPI({
			action: 'query',
			generator: 'allpages',
			gapprefix: this.config.distroPrefix,
			gapnamespace: this.config.distroNs,
			gapfilterredir: 'nonredirects',
			gaplimit: 100,
			prop: 'revisions',
			rvprop: 'content'
		}, $.proxy(this._gotDistributionList, this, $recipsSel), this.sitematrix['commons.wikimedia.org'].api, 'GET', true);
		
		dlgButtons["Start"] = function() {
			_send();
		};
		dlgButtons["Cancel"] = function() {
			$(this).dialog('close');
		};
		
		
		this.$dlg.dialog({
			modal: true,
			closeOnEscape: true,
			position: 'center',
			height: this.config.dlg.height,
			width: Math.min($(window).width(), this.config.dlg.width),
			buttons: dlgButtons,
			close: function () {
				$(this).dialog('destroy');
				$(this).remove();
				$('.tipsy').remove();
				_this.dlgPresent = false;
			},
			open: function () {
				var $dlg = $(this);
				
				$dlg.parents('.ui-dialog').css({
					position:'fixed', 
					top:Math.round(($(window).height() - Math.min($(window).height(), $('.ui-dialog.ui-widget').height())) / 2) + 'px'
				});
				
				_this.$dButtons = $dlg.parent().find('.ui-dialog-buttonpane').find('button');
				$submitButton = _this.$dButtons.eq(0);
				$submitButton.specialButton('proceed')/*.button({ disabled: true })*/;
				_this.$dButtons.eq(1).specialButton('cancel');
				$submitButton.submitOnEnter = function(e) {
					if (13 === e.which && !this.button('option', 'disabled')) this.click();
				};
			}
		});
		testCORS(function(r) {
			$CORSStatus.text($CORSStatus.text() + r);
			if ('OK' === r) $CORSStatus.hide();
		});
		
		var _parseDistroList = function(cb) {
			// First, fetch the rendering for this list 
			// (we can't just use the wikitext because there are parser functions inside)
			var listName = $recipsSel.val(),
				listTitle = gmd.distroLists[listName].title,
				listContent = gmd.distroLists[listName].revisions[0]['*'];
				
			if (!$settingsDefaultL.val() || !gmd.i18nStore || !gmd.i18nStore[$settingsDefaultL.val()]) return alert("No (default) translation available!");
			
			// The current distribution list
			gmd.distroList = [];
			gmd.currentListTitle = listTitle;
			gmd._parseMeta(listTitle, function(r) {
				var $distroList = $($.parseHTML(r.parse.text['*'])),
					defaultLang = $settingsDefaultL.val(),
					useDefault = $settingsMissingI18n.val();
					
				gmd.log("PARSEMETA");
					
				$distroList.find('li').each(function(i, el) {
					var $el = $(el),
						t = $el.text(),
						m = t.match(/(.+)?\s{1,}\[at\]\s*(.+)/);
					if (!m) return;
					
					var title = m[1],
						project = m[2];
					if (!(project in gmd.sitematrix)) project = 'www.' + project;
					
					var to = gmd.sitematrix[project],
						lang = project.match(/^[^.]+/);
					if (!lang || !to) return;
					
					var isAvailable = lang[0] in gmd.i18nStore,
						actualLang = isAvailable ? lang[0] : defaultLang;
						
					if (!isAvailable && (useDefault === 'dontDistribute') && !to.specialcode) return;
					
					//if (!(lang in gmd.knownLangCodes)) return;
					var distroItem = {
						to: gmd.sitematrix[project],
						tolink: $el.find('a'),
						title: title,
						lang: actualLang,
						msg: gmd.i18nStore[actualLang]
					};
					gmd.distroList.push(distroItem);
				});
				gmd.secureCall(cb);
			});
		};
		
		var _sanitizeText = function(t) {
			// TODO: Regexp replace option in UI
            t = t.replace(/<!--.*?-->/, '');
			
			t = [$msgOptionsPrepend.val(), t, $msgOptionsAppend.val()].join('');
			t += "\n\n<small>This message was delivered based on [[:commons:" + gmd.currentListTitle + "]]. Translation fetched from: [[:commons:%listtitle%]] -- ~\~\~\~</small>";
			return t;
		};
		
		var _send = function() {
			_sendRequests();
			_sendRequests();
		};
		var currentMsg = 0;
		var _sendRequests = function() {
			if ("OK" !== CORSsupported) return alert(CORSsupported);

			if (currentMsg >= gmd.distroList.length) {
				//alert("Done!");
				return;
			}
			
			var li = gmd.distroList[currentMsg];
			currentMsg++;
			
			// Skip list item if the current checkbox isn't checked
			if (!li.$chk[0].checked) return _sendRequests();
			
			doCORSReq({
				action: 'tokens'
				}, li.to.domain, function(r) {
					if (!r || !r.tokens) return gmd.log("NOTOKEN for " + li.to.dbname);
					var et = r.tokens.edittoken,
						text = '\n' + _sanitizeText(li.msg.revisions[0]['*']).replace('%listtitle%', li.msg.title),
						heading = text.match(/(?:<h2>|==)(.+)(?:<h2\/>|==)/);
						
					if (heading) heading = $.trim(heading[1]);
					var edit = {
						action: 'edit',
						title: li.title,
						appendtext: text,
						summary: '/* ' + heading + ' */ ' + $msgOptionsEditSummary.val(),
						nocreate: 1,
						redirect: 1,
						token: et
					};
					
					li.$li.attr('class', 'gmd-doing');
					var _success = function(r) {
						// Ratelimit errors often happen, so catch them
						if (r.error && 'ratelimited' === r.error.code) {
							li.$li.attr('class', 'gmd-ratelimt');
							setTimeout(function() {
								_doRequest(edit);
							}, 65000);
						} else if (r.edit && r.edit.result === 'Success') {
							li.$li.attr('class', 'gmd-done');
							_sendRequests();
						} else {
							li.$li.attr('class', 'gmd-unknown');
							$('<span>').text(" Result: " + (r.error ? (r.error.code + r.error.info) : '') + (r.result || '')).appendTo(li.$li);
							_sendRequests();
						}
					};
					var _doRequest = function(params) {
						doCORSReq(params, li.to.domain, _success, function() {
								li.$li.attr('class', 'gmd-failed');
								gmd.log("-----> Edit error for " + li.to.dbname);
								_sendRequests();
							}, 'POST');
					};
					_doRequest(edit);
					
				}, function() {
					_sendRequests();
					gmd.log("NOTOKEN for " + li.to.dbname);
				}, 'GET');
		};
	},
	_parseMeta: function(pg, cb) {
		this.queryAPI({
			action: 'parse',
			page: pg,
			prop: 'text'
		}, cb, this.sitematrix['commons.wikimedia.org'].api, 'GET', true);
	},
	_gotDistributionList: function($select, r) {
		gmd.distroLists = {};
		$.each(r.query.pages, function(id, pg) {
			var t = pg.title.replace(gmd.config.distroPrefix, '/');
			gmd.distroLists[t] = pg;
			$('<option>').text(t).appendTo($select);
		});
	},
	_gotTranslations: function($select, $status, cb, r) {
		var i = 0,
			cl = mw.config.get('wgContentLanguage');
		
		gmd.i18nStore = {};
		
		if (!r.query) {
			$status.text("The specified message location does not contain sub-pages.");
			return;
		}
		$select.children().remove();
		
		$.each(r.query.pages, function(id, pg) {
			var t = pg.title.replace(gmd.currentI18nStore.pagename, '').split('-')[0],
				$opt;
				
			if ( !(t in gmd.knownLangCodes) ) return;
			
			++i;
			gmd.i18nStore[t] = pg;
			$opt = $('<option>').text(t);
			if (t === cl) $opt.attr('selected', 'selected');
			$opt.appendTo($select);
		});
		if (i) {
			$select.removeAttr('disabled');
		} else {
			$select.attr('disabled', 'disabled');
		}
		if ($.isFunction(cb)) cb();
	},
	
	/**
	** Does a MediaWiki API request and passes the result to the supplied callback.
	**/
	queryAPI: function (params, callback, url, method, cache) {
		return mw.libs.commons.api.query(params,
		{
			cache: !!cache,
			url: url,
			method: method,
			cb: function(r) {
				gmd.secureCall(callback, r);
			},
			// r-result, query, text
			errCb: function(t, r, q) {
				gmd.fail(t);
			}
		});
	},
	log: function(args) {
		var a = Array.prototype.slice.call(arguments, 0);
		a.unshift('GMP> ');
		if (window.console && $.isFunction(window.console.log)) window.console.log.apply(window.console, a);
	},
	/**
	** Method to catch errors and report where they occurred
	**/
	secureCall: function (fn) {
		var o = gmd;
		try {
			o.currentTask = arguments[0];
			if ($.isFunction(fn)) {
				if (fn.name) o.log(fn);
				return fn.apply(o, Array.prototype.slice.call(arguments, 1)); // arguments is not of type array so we can't just write arguments.slice
			} else if ('string' === typeof fn) {
				o.log(fn);
				return o[fn].apply(o, Array.prototype.slice.call(arguments, 1)); // arguments is not of type array so we can't just write arguments.slice
			} else {
				o.log('This is not a function!');
			}
		} catch (ex) {
			o.log('failure at ' + fn, ex);
			o.fail(ex);
		}
	},
	fail: function (err, cb) {
	},
	namspaceInfo: function(pgn) {
		var rxNs = pgn.match(/^(\w+)\:.+/),
			r,
			tNs,
			nsId;
			
		if (rxNs && rxNs[1]) {
			tNs = rxNs[1];
			nsId = tNs.replace(/ /g, '_').toLowerCase();
			nsId = mw.config.get('wgNamespaceIds')[nsId];
			if (nsId) {
				rxNs = new RegExp('^' + mw.RegExp.escape(rxNs[1]) + '\\:', 'i');
				r = pgn.replace(rxNs, '');
			} else {
				nsId = undefined;
			}
		}
		return { ns: nsId, title: r, nsn: tNs || '', pagename: pgn };
	}
};

// Expose globally
window.GlobalMessageDelivery = gmd;


// Fire off the rest as soon as the dom is ready
mw.loader.using(['jquery.ui', 'mediawiki.util', 'ext.gadget.libJQuery', 'ext.gadget.libAPI', 'mediawiki.RegExp'], function() {
	$( document ).ready( gmd.install );
});


}( jQuery, mediaWiki ));