Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/**
 * Ajax-based stub tag manager
 *
 * See [[User:SD0001/StubSorter]] for details and installation instructions.
 *
 */

// <nowiki>
// jshint maxerr: 999

$.when(
	$.ready,
	mw.loader.using(['mediawiki.util', 'mediawiki.api', 'mediawiki.Title', 'jquery.chosen'])
).then(function() {

var API = new mw.Api({
	ajax: { headers: { 'Api-User-Agent': '[[w:User:SD0001/StubSorter.js]]' } }
});

var activate = function(container) {

	// if already present, don't duplicate
	if ($('#stub-sorter-wrapper').length !== 0) {
		return;
	}

	container.prepend(
		$('<div>').attr('id', 'stub-sorter-wrapper').css({
			'max-height': 'max-content',
			'background-color': '#c0ffec',
			'margin-bottom': '10px'
		}).append(
			$('<select>')
				.attr('id', 'stub-sorter-select')
				.attr('multiple', 'true')
				.change(handlePreview),

			$('<div>').attr('id', 'stub-sorter-previewbox').css({
				'background-color': '#cfd8eb' // '#98b685'
				// 'border-bottom': 'solid 0.5px #aaaaaa'
			})
		)

	);

	var $select = $('#stub-sorter-select');

	var selectExistingStubTags = function($html) {
		$html.find('.stub .hlist .nv-view a').each(function(_, e) {
			var template = e.title.slice('Template:'.length);
			$select.append(
				$('<option>').text(template).val(template).attr('selected', 'true')
			);
		});
	};

	if (mw.config.get('wgCurRevisionId') === mw.config.get('wgRevisionId')) {
		// Viewing the current version of the page, no need for api call to get the page html
		selectExistingStubTags($('.mw-parser-output'));
	} else {
		// In edit/history/diff/oldrevision mode, get the page html by api call
		API.parse(new mw.Title(mw.config.get('wgPageName'))).then(function(html) {
			selectExistingStubTags($(html));
			$select.trigger('chosen:updated');
			$select.trigger('click');
			$input.focus();
		});
	}

	$select.chosen({
		search_contains: true,
		placeholder_text_multiple: 'Start typing to add a stub tag...',
		width: '100%',

		// somehow beacuse of the hacks below, the no_results_text shows up
		// when the search results are loading, and not when there are no results
		no_results_text: 'Loading results for'
	});

	var $input = $('#stub_sorter_select_chosen input');

	var menuFrozen = false;
	var searchBy = getPref('searchBy', 'prefix');

	$('#stub_sorter_select_chosen .chosen-choices').after(

		$('<div>').append(

			// Freeze button
			$('<span>').append(
				$('<a>').text('Freeze menu ').click(function() {
					menuFrozen = !menuFrozen;
					if (menuFrozen) {
						$(this).text('Unfreeze menu ');
						$(this).parent().css('font-weight', 'bold');
					} else {
						$(this).text('Freeze menu ');
						$(this).parent().css('font-weight', 'normal');
					}
					$input[0].focus();
					$input.trigger('keyup');
				}).css({
					'padding-right': '100px',
					'padding-left': '5px'
				})
			),

			// Search mode select
			$('<select>').append(
				$('<option>').text('List prefix matches first').val('prefix'),
				$('<option>').text('List intitle matches first').val('intitle'),
				$('<option>').text('Use strict character-match search').val('regex')
			).change(function(e) {
				searchBy = e.target.value;
				$input.trigger('keyup');
			}),

			// help button after the search mode select
			$('<small>').append(
				' (', $('<a>').text('help').attr('href', '/wiki/User:SD0001/StubSorter#Search_modes').attr('target', '_blank'), ')'
			)
		).css({
			'border-bottom': 'solid 0.5px #aaaaaa',
			'border-left': 'solid 0.5px #aaaaaa',
			'border-right': 'solid 0.5px #aaaaaa'
		})

	);

	// Save button
	$('<button>')
		.text('Save').css({
			'float': 'right'
		})
		.attr('id', 'stub-sorter-save')
		.attr('accesskey', 's')
		.click(handleSave)
		.insertAfter($('#stub_sorter_select_chosen .chosen-choices'));

	// hide selected items in dropdown
	mw.util.addCSS(
		'#stub_sorter_select_chosen .chosen-results .result-selected { display: none; }'
	);

	// Focus on the search box as soon as the the sorter menu loads
	// Add placeholder, because chosen's native placeholder doesn't work with a changing menu.
	// Reset the search box width to accomodate the placeholder text
	// Keep resetting whenever the input goes out of focus
	$input
		.focus()
		.attr('placeholder', 'Start typing to add a stub tag...')
		.css('width', '200px')
		.blur(function() {
			$(this).css('width', '100%');
		});

	// also reset it when an option is selected by clicking on it
	// or when clicking on the search box after the $input has become narrow (despite our best efforts...)
	$('.chosen-container').click(function() {
		$input.css('width', '100%');
	});

	// Adapted from [[User:Enterprisey/afch-master.js/submissions.js]]'s category selection menu:
	// Offer dynamic suggestions!
	// Since jquery.chosen doesn't natively support dynamic results,
	// we sneakily inject some dynamic suggestions instead.
	// Consider upgrading to select2 or OOUI to avoid these hacks
	$input.keyup(function(e) {
		var searchStr = $input.val();

		// The worst hack. Because Chosen keeps messing with the
		// width of the text box, keep on resetting it to 100%
		$input.css('width', '100%');
		$input.parent().css('width', '100%');

		// Ignore arrow keys and home/end keys to allow users to navigate through the suggestions or through the search query
		// and don't show results when an empty string is provided
		if ((e.which >= 35 && e.which <= 40) ||
			(menuFrozen && e.which !== undefined) ||
			!searchStr) {
			return;
		}

		// true when fake keyup is produced by the Freeze button
		// in this case, api limit has to be raised to 500
		var extended = e.which === undefined;

		$.when(
			searchBy !== 'regex' ? getStubSearchResults('prefix', searchStr, extended) : undefined,
			searchBy !== 'regex' ? getStubSearchResults('intitle', searchStr, extended) : undefined,
			searchBy === 'regex' ? getStubSearchResults('regex', searchStr, extended) : undefined
		).then(function(stubsPrefix, stubsIntitle, stubsRegex) {

			var stubs;
			switch (searchBy) {
				case 'prefix': stubs = uniqElements(stubsPrefix, stubsIntitle); break;
				case 'intitle': stubs = uniqElements(stubsIntitle, stubsPrefix); break;
				case 'regex': stubs = stubsRegex; break;
			}

			// Reset the text box width again
			$input.css('width', '100%');
			$input.parent().css('width', '100%');

			// If the input has changed since we started searching,
			// don't show outdated results
			if ($input.val() !== searchStr) {
				return;
			}

			// Clear existing suggestions
			$select.children().not(':selected').remove();

			// Now, add the new suggestions
			stubs.forEach(function (stub) {

				// do not add if already selected
				if ($select.val().indexOf(stub) !== -1) {
					return;
				}
				$select.append(
					$('<option>').text(stub).val(stub)
				);
			});

			// We've changed the <select>, now tell Chosen to
			// rebuild the visible list
			$select.trigger('liszt:updated');
			$select.trigger('chosen:updated');
			$input.val(searchStr);
			$input.css('width', '100%');
			$input.parent().css('width', '100%');

		}).catch(function(e) {
			if ($input.val() !== searchStr) {
				return;
			}
			$select.children().not(':selected').remove();
			$select.append(
				$('<option>')
					.text('Error fetching results: ' + e)
					.attr('disabled', 'true')
			);
			$select.trigger('liszt:updated');
			$select.trigger('chosen:updated');
			$input.val(searchStr);
			$input.css('width', '100%');
			$input.parent().css('width', '100%');
		});

	});

};

var getStubSearchResults = function(searchType, searchStr, extended) {
	var query = {
		'action': 'query',
		'list': 'search',
		'srsearch': 'incategory:"Stub message templates" ',
		'srnamespace': '10',
		'srlimit': extended ? '500' : '100',
		'srqiprofile': 'classic',
		'srprop': '',
		'srsort': 'relevance'
	};
	switch (searchType) {
		case 'prefix':
			query.srsearch += 'prefix:"Template:' + searchStr + '"';
			break;
		case 'intitle':
			var searchStrWords = searchStr.split(' ').filter(function(e) {
				return !/^\s*$/.test(e);
			});
			query.srsearch += 'intitle:"' + searchStrWords.join('" intitle:"') + '"';
			break;
		case 'regex':
			query.srsearch += 'intitle:/' + mw.util.escapeRegExp(searchStr) + '/i';
			break;
	}

	return API.get(query).then(function(response) {
		if (response && response.query && response.query.search) {
			return response.query.search.map(function(e) {
				return e.title.slice(9);
			});
		} else {
			return $.Deferred().reject(JSON.stringify(response));
		}
	}, function(e) {
		return $.Deferred().reject(JSON.stringify(e));
	});
};

var handlePreview = function() {

	// Show preview
	var $this = $(this);
	var selectedTags = $this.val();
	if (selectedTags.length) {
		var tagsWikitext = '{{' + selectedTags.join('}}\n{{') + '}}';

		API.parse(tagsWikitext).then(function(parsedhtmldiv) {

			// Do nothing if tag selection has changed since we
			// sent the parse API call, comparing lengths is enough
			if (selectedTags.length !== $this.val().length) {
				return;
			}
			$('#stub-sorter-previewbox').html(parsedhtmldiv);
		});
	} else {
		$('#stub-sorter-previewbox').empty();
	}
	// $input.css('width', '100%');  // doesn't work
};

var createEdit = function(pageText, values) {
	var tagsBefore = (pageText.match(/\{\{[^{ ]*?[sS]tub(?:\|.*?)?\}\}/g) || []).map(function(e) {
		// capitalise first char after {{
		return e[0] + e[1] + e[2].toUpperCase() + e.slice(3);
	});
	var tagsAfter = values.map(function(e) {
		return '{{' + e + '}}';
	});
	
	// Automatically remove {{Stub}} if accidentally left behind
	if (tagsAfter.length > 1) {
		var idx = tagsAfter.indexOf('{{Stub}}');
		if (idx !== -1) {
			tagsAfter.splice(idx, 1);	
		}
	}

	// remove all stub tags
	pageText = pageText
		.replace(/\{\{[^{ ]*[sS]tub(\|.*?)?\}\}\s*/g, '')
		// also remove tags with spaces, but don't try to remove any params
		.replace(/\{\{.*?-[sS]tub\}\}\s*/g, '')
		.trim();

	// add selected stub tags
	pageText += '\n\n\n' + tagsAfter.join('\n'); 	// per [[MOS:LAYOUT]]

	// For producing edit summary
	var summary = '';

	var tagsAdded = tagsAfter.filter(function(e) {
		return tagsBefore.indexOf(e) === -1;
	});
	var tagsRemoved = tagsBefore.filter(function(e) {
		return tagsAfter.indexOf(e) === -1;
	});

	tagsRemoved.forEach(function(e) {
		summary += '–' + e + ', ';
	});
	tagsAdded.forEach(function(e) {
		summary += '+' + e + ', ';
	});
	summary = summary.slice(0, -2); // remove the final ', '

	return {
		text: pageText,
		summary: summary + ' using [[User:SD0001/StubSorter|StubSorter]]',
		nocreate: 1,
		minor: getPref('minor', true),
		watchlist: getPref('watchlist', 'nochange')
	};
};

var handleSave = function submit() {
	$('#stub-sorter-error').remove();
	var $status = $('<div>').text('Fetching page...')
		.attr('id', 'stub-sorter-status')
		.css({
			'float': 'right'
		});
	$(this).replaceWith($status);
	API.edit(mw.config.get('wgPageName'), function(revision) {
		$status.text('Saving page...');
		var pageText = revision.content;
		return createEdit(pageText, $('#stub-sorter-select').val());
	}).then(function() {
		$status.text('Done. Reloading page...');
		setTimeout(function() {
			window.location.href = mw.util.getUrl(mw.config.get('wgPageName'));
		}, 500);
	}).fail(function(e) {
		$status.text('Save failed. Please try again.')
			.attr('id', 'stub-sorter-error')
			.css({
				'color': 'red',
				'font-weight': 'bold',
				'padding-right': '5px'
			});
		console.error(e); // eslint-disable-line no-console
		setTimeout(function() {
			$status.before($('#stub-sorter-save'));
			$('#stub-sorter-save').click(handleSave);
		}, 500);
	});
};

// utility function to get unique elements from 2 arrays
var uniqElements = function(arr1, arr2) {
	var obj = {}; var i;
	for (i = 0; i < arr1.length; i++) {
		obj[arr1[i]] = 0;
	}
	for (i = 0; i < arr2.length; i++) {
		obj[arr2[i]] = 0;
	}
	return Object.keys(obj);
};

// function to obtain a preference option from common.js
var getPref = function(name, defaultVal) {
	if (window['StubSorter_' + name] === undefined) {
		return defaultVal;
	} else {
		return window['StubSorter_' + name];
	}
};

/**
 ********************* SET UP *********************
 */

// auto start the script when navigating to an article from CAT:STUBS
if (mw.config.get('wgPageName') === 'Category:Stubs') {
	$('#mw-pages li a').each(function(_, e) {
		e.href += '?startstubsorter=y';
	});
}

// show only on existing articles, and my sandbox (for testing)
if ((mw.config.get('wgNamespaceNumber') === 0 ||
	mw.config.get('wgPageName') === 'User:SD0001/sandbox') &&
	mw.config.get('wgCurRevisionId') !== 0
) {
	mw.util.addPortletLink(getPref('portlet', 'p-cactions'), '#', 'Stub Sort',
	'ca-stub', 'Add or remove stub tags').addEventListener('click', function(e){
		e.preventDefault();
		activate($('#mw-content-text'));
	});
}

// Enable activation from other scripts
mw.hook('StubSorter_activate').add(activate);
window.StubSorter_create_edit = createEdit;


if (mw.util.getParamValue('startstubsorter')) {
	setTimeout(function() {
		$('#ca-stub').click();
	}, 1000);
}

});

// </nowiki>