MediaWiki:Gadget-catMoveLink.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.
/**
 * catMoveLink
 * UI Gadget for moving category contents
 * 
 *
 * @rev 1 (2014-05-16)
 * @author Rillke  <https://commons.wikimedia.org/wiki/User:Rillke>, 2014
 * @license This software is quadruple licensed. You may use it under the terms of GPL v.3, LGPL v.3, CC-By-SA 3.0, GFDL 1.2
 *
 **/

/*global jQuery:false, mediaWiki:false, alert:false */
(function($, mw) {
	'use strict';

	var messages = {
		'com-cml-title': "$1",
		'com-cml-reason': "Reason and edit summary",
		'com-cml-reason-hint': "Reason with at least {{plural:$1|$1 character|$1 characters}}",
		'com-cml-target': "Target – The new desired category name",
		'com-cml-button-submit': "Run selected action",
		'com-cml-button-cancel': "Cancel",
		'com-cml-first-step': "select action",
		'com-cml-failed-loading-options': "Error retrieving options and translation",
		'com-cml-add-to-talk': "Read the talk page and comment there",
		'com-cml-add-to-talk-info': "Good for a relaxed discussion and to see which thoughts other users shared about the category before.",
		'com-cml-newtalk': "Start a new talk page",
		'com-cml-newtalk-info': "Good for a relaxed discussion.",
		'com-cml-nominate-for-discussion': "Nominate category for discussion",
		'com-cml-nominate-for-discussion-info': "Starts the formal CfD process.",
		'com-cml-add-move-template': "Suggest moving on the category page itself",
		'com-cml-add-move-template-info': "Be specific and make sure you have a good reason. An administrator will finally decide over it."
	};
	mw.messages.set(messages);
	
	var msg = function(/*params*/) {
		var args = Array.prototype.slice.call(arguments, 0);
		args[0] = 'com-cml-' + args[0];
		args[args.length] = 'Category Move Assistant (Community Script)';
		return mw.message.apply(this, args).parse();
	};
	
	var css = [
		'#com-cml-options-table tr:not(:first-child) { cursor: pointer }',
		'.com-cml-input-block { width: 100%; box-sizing: border-box; display: block; }',
		'.com-cml-optioncontainer { margin-top: 1.5em }',
		'.com-cml-sub-nav { font-size: smaller }',
		'.com-cml-sub-nav span, .com-cml-sub-nav a { display: inline-block; overflow: hidden; white-space: nowrap; }',
		'.com-cml-ellipsis-on-overflow { text-overflow: ellipsis; max-width: 90%; } ',
		'.com-cml-optioncontainer-option { padding: 0.4em 0; }',
		'#com-cml-dummy-submit { visibility: hidden; }'
	];
	
	var mwconfig = mw.config.get([
		'wgNamespaceNumber',
		'wgUserGroups',
		'wgGlobalGroups',
		'wgIsProbablyEditable',
		'wgTranslatePageTranslation',
		'wgTitle'
	]);

	var stack,
		$doc = $(document),
		$win = $(window);

	var KEY_SPACE = 32,
		KEY_ENTER = 13,
		KEY_UP = 38,
		KEY_DOWN = 40;

	$.fn.comIsValid = function() {
		var v = true;
		$(this).find('input').each(function() {
			var $input = $(this),
				isRequired = $input.is('[required]'),
				pattern = $input.attr('pattern'),
				isnot = $input.attr('isnot'),
				val = $input.val();
				
			if (isRequired) {
				v = v && ( isRequired && val );
				if (!v) return false;
			}
			if (pattern) {
				v = v && new RegExp(pattern).test(val);
				if (!v) return false;
			}
			if (isnot) {
				v = v && ( val !== isnot );
				if (!v) return false;
			}
		});
		return v;
	};

	/**
	 *  @singleton
	 */
	var moveLink = {
		config: {
			fatalErrorReportURL: 'https://commons.wikimedia.org/wiki/Commons:Administrators%27_noticeboard',
			hookOnSelector: '#ca-move',
			optionsTablePage: 'Commons:Categories/MoveOptions/render'
		},
		jStackOptions: {
			app: 'Category Move Assistant',
			reportPage: 'MediaWiki talk:Gadget-Category Move Assistant.js/errors'
		},
		stack: null,
		install: function() {
			// In category namespace only
			// if ( mwconfig.wgNamespaceNumber !== 14 ) return;

			// For registred users only
			if ( $.inArray( 'user', mwconfig.wgUserGroups ) === -1 ) return;

			// If we are not allowed to edit this page, we can't move it
			if ( !mwconfig.wgIsProbablyEditable ) return;
			
			// If the page is translated by the translate extension, don't allow non TA's to move the category's contents
			if ( mwconfig.wgTranslatePageTranslation && $.inArray( 'translationadmin', mwconfig.wgUserGroups ) === -1 ) return;
			
			var $moveLink = this.$moveLink = $(moveLink.config.hookOnSelector);
			
			if ($moveLink.length) {
				$moveLink.click(function(e) {
					e.preventDefault();
					moveLink.loadInterface($moveLink);
				});
				mw.util.addCSS(css.join('\n'));
			}
		},
		isUserBot: function() {
			return $.inArray( 'bot', mwconfig.wgUserGroups ) !== -1 ||
				$.inArray( 'Global_bot', mwconfig.wgGlobalGroups ) ||
				$.inArray( 'steward', mwconfig.wgGlobalGroups ) !== -1;
		},
		loadInterface: function($moveLink) {
			moveLink.loadReasons();
			mw.loader.using([
				'ext.gadget.libJQuery',
				'ext.gadget.progressDialog',
				'ext.gadget.libCat',
				'ext.gadget.JStack',
				'ext.gadget.jquery.blockUI',
				'jquery.throttle-debounce',
				'jquery.ui',
				'jquery.lengthLimit',
				'jquery.spinner',
				'jquery.ui'
			], moveLink.waitReasons, function() {
				// The last time we're using alert in this script
				// but if components aren't loaded alert is the safest
				// bringing something to the user's attention
				// since alert is modal, never alert on each page load
				alert(
					"An error occurred loading the required components of catMoveLink." + 
					"Please report this issue on " + moveLink.config.fatalErrorReportURL
				);
				$moveLink.off('click').click();
			});
		},
		loadReasons: function() {
			var processResult = function(r) {
				// This is considered safe because the text generated by action=render
				// is created by the MediaWiki parser
				moveLink.$optionsPage = $('<div>' + r + '</div>');
				moveLink.$optionsTable = moveLink.$optionsPage.find('#com-cml-options-table');
				moveLink.$translations = moveLink.$optionsPage.find('#com-cml-translations');
				moveLink.readTranslation(moveLink.$translations);
				moveLink.$reasonLoadStatus.resolve();
			};
		
			// No need to fetch again but make sure we start clean
			if (moveLink.optionsPageRaw) return processResult(moveLink.optionsPageRaw);

			moveLink.$reasonLoadStatus = $.Deferred();
			$.get(mw.util.wikiScript(), $.param({
				'action': 'render',
				'title': moveLink.config.optionsTablePage
			})).done(function(r) {
				r = r.replace('$cat', mwconfig.wgTitle);
				moveLink.optionsPageRaw = r;
				processResult(r);
			}).error(function() {
				moveLink.$reasonLoadStatus.reject();
			});
		},
		readTranslation: function($translations) {
			$.each(messages, function(k, v) {
				// Since we only run this on demand, it should be sufficiently fast
				messages[k] = $translations.find('#' + k).text() || v;
			});
			mw.messages.set(messages);
		},
		waitReasons: function() {
			moveLink.stack = stack = new mw.libs.JStack( moveLink );
			if (!moveLink.$reasonLoadStatus) moveLink.loadReasons();
			moveLink.$reasonLoadStatus
				.done(moveLink.showInterface)
				.fail(function() {
					stack.showErrDlg(new Error(msg('failed-loading-options')), {
						showRetry: false,
						showIgnore: false
					});
				});
		},
		showInterface: function() {
			var $dButtons, $submitButton,
				buttons = {},
				$ui = moveLink.$getUI(),
				canSubmit = false;
			
			buttons[msg('button-submit')] = function() {
				$ui.submit();
			};
			buttons[msg('button-cancel')] = function() {
				$(this).dialog('close');
			};
			$ui.submit(function() {
				if (!canSubmit) return;
				
				$ui.dialog('widget').block({
					message: $.createSpinner({
						size: 'large',
						type: 'block'
					})
				});
				setTimeout(function() {
					$doc.triggerHandler('catMoveLink.submit.submit');

					// Do not close the dialog before obtaining the information required!
					$(this).dialog('close');
				}, 500);
			});
			
			$ui.dialog({
				title: msg('title'),
				buttons: buttons,
				width: Math.min($win.width(), 800),
				modal: true,
				close: function() {
					// Destroy dialog on close
					$(this).remove();
				},
				open: function() {
					var height;
					
					$dButtons = $ui.parent().find('.ui-dialog-buttonpane').find('button');
					$submitButton = $dButtons
						.eq(0)
						.specialButton('proceed')
						.button({
							disabled: true
						});
					$doc.on({
							'catMoveLink.submit.enable': function() {
								$submitButton.button('option', 'disabled', false);
								canSubmit = true;
							},
							'catMoveLink.submit.disable': function() {
								$submitButton.button('option', 'disabled', true);
								canSubmit = false;
							}
						});
				
					$dButtons.eq(1).specialButton('cancel');
					moveLink.$optionsTable.find('tr').eq(1).focus();
					
					height = Math.min($ui.dialog('widget').height(), $win.height());
					$ui.dialog('option', 'height', height);
				}
			});
		},
		table2Select: function($table) {
			var $trs, 
				$changeCbs = $.Callbacks();

			$table
				.find('table')
				.addClass('ui-widget-content');
				
			// Override the method from .prototype (how evil ...)
			$table.change = function(cb) {
				$changeCbs.add(cb);
			};

			$trs = $table.find('tr').not('tr:first').hover(function() {
				$(this).addClass('ui-state-default');
			}, function() {
				$(this).removeClass('ui-state-default');
			}).click(function() {
				var $tr = $(this);
				$trs.removeClass('ui-state-highlight');
				$tr.addClass('ui-state-highlight');
				$tr.focus();
				$table.value = $tr.attr('id');
				$changeCbs.fire($tr, $table.value);
			})
			.focus(function() {
				var $tr = $(this);
				$trs.removeClass('ui-state-default');
				$tr.addClass('ui-state-default');
			})
			.attr('tabindex', 0)
			.keyup(function(e) {
				var $next, $prev,
					$tr = $(this);

				e.preventDefault();
				switch(e.which) {
					case KEY_SPACE:
					case KEY_ENTER:
						$tr.click();
						break;
					case KEY_UP:
						$prev = $tr.prev('tr');
						if ($prev.length === 0 || $prev.find('th').length) $prev = $trs.last();
						$prev.focus();
						break;
					case KEY_DOWN:
						$next = $tr.next('tr');
						if ($next.length === 0) $next = $trs.first();
						$next.focus();
						break;
				}
			});
			return $table;
		},
		tasks: {
			discuss: function() {
			},
			page: function() {
			},
			content: function() {
			},
			redir: function() {
			},
			immediately: function() {
			},
			oAuth: function() {
			},
			CommonsDelinkerAdmin: function() {
			},
			CommonsDelinker: function() {
			}
		},
		uiElements: {
			getBackLink: function($option, cb) {
				var $subNav = $('<div>').addClass('com-cml-sub-nav');
				$('<a>')
					.attr('href', '#back')
					.text(msg('first-step'))
					.click(function(e) {
						e.preventDefault();
						cb();
					})
					.appendTo($subNav);
				$('<span>')
					.text('> ')
					.appendTo($subNav);
				$('<span>')
					.text($option.find('td').first().text())
					.addClass('com-cml-ellipsis-on-overflow')
					.appendTo($subNav);

				return $subNav;
			},
			reasonCounter: 0,
			reasonAndTarget: function() {
				var $rNt = $('<div>').addClass('com-cml-optioncontainer');
				$rNt.$reasonLabel = $('<label>')
					.attr({
						'for': 'com-cml-reason-input-' + this.reasonCounter,
						'class': 'com-cml-input-block'
					})
					.text(msg('reason'))
					.appendTo($rNt);

				$rNt.$reasonInput = $('<input type="text" />')
					.attr({
						'id': 'com-cml-reason-input-' + this.reasonCounter,
						'maxlength': 255,
						'placeholder': msg('reason'),
						'class': 'com-cml-input-block',
						'title': msg('reason-hint', 5),
						'required': 'required',
						'pattern': '.{5,}'
					})
					.byteLimit(255)
					.appendTo($rNt);
				
				$rNt.$targetLabel = $('<label>')
					.attr({
						'for': 'com-cml-target-input-' + this.reasonCounter,
						'class': 'com-cml-input-block'
					})
					.text(msg('target'))
					.appendTo($rNt);

				$rNt.$targetInput = $('<input type="text" />')
					.attr({
						'id': 'com-cml-target-input-' + this.reasonCounter,
						'maxlength': 255,
						'placeholder': msg('target'),
						'class': 'com-cml-input-block',
						'required': 'required',
						'isnot': mwconfig.wgTitle
					})
					.val(mwconfig.wgTitle)
					.byteLimit(255)
					.appendTo($rNt);
				
				this.reasonCounter++;

				return $rNt;
			},
			discuss: function() {
				// Check if there's a talk page
				var makeOptionGroup, validate, radioVal,
					$optionDiv,
					$allDetails = $(),
					$allRadios = $(),
					$reasonAndTarget = moveLink.uiElements.reasonAndTarget(),
					$talkTab = $('#ca-talk'),
					$discussionLink = $('#t-ajaxquickdiscusscat'),
					hasTalk = !$talkTab.hasClass('new') && !$talkTab.find('.new').length;
				
				if (!$discussionLink.length) {
					// Prefetch AQD
					mw.loader.load('ext.gadget.AjaxQuickDelete');
				}
				$optionDiv = $('<div>')
					.addClass('com-cml-optioncontainer')
					.attr('id', 'com-cml-opt-discuss-wrap');
				
				makeOptionGroup = function(val, mwmsg, $content) {
					var $radio,
						$details,
						$wrap = $('<div>')
										.attr('id', 'com-cml-opt-discuss-' + val + '-wrap')
										.addClass('com-cml-optioncontainer-option')
										.appendTo($optionDiv);

					$radio = $('<input name="com-cml-opt-discuss" id="com-cml-radio-opt-' + val + '" value="' + val + '" type="radio" />').appendTo($wrap);
					$allRadios = $allRadios.add($radio);
					
					$('<label for="com-cml-radio-opt-' + val + '"></label>').text( msg(mwmsg) ).appendTo($wrap);

					$details = $('<div>')
						.attr('id', 'com-cml-opt-discuss-' + val + '-details')
						.addClass('com-cml-opt-discuss-details')
						.hide()
						.appendTo($wrap);

					$allDetails = $allDetails.add($details);
					$('<p>').text( msg(mwmsg + '-info') ).appendTo($details);
					$details.append($content);
				};

				// TODO: This could be a guided tour.
				makeOptionGroup('talk', hasTalk ? 'add-to-talk' : 'newtalk');
				makeOptionGroup('nfd', 'nominate-for-discussion');
				makeOptionGroup('tag', 'add-move-template', $reasonAndTarget);

				// Interactivity
				validate = function() {
					if (({ 'talk': 1, 'nfd': 1 })[radioVal]) {
						$doc.triggerHandler('catMoveLink.submit.enable');
					} else {
						if ($reasonAndTarget.comIsValid()) {
							$doc.triggerHandler('catMoveLink.submit.enable');
						} else {
							$doc.triggerHandler('catMoveLink.submit.disable');
						}
					}
				};
				
				$allRadios.change(function() {
					var $radio = $(this);
					// Update variable that is used by other functions
					radioVal = $radio.val();
					
					$allRadios.not(this).nextAll('div.com-cml-opt-discuss-details').stop(true).slideUp();
					$radio.nextAll('div.com-cml-opt-discuss-details').slideDown();
					
					// Validate input
					validate();
				});
				$reasonAndTarget
					.find('input')
					.on('keyup change input', mw.util.debounce( 50, validate ) );
				
				// TODO: Enable buttons
				$optionDiv.getValues = function() {
					return {
						action: $allRadios.find('checked').val(),
						reason: $reasonAndTarget.$reasonInput.val(),
						target: $reasonAndTarget.$targetInput.val()
					};
				};
				return $optionDiv;
			},
			page: function() {
			},
			content: function() {
			},
			redir: function() {
			},
			immediately: function() {
			},
			oAuth: function() {
			},
			CommonsDelinkerAdmin: function() {
			},
			CommonsDelinker: function() {
			}
		},
		$getUI: function() {
			var $ui = $('<form>'),
				$table = moveLink.$optionsTable;
			
			// This allows the form to be actually submitted and validated
			$('<input id="com-cml-dummy-submit" type="submit" value="dummy" style="position:absolute"/>')
				.height(0)
				.width(0)
				.appendTo($ui);

			$table = $ui.$table = moveLink.table2Select($table);
			$table.appendTo($ui);

			$table.change(function($el, val) {
				// and drop the table
				$table.effect('drop', function() {

					// Create the new content
					var $backLink, 
						$newContent = stack.secureCall(moveLink.uiElements[val.replace('com-cml-option-move-', '')]);
					
					if (!$newContent) {
						alert("This feature has not been implemented, yet.");
						$table.show('drop', 'slow');
						return;
					}
					// Obtain the "back-to-options-table-link"
					$backLink = moveLink
						.uiElements
						.getBackLink($el, function() {

							$backLink.remove();
							
							// Hide the new content and show table again
							$newContent.effect('drop', {
									direction: 'right'
								}, function() {
								$table.show('drop', 'slow', function() {
									$el.focus();
								});
								$newContent.remove();
							});
						})
						.appendTo($ui);

					$backLink.find('a').focus();

					// Show the new content
					$newContent.hide().appendTo($ui).show('drop', {
						direction: 'right'
					}, 'slow');
				});
			});

			$ui.submit(function(e) {
				e.preventDefault();
			});
			return $ui;
		}
	};
	
	window.catMoveLink = moveLink;
	$(moveLink.install);
}(jQuery, mediaWiki));