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.
/*
 Signpost Publishing Script (SPS)
 by Evad37
 ------------
 Note 1: This script will only run for users specified in the publishers array.
 ------------
 Note 2: This script assumes users have the following permissions - please request them if you do
 not already have them.
 * Page mover (or administrator) on English Wikipedia
   - This ensures redirects are not left behind when moving pages during publication.
 * Mass message sender (or administrator) on English Wikipedia
   - This allows posting the Signpost on the talkpages of English Wikipedia subscribers.
 * Mass message sender (or administrator) on Meta
   - This allows posting the Signpost on the talkpages of subscribers on other projects.

*/
/* jshint esversion: 6, laxbreak: true, undef: true */
/* globals console, window, document, $, mw, OO, extraJs */
// <nowiki>

/* ========== Dependencies and initial checks =================================================== */
$.when(
	// Resource loader modules
	mw.loader.using([
		'mediawiki.util', 'mediawiki.api', 'ext.gadget.libExtraUtil',
		'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows'
	]),
	// Page ready
	$.ready
).then(function() {

var atNewsroom = mw.config.get('wgPageName').includes('Wikipedia:Wikipedia_Signpost/Newsroom');
if ( !atNewsroom ) {
	return;
}
var publishers = ['Evad37', 'Chris troutman', 'Smallbones'];
var isApprovedUser = ( publishers.indexOf(mw.config.get('wgUserName')) !== -1 );
if ( !isApprovedUser ) {
	return;
}
// Script version and API config options
var scriptVersion = '2.4.1';
var apiConfig = {ajax: {headers: {'Api-User-Agent': 'SignpostPublishingScript/' + scriptVersion + ' ( https://en.wikipedia.org/wiki/User:Evad37/SPS )'} } };

//On first run after page load, clear the cache in window.localStorage
try {
	window.localStorage.setItem('SignpostPubScript-titles', '');
	window.localStorage.setItem('SignpostPubScript-selected-titles', '');
	window.localStorage.setItem('SignpostPubScript-previousIssueDates', '');
	window.localStorage.setItem('SignpostPubScript-info', '');
	window.localStorage.setItem('SignpostPubScript-startAtZero', '');
} catch(e) {}

/* ========== Styles ============================================================================ */
mw.util.addCSS(
	'.SPS-dialog-heading { font-size: 115%; font-weight: bold; text-align: center; margin: -0.2em 0 0.2em; }'+
	'.SPS-dryRun { display: none; font-size: 88%; margin-left: 0.2em; }'+
	'.SPS-dialog-DraggablePanel { margin: 0.5em 0; }'+
	'.SPS-dialog-DraggablePanel .oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header { display: none; }'+
	'.SPS-dialog-item-section { font-weight: bold; }'+
	'.SPS-dialog-item-title { font-size: 92%; color: #333; margin-left: 0.1em; }'+
	'.SPS-dialog-item-blurb { font-size: 85%; color: #333; margin-left: 0.1em; }'+
	'.four ul li.SPS-task-waiting { color: #777; }'+
	'.four ul li.SPS-task-doing { color: #00F; }'+
	'.four ul li.SPS-task-done { color: #0A0; }'+
	'.four ul li.SPS-task-failed { color: #A00; }'+
	'.four ul li.SPS-task-skipped { color: #B0A; }'+
	'.four ul li .SPS-task-status { font-weight: bold; }'+
	'.four ul li .SPS-task-failed .SPS-task-errorMsg { font-weight: bold; }'+
	'.SPS-inlineButton { margin: 0.2em; padding: 0.3em 0.6em; font-size: 0.9em; }'+
	'.no-bold { font-weight: normal; }'
);

/* ========== Utility functions ================================================================= */
/** writeToCache
 * @param {String} key
 * @param {Array|Object} val
 */
var writeToCache = function(key, val) {
	try {
		var stringVal = JSON.stringify(val);
		window.localStorage.setItem('SignpostPubScript-'+key, stringVal);
	} catch(e) {}
};
/** readFromCache
 * @param {String} key
 * @returns {Array|Object|String|null} Cached array or object, or empty string if not yet cached,
 *          or null if there was error.
 */
var readFromCache = function(key) {
	var val;
	try {
		var stringVal = window.localStorage.getItem('SignpostPubScript-'+key);
		if ( stringVal !== '' ) {
			val = JSON.parse(stringVal);
		}
	} catch(e) {
		console.log('[SPS] error reading ' + key + ' from window.localStorage cache:');
		console.log(
			'\t' + e.name + ' message: ' + e.message +
			( e.at ? ' at: ' + e.at : '') +
			( e.text ? ' text: ' + e.text : '')
		);
	}
	return val || null;
};
/** makeLink
 * @param {String} linktarget - Wikipedia page title
 * @param {String|null} linktext - optional text to display instead of the page title
 * @return {jQuery element}
 */
var makeLink = function(linktarget, linktext) {
	if ( linktext === null ) {
		linktext = linktarget;
	}
	return $('<a>').attr({
		'href': 'https://en.wikipedia.org/wiki/' + mw.util.wikiUrlencode(linktarget),
		'target': "_blank"
	}).text(linktext);
};
/** promiseTimeout
 * @param {Number} time - duration of the timeout in miliseconds
 * @returns {Promise} that will be resolved after the specified duration
 */
var promiseTimeout = function(time) {
	var timeout = $.Deferred();
	window.setTimeout(function() { return timeout.resolve(); }, time);
	return timeout;
};
/** reflect
 * @param {Promise|Any} Promise, or a value to treated as the result of a resolved promise
 * @returns {Promise} that always resolves to an object which wraps the values or errors from the
 *          resolved or rejected promise in a 'value' or 'error' array, along with a 'status' of
 *          "resolved" or "rejected"
 */
var reflect = function(promise) {
	var argsArray = function(args) {
		return (args.length === 1 ? [args[0]] : Array.apply(null, args));
	};
	return $.when(promise).then(
		function() { return {'value': argsArray(arguments), status: "resolved" }; },
		function() { return {'error': argsArray(arguments), status: "rejected" }; }
	);
};

/** whenAll
 * Turns an array of promises into a single promise of an array, using $.when.apply
 * @param {Promise[]} promises
 * @returns {Promise<Array>} resolved promises
 */
var whenAll = function(promises) {
	return $.when.apply(null, promises).then(function() {
		return Array.from(arguments);
	});
};

/** getFullUrl
 * @param {String|null} page Page name. Defaults to the value of `mw.config.get('wgPageName')`
 * @param {Object|null} params A mapping of query parameter names to values, e.g. `{action: 'edit'}`
 * @retuns {String} Full url of the page
 */
var getFullUrl = function(page, params) {
	return 'https:' + mw.config.get('wgServer') + mw.util.getUrl( page, params );
};

/** approxPageSize
 * Calculates the approximate size of a page by adding up the size of images,
 * and rounding up to the nearest MB.
 *
 * @param {String} page Name of the page
 * @return {Promise<String>} Size
 */
var approxPageSize = function(page) {
	return $.get( getFullUrl(page, {useskin: 'vector'}) ).then(function(pageHtml) {
		var doc = document.implementation.createHTMLDocument("New Document");
		$(doc.body).append( pageHtml.replace(/<(link|meta|script.*?\>).*?\>/g, '<!-- $1 -->') );

		var imagesSizesPromises = Array.from(doc.images).map(function(image) {
			return $.get(image.src).then(res => res.length*1.09); // .length slightly underestimates file size
		});
		return $.when.apply(null, imagesSizesPromises).then(function() {
			var imagesSizes = Array.from(arguments);
			var total_bytes = (200*1024) + // Non-image resources (scripts, css, etc)
				imagesSizes.reduce((a, b) => a+b);
			var total_Mb = total_bytes / 1024 / 1024;
			var total_Mb_rounded = Math.round(total_Mb*10)/10;
			var total_approx = (total_Mb < 0.1) ?
				"<0.1&nbsp;MB" :
				total_Mb_rounded + "&nbsp;MB";
			return total_approx;
		});
	});
};

var removeHtmlComments = function(wikitext, trim) {
	var newWikitext = wikitext.replace(/<!\-\-(.|\n)*?\-\->/g, '');
	return trim ? newWikitext.trim() : newWikitext;
};

/* ========== Overlay Dialog ==================================================================== */
/* ---------- OverlayDialog class --------------------------------------------------------------- */
var OverlayDialog = function( config ) {
	OverlayDialog.super.call( this, config );
};
OO.inheritClass( OverlayDialog, OO.ui.MessageDialog );
OverlayDialog.static.name = 'overlayDialog';
OverlayDialog.prototype.clearMessageAndSetContent = function(contentHtml) {
	this.$element.find('label.oo-ui-messageDialog-message').after(
		$('<div>').addClass('oo-ui-overlayDialog-content').append(contentHtml)
	).empty();
};
OverlayDialog.prototype.getTeardownProcess = function( data ) {
	this.$element.find('div.oo-ui-overlayDialog-content').remove();
	return OverlayDialog.super.prototype.getTeardownProcess.call( this, data );
};

/* ---------- Window manager -------------------------------------------------------------------- */
/* Factory.
   Makes it easer to use the window manager: Open a window by specifiying the symbolic name of the
   class to use, and configuration options (if any). The factory will automatically crete and add
   windows to the window manager as needed. If there is an old window of the same symbolic name,
   the new version will automatically replace old one.
*/
var ovarlayWindowFactory = new OO.Factory();
ovarlayWindowFactory.register( OverlayDialog );
var ovarlayWindowManager = new OO.ui.WindowManager({ factory: ovarlayWindowFactory });
ovarlayWindowManager.$element.attr('id','SPS-ovarlayWindowManager').addClass('sps-oouiWindowManager').appendTo('body');

/** showOverlayDialog
 * @param {Promise} contentPromise - resolves to {String} of HTML to be displayed
 * @param {String} title - title of overlay dialog
 */
var showOverlayDialog = function(contentWikitext, parsedContentPromise, title, mode) {
	var isWikitextMode = mode && mode.wikitext;
	var contentPromise = ( isWikitextMode ) ?
		$.Deferred().resolve($('<pre>').text(contentWikitext)) : parsedContentPromise;
	var instance = ovarlayWindowManager.openWindow( 'overlayDialog', {
		title: title + ( isWikitextMode ? ' wikitext' : '' ),
		message: 'Loading...',
		size: 'larger',
		actions: [
			{
				action: 'close',
				label: 'Close',
				flags: 'safe'
			},
			{
				action: 'toggle',
				label: 'Show ' + ( isWikitextMode ? 'preview' : 'wikitext' )
			}
		]
	});
	instance.opened.then( function() {
		contentPromise.done(function(contentHtml) {
			ovarlayWindowManager.getCurrentWindow().clearMessageAndSetContent(contentHtml);
			ovarlayWindowManager.getCurrentWindow().updateSize();
		})
		.fail(function(code, jqxhr) {
			ovarlayWindowManager.getCurrentWindow().clearMessageAndSetContent(
				'Preview failed.',
				( code == null ) ? '' : extraJs.makeErrorMsg(code, jqxhr)
			);
		});
	});
	instance.closed.then(function(data) {
		if ( !data || !data.action || data.action !== 'toggle' ) {
			return;
		}
		showOverlayDialog(contentWikitext, parsedContentPromise, title, {'wikitext': !isWikitextMode});
	});
};

/* ========== Fake API class ==================================================================== */
// For dry-run mode. Makes real read request to retrieve content, but logs write request to console.
// Also handles previews of content.
var FakeApi = function(apiConfig){
	this.realApi = new mw.Api(apiConfig);
	this.isFake = true;
};
FakeApi.prototype.abort = function() {
	this.realApi.abort();
	console.log('FakeApi was aborted');
};
FakeApi.prototype.get = function(request) {
	return this.realApi.get(request);
};
FakeApi.prototype.preview = function(label, content, title) {
	var self = this;
	$('#SPS-previewButton-container').append(
		$('<button>').addClass('SPS-inlineButton mw-ui-button').text(label).click(function() {
			var parsedContentPromise = self.realApi.post({
				action: 'parse',
				contentmodel: 'wikitext',
				text: content,
				title: title,
				pst: 1,
				prop: 'text'
			})
			.then(function(result) {
				if ( !result || !result.parse || !result.parse.text || !result.parse.text['*'] ){
					return $.Deferred().reject('Empty result');
				}
				return result.parse.text['*'];
			});
		
			showOverlayDialog(content, parsedContentPromise, label);
		})
	);
};
FakeApi.prototype.postWithEditToken = function(request) {
	console.log(request);

	// For occasional failures, for testing purposes, set the first Boolean value to true
	if ( false && Math.random() > 0.8 ) {
		return $.Deferred().reject('Random failure');
	}

	// Show previews for key tasks
	if ( request.title && request.title === 'Wikipedia:Wikipedia Signpost' ) {
		var previewMainWikitext = request.text
			.replace( // Use today's date
				"{{Wikipedia:Wikipedia Signpost/Issue|1}}",
				new Date().toISOString().slice(0, 10)
			).replace( // Increment issue number. Won't be correct for first issue of the year, but probably good enough for a preview.
				"{{Str right|{{Wikipedia:Wikipedia Signpost/Issue|2}}|10}}",
				"issue {{#expr:1+{{Str right|{{Wikipedia:Wikipedia Signpost/Issue|2}}|16}}}}"
			);
		this.preview('Preview main page', previewMainWikitext, request.title);
	} else if (
		request.action &&
		request.action === 'massmessage' &&
		request.spamlist === 'Global message delivery/Targets/Signpost'
	) {
		var previewMassMsgWikitext = '{{Fake heading|1=' + request.subject + '}}\n' + request.message;
		this.preview('Preview mass message', previewMassMsgWikitext, 'User talk:Example');	
	}

	return promiseTimeout(500).then(function() {
		return {'action': {'result': 'Success'}};
	});
};

/* ========== Pre-publishing tasks / API requests =============================================== */
/** getArticleTitles
 * Find page titles of next edition's articles.
 * @param {Object} api - real or fake API
 * @returns {Promise} of an {Array} of page titles
 */
var getArticleTitles = function(api) {
	return api.get({
		action: 'query',
		generator: 'allpages',
		gapprefix: 'Wikipedia_Signpost/Next_issue/',
		gapnamespace: 4,
		gapfilterredir: 'nonredirects',
		gaplimit: 'max',
		indexpageids: 1
	})
	.then(function(result) {
		return $.map(result.query.pages, function(page) {
			return page.title;
		});
	});
};

/** getPreviousIssueDates
 * Get the previous issue date that each section ran it.
 * @param {Object} api - real or fake API
 * @param {Number} year - the current year
 * @returns {Promise} of an {Object} of 'section':'previous issue date' pairs
 */
var getPreviousIssueDates = function(api, year) {
	return api.get({
		action: 'query',
		list: 'allpages',
		apfrom: 'Wikipedia Signpost/' + (year+1),
		apnamespace: 4,
		apfilterredir: 'nonredirects',
		apminsize: '1500',
		aplimit: '500',
		apdir: 'descending'
	})
	.then(function(result) {
		return result.query.allpages.map(function(page) {
			return page.title;
		})
		.map(function(title) {
			return title.split('/');
		})
		.reduce(function(prevIssueDates, titleParts) {
			if ( titleParts[1] && titleParts[2] && !(prevIssueDates[titleParts[2]]) ) {
				prevIssueDates[titleParts[2]] = titleParts[1];
			}
			return prevIssueDates;
		}, {});
	});
};

/** getPageInfo
 * Get article information for a particular article, to be used for snippets etc. Used by #getInfo
 *
 * @param {Object} page Page information from Api query with prop: 'revisions', rvprop: 'content'
 * @returns {Promise<Object>} Relevant page information in key:value pairs
 */
var getPageInfo = function(page) {
	// Page size approximation (promise)
	var sizePromise = approxPageSize(page.title);

	var wikitext = page.revisions[ 0 ][ '*' ];

	var templates = extraJs.parseTemplates(wikitext);
	// Title and blurb from Signpost draft template
	var draftTemplate = templates.find(t => t.name === 'Signpost draft');
	var title = draftTemplate && draftTemplate.getParam('title') && draftTemplate.getParam('title').value || '';
	var blurb = draftTemplate && draftTemplate.getParam('blurb') && draftTemplate.getParam('blurb').value || '';
	// RSS feed description from the RSS description template,
	// or use title and blurb instead if no rss description is found
	var rssTemplate = templates.find(t => t.name === 'Wikipedia:Wikipedia Signpost/Templates/RSS description');
	var rss = ( rssTemplate && rssTemplate.getParam('1') && rssTemplate.getParam('1').value && removeHtmlComments(rssTemplate.getParam('1').value, true) ) ||
		removeHtmlComments(title, true) + ': ' + removeHtmlComments(blurb, true);

	return sizePromise.then(function(size) {
		return {
			'pageid': page.pageid,
			'section': page.title.slice(40), // slice removes "Wikipedia:Wikipedia Signpost/Next issue/"
			'title': removeHtmlComments(title, true),
			'blurb': removeHtmlComments(blurb, true),
			'rss': removeHtmlComments(rss, true),
			'wikitext': wikitext,
			'templates': templates,
			'size': size
		};
	});
};

/** getInfo
 * Get article information for each section, to be used for snippets etc
 * @param {Object} api - real api or fake api
 * @param {Array} pagetitles
 * @param {Object} prevIssueDates - object with 'section':'previous issue date' pairs
 * @returns Promise of an Array of Objects
 */
var getInfo = function(api, pagetitles, prevIssueDates) {
	return api.get({
		action: 'query',
		titles: pagetitles,
		prop: 'revisions',
		rvprop: 'content',
		indexpageids: 1
	})
	.then(function(result) {
		var infoPromises = $.map(result.query.pages, getPageInfo);
		return whenAll(infoPromises);
	})
	.then(function(infos) {
		return infos.map(function(info) {
			info.prev = prevIssueDates[info.section] || '';
			return info;
		});
	});
};

/* ========== Publishing tasks / API requests =================================================== */
// Step 0:
/** makeIssuePage
 * Creates the main issue page
 * @param {Object} data - configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edit
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *   @param {String} data.script_ad
 *   @param {Array} data.articles
 *   @param {Number} data.firstItemIndex - 0 if there is a "From the editor(s)" section, otherwise 1
 *
 * @returns Promise of a success or failure message
 */
var makeIssuePage = function(data) {
	var toCoverItem = function(article, index) {
		return "{{Wikipedia:Signpost/Template:Cover-item|{{{1}}}|" + (index+data.firstItemIndex) +
		"|" + data.today.iso + "|" + article.section + "|" + article.title + "|" + article.size + "}}\n";
	};
	return data.api.postWithEditToken({
		action: 'edit',
		title: data.path + "/" + data.today.iso,
		text: data.articles.map(toCoverItem).join(''),
		summary: "New edition" + data.script_ad
	});
};

// Step 1:

/** cleanupWikitext
 * Prepare wikitext of an article for publication (remove draft template, add/edit footer template)
 * Used by #prepareArticles
 * @param {Object} article Data from #getInfo
 * @returns {String} wikitext for publication
 */
var cleanupWikitext = function(article) {
	// Replacement wikitext for top noinclude section
	var new_topNoincludeSection = "<noinclude>{{Wikipedia:Wikipedia Signpost/Templates/RSS description|1=" +
		article.rss + "}}{{Wikipedia:Signpost/Template:Signpost-header|||}}</noinclude>";

	// Replacement wikitext for article header template
	var articleHeaderTemplate = article.templates.find( t => t.name === 'Wikipedia:Wikipedia Signpost/Templates/Signpost-article-header-v2' || t.name === 'Wikipedia:Signpost/Template:Signpost-article-start');
	var new_headerWikitext = articleHeaderTemplate && articleHeaderTemplate.wikitext.replace(articleHeaderTemplate.getParam('1').wikitext, '|' + "{{{1|" + article.title + "}}}");

	// Replacement wikitext for article footer template
	var footerTemplate = article.templates.find(t => t.name === 'Wikipedia:Signpost/Template:Signpost-article-comments-end' || t.name === 'Wikipedia:Wikipedia Signpost/Templates/Signpost-article-comments-end');
	var new_footerWikitext = "{{Wikipedia:Signpost/Template:Signpost-article-comments-end||" + article.prev + "|}}";

	// Signpost draft helper template - to be removed
	var helperTemplate = article.templates.find(t => t.name === 'Signpost draft helper');
	var helperPatt = ( helperTemplate )
		? new RegExp('\\n?'+mw.util.escapeRegExp(helperTemplate.wikitext))
		: null;

	var updatedWikitext = article.wikitext
		.replace(helperPatt, '')
		.replace(/<noinclude>(?:.|\n)*?<\/noinclude>/, new_topNoincludeSection)
		.replace(articleHeaderTemplate && articleHeaderTemplate.wikitext, new_headerWikitext);
	if ( footerTemplate ) {
		return updatedWikitext.replace(footerTemplate.wikitext, new_footerWikitext);
	} else {
		return updatedWikitext.trim() + "\n<noinclude>" + new_footerWikitext + "</noinclude>";
	}
};
/** prepareArticles
 * Edit each article to prepare it for publication (remove draft template, add/edit footer template)
 * @param {Object} data - configuration and other data, including
 *   @param {Object} data.api - real api for editing, or fake api for logging proposed edit
 *   @param {String} data.script_ad
 *   @param {Array} data.articles
 * @returns {Promise} resolved if edits were successfull, or rejected with a failure message
 */
var prepareArticles = function(data) {
	var editedArticlesPromises = data.articles.map(function(article) {
		return data.api.postWithEditToken({
			action: 'edit',
			pageid: article.pageid,
			text: cleanupWikitext(article),
			summary: "Preparing for publication" + data.script_ad
		});
	});
	return whenAll(editedArticlesPromises);
};

// Step 2:
var maxMovesPerMinute = 16; // Var placed outside function for reuse in dialog status panel
/**
 * moveArticles
 *
 * Move each article to the new issue subpage
 *
 * @param {Object} data - configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edit
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *   @param {String} data.script_ad
 *   @param {Array} data.articles
 *
 * @returns Promise of a success or failure message
 */
var moveArticles = function(data) {
	var minutesBetweenMoveBatches = 1.1; // The extra 0.1 is a safety factor.
	var millisecondsBetweenMoveBatches = minutesBetweenMoveBatches * 60 * 1000;

	var movedArticlesPromises = data.articles.map(function(article, index) {
		var numberAlreadyMoved = index;
		var sleepMilliseconds = Math.floor(numberAlreadyMoved/maxMovesPerMinute) * millisecondsBetweenMoveBatches;
		return promiseTimeout(sleepMilliseconds).then(function() {
			return data.api.postWithEditToken({
				action: 'move',
				fromid: article.pageid,
				to: data.path + "/" + data.today.iso + "/" + article.section,
				noredirect: 1,
				reason: 'Publishing' + data.script_ad
			});
		});
	});
	return whenAll(movedArticlesPromises);
};

// Step 3:
/**
 * editIssueSubpage
 *
 * Update the page "Wikipedia:Wikipedia Signpost/Issue"
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edit
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *   @param {String} data.script_ad
 *
 * @returns Promise of both the previous date in ISO format, and a success or failure message
 */
var editIssueSubpage = function(data) {
	return data.api.get({
		action: 'query',
		titles: 'Wikipedia:Wikipedia Signpost/Issue',
		prop: 'revisions',
		rvprop: 'content',
		indexpageids: 1
	})
	.then(function(result) {
		var pid = result.query.pageids[0];
		var oldWikitext = result.query.pages[pid].revisions[ 0 ]['*'];
		// Update the YYYY-MM-DD dates
		var oldIsoDates = oldWikitext.match(/(\d{4}-\d{2}-\d{2})/g);
		var wikitext = oldWikitext.replace(oldIsoDates[0], data.today.iso)
			.replace(oldIsoDates[1], oldIsoDates[0]);
		//Store previous edition date for later use
		var previous_iso = oldIsoDates[0];
		//Get the current volume and issue numbers
		var edition, vol, iss;
		var edition_patt = /Volume (\d+), Issue (\d+)/;
		//If the previous edition was last year, increment volume and reset issue number to 1
		if ( parseInt( previous_iso.slice(0,4) ) < data.today.year ) {
			edition = edition_patt.exec(wikitext);
			vol = (parseInt(edition[1])+1).toString();
			iss = "1";
		} else { //increment issue number
			edition = edition_patt.exec(wikitext);
			vol = edition[1];
			iss = (parseInt(edition[2])+1).toString();
		}
		//update volume and issue numbers
		return $.Deferred().resolve(
			wikitext.replace(/Volume (\d+), Issue (\d+)/, "Volume " + vol + ", Issue " + iss ),
			{"previousIssueDate": previous_iso, "vol": vol, "iss": iss}
		);
	})
	.then(function(wikitext, editionInfo) {
		var editPromise = data.api.postWithEditToken({
			action: 'edit',
			title: data.path + "/" + "Issue",
			text: wikitext,
			summary: "Publishing new edition" + data.script_ad
		});
		return $.when(editPromise, editionInfo);
	});
};

// Step 4:
/**
 * editMain
 *
 * Edit the main Signpost page
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edit
 *   @param {String} data.path
 *   @param {String} data.script_ad
 *   @param {Array} data.articles
 *
 * @returns Promise of a success or failure message
 */
var editMain = function(data) {
	var topwikitext = "{{subst:Wikipedia:Wikipedia Signpost/Templates/Main page top}}\n\n";
	var bottomwikitext = "{{subst:Wikipedia:Wikipedia Signpost/Templates/Main page bottom}}";
	var midwikitext = data.articles.map(function(article) {
		return "{{Wikipedia:Signpost/Template:Signpost-snippet|{{Wikipedia:Wikipedia Signpost/Issue|1}}|" +
			article.section + "|" + article.title + "|" + article.blurb + "|" + article.size + "}}\n\n";
	});
	return data.api.postWithEditToken({
		action: 'edit',
		title: data.path,
		text: topwikitext + midwikitext.join('') + bottomwikitext,
		summary: "Publishing new edition" + data.script_ad
	});

};

// Step 5:
/**
 * makeSingle
 *
 * Create the single page edition
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edit
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *   @param {String} data.script_ad
 *
 * @returns Promise of a success or failure message
 */
var makeSingle = function(data) {
	return data.api.postWithEditToken({
		action: 'edit',
		title: data.path + "/Single/" + data.today.iso,
		text: "{{Wikipedia:Wikipedia Signpost/Single|issuedate=" + data.today.iso + "}}",
		summary: "Publishing new single page edition" + data.script_ad
	});
};

// Step 6:
/**
 * makeArchive
 *
 * Create this issue's archive page
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edit
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *   @param {String} data.script_ad
 *   @param {String} data.previousIssueDate -- in ISO format
 *
 * @returns Promise of a success or failure message
 */
var makeArchive = function(data) {
	if ( !data.previousIssueDate ) {
		return $.Deferred().reject('Previous issue date not found');
	}
	return data.api.postWithEditToken({
		action: 'edit',
		title: data.path + "/Archives/" + data.today.iso,
		text: "{{Signpost archive|" + data.previousIssueDate + "|" + data.today.iso + "|}}",
		summary: "Publishing new single page edition" + data.script_ad
	});
};

// Step 7:
/**
 * updatePrevArchive
 *
 * Update the previous issue's archive page with the next edition date
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edit
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *   @param {String} data.script_ad
 *   @param {String} data.previousIssueDate -- in ISO format
 *
 * @returns Promise of a success or failure message
 */
var updatePrevArchive = function(data) {
	if ( !data.previousIssueDate ) {
		return $.Deferred().reject('Previous issue date not found');
	}
	return data.api.get({
		action: 'query',
		titles: data.path + "/Archives/" + data.previousIssueDate,
		prop: 'revisions',
		rvprop: 'content',
		indexpageids: 1
	})
	.then(function(result) {
		var pid = result.query.pageids[0];
		var wikitext = result.query.pages[pid].revisions[ 0 ]['*'];
		return wikitext.replace(/\|?\s*}}/, "|" + data.today.iso + "}}");
	})
	.then(function(wikitext) {
		return data.api.postWithEditToken({
			action: 'edit',
			title: data.path + "/Archives/" + data.previousIssueDate,
			text: wikitext,
			summary: "Add next edition date" + data.script_ad
		});
	});
};

// Step 8:
/**
 * purgePages
 *
 * Purge pages to ensure that latest versions are shown, following all the edits and page moves
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for purging, or fake api for logging proposed purge
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *   @param {Array} data.articles
 *
 * @returns Promise of a success or failure message
 */
var purgePages = function(data) {
	var purgetitles = data.articles.map(function(article) {
		return data.path + "/" + data.today.iso + "/" + article.section;
	})
	.concat([
		"Wikipedia:Wikipedia Signpost/Issue",
		"Wikipedia:Wikipedia Signpost",
		"Wikipedia:Wikipedia Signpost/Single",
		data.path + "/Archives/" + data.today.iso,
		"Wikipedia:Wikipedia Signpost/" + data.today.iso
	]);

	return data.api.postWithEditToken({
		action: 'purge',
		titles: purgetitles
	});
};

// Step 9:
/**
 * massmsg
 *
 * Mass-message enwiki subscribers
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edits
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *     @param {String} data.today.dmy
 *   @param {String} data.script_page
 *   @param {String} data.vol -- Volume number of current issue
 *   @param {String} data.iss -- Issue number of current issue
 *
 * @returns Promise of a success or failure message
 */
var massmsg = function(data) {
	var vol = data.vol || '';
	var iss = data.iss || '';
	var msg_spamlist = data.path + "/Tools/Spamlist";
	var msg_content = '<div lang="en" dir="ltr" class="mw-content-ltr"><div style="-moz-column-count:2; -webkit-column-count:2; column-count:2;"> '+
	'{{Wikipedia:Wikipedia Signpost/' + data.today.iso + '}} </div><!--Volume ' + vol + ', Issue ' + iss + '--> '+
	'<div class="hlist" style="margin-top:10px; font-size:90%; padding-left:5px; font-family:Georgia, Palatino, Palatino Linotype, Times, Times New Roman, serif;"> '+
	'* \'\'\'[[Wikipedia:Wikipedia Signpost|Read this Signpost in full]]\'\'\' * [[Wikipedia:Wikipedia Signpost/Single/' + data.today.iso + '|Single-page]] * '+
	'[[Wikipedia:Wikipedia Signpost/Subscribe|Unsubscribe]] * [[User:MediaWiki message delivery|MediaWiki message delivery]] ([[User talk:MediaWiki message delivery|talk]]) ~~~~~ ' +
	'<!-- Sent via script ([[' + data.script_page + ']]) --></div></div>';
	var msg_subject = "''The Signpost'': " + data.today.dmy;

	return data.api.postWithEditToken({
		action: 'massmessage',
		spamlist: msg_spamlist,
		subject: msg_subject,
		message: msg_content
	});
};

// Step 10:
/**
 * gloablmassmsg
 *
 * Mass-message global subscribers
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.metaApi -- real (foreign) api for editing, or fake api for logging proposed edits
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *     @param {String} data.today.dmy
 *   @param {String} data.script_page
 *   @param {Array} data.articles
 *
 * @returns Promise of a success or failure message
 */
var globalmassmsg = function(data) {
	var msg_spamlist = "Global message delivery/Targets/Signpost";
	var msg_subject = "''The Signpost'': " + data.today.dmy;
	var msg_top = '<div lang="en" dir="ltr" class="mw-content-ltr">'+
	'<div style="margin-top:10px; font-size:90%; padding-left:5px; font-family:Georgia, Palatino, Palatino Linotype, Times, Times New Roman, serif;">'+
	'[[File:WikipediaSignpostIcon.svg|40px|right]] \'\'News, reports and features from the English Wikipedia\'s weekly journal about Wikipedia and Wikimedia\'\'</div>\n'+
	'<div style="-moz-column-count:2; -webkit-column-count:2; column-count:2;">\n';
	var msg_mid = data.articles.reduce(function(midtext, article) {
		return midtext + "* " + article.section + ": [[w:en:" + data.path + "/" + data.today.iso + "/" + article.section + "|" + article.title + "]]\n\n";
	}, '');
	var msg_bottom = '</div>\n'+
	'<div style="margin-top:10px; font-size:90%; padding-left:5px; font-family:Georgia, Palatino, Palatino Linotype, Times, Times New Roman, serif;">'+
	'\'\'\'[[w:en:Wikipedia:Wikipedia Signpost|Read this Signpost in full]]\'\'\' · [[w:en:Wikipedia:Signpost/Single|Single-page]] · '+
	'[[m:Global message delivery/Targets/Signpost|Unsubscribe]] · [[m:Global message delivery|Global message delivery]] ~~~~~\n'+
	'<!-- Sent via script ([[w:en:' + data.script_page + ']]) --></div></div>';
	return data.metaApi.postWithEditToken({
		action: 'massmessage',
		assert: 'user',
		spamlist: msg_spamlist,
		subject: msg_subject,
		message: msg_top + msg_mid + msg_bottom
	});
};

// Step 11:
/**
 * updateYearArchive
 *
 * Update the current year's archive overview page
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edits
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *     @param {String} data.today.dmy
 *     @param {String} data.today.month
 *     @param {String|Number} data.today.year
 *   @param {String} data.script_ad
 *   @param {String} data.vol -- Volume number of current issue
 *   @param {String} data.iss -- Issue number of current issue
 *
 * @returns Promise of a success or failure message
 */
var updateYearArchive = function(data) {
	if ( !data.vol || !data.iss ) {
		var notFound = ( !data.vol ? 'Volume number ' : '') + ( !data.vol && !data.iss ? 'and ' : '' ) + ( !data.iss ? 'Issue number ' : '');
		return $.Deferred().reject(notFound + 'not found');
	}
	return data.api.get({
		action: 'query',
		titles: data.path + "/Archives/" + data.today.year,
		prop: 'revisions',
		rvprop: 'content',
		indexpageids: 1
	})
	.then(function(result) {
		// Zero-padded issue number
		var padded_iss = ( data.iss.length === 1 ) ? "0" + data.iss : data.iss;
	
		var newContent = "===[[Wikipedia:Wikipedia Signpost/Archives/" + data.today.iso +
			"|Volume " + data.vol + ", Issue " + padded_iss + "]], " + data.today.dmy +
			"===\n{{Wikipedia:Wikipedia Signpost/" + data.today.iso + "}}\n\n";

		var emptyPageStartText = "{{Wikipedia:Wikipedia Signpost/Archives/Years|year=" +
			data.today.year +	"}}\n\n<br />\n{{Template:TOCMonths}}\n\n" +
			"{{Wikipedia:Signpost/Template:Signpost-footer}}\n" +
			"[[Category:Wikipedia Signpost archives|" +	data.today.year +
			"]]\n[[Category:Wikipedia Signpost archives " +	data.today.year + "| ]]";
		var pid = result.query.pageids[0];
		var pageDoesNotExist = ( pid < 0 );
		var wikitext = ( pageDoesNotExist ) ? emptyPageStartText : result.query.pages[pid].revisions[ 0 ]['*'];
	
		var needsMonthHeading = ( wikitext.indexOf(data.today.month) === -1 );
		var newContentHeading = ( needsMonthHeading ) ? "== " + data.today.month + " ==\n" : '';
	
		var insertionPoint = ""; // a falsey value
		if ( wikitext.indexOf("{{Wikipedia:Signpost/Template:Signpost-footer}}") !== -1 ) {
			insertionPoint = "{{Wikipedia:Signpost/Template:Signpost-footer}}";
		} else if ( wikitext.indexOf('[[Category:') !== -1 ) {
			// footer not found, insert new wikitext above categories
			insertionPoint = "[[Category:";
		}
		if ( !insertionPoint ) {
			return wikitext.trim() + newContentHeading + newContent.trim();
		} else {
			return wikitext.replace(insertionPoint, newContentHeading + newContent + insertionPoint);
		}
	})
	.then(function(wikitext) {
		return data.api.postWithEditToken({
			action: 'edit',
			title: data.path + "/Archives/" + data.today.year,
			text: wikitext,
			summary: "Add next edition" + data.script_ad
		});
	});
};

// Step 12:
/**
 * createArchiveCats
 *
 * Create archive categories for current month and year, if they don't already exist
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edits
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *     @param {String|Number} data.today.year
 *   @param {String} data.script_ad
 *
 * @returns Promise of a success or failure message
 */
var createArchiveCats = function(data) {
	// Ignore the articleexists error
	var promiseWithoutExistsError = function(promise) {
		return promise.then(
			function(v) { return v; },
			function(c, jqxhr) {
				return ( c === 'articleexists' ) ? c : $.Deferred().reject(c, jqxhr);
			}
		);
	};

	var month_cat_title = "Category:Wikipedia Signpost archives " + data.today.iso.slice(0, 7);
	var month_cat_text = "[[Category:Wikipedia Signpost archives " + data.today.year +
	"|" + data.today.iso.slice(5, 7) + "]]";

	var year_cat_title = "Category:Wikipedia Signpost archives " + data.today.year;
	var year_cat_text = "{{Wikipedia:Wikipedia Signpost/Archives/Years}}\n\n" +
	"[[Category:Wikipedia Signpost archives|" + data.today.year + "]]";

	var monthCatPrmoise = promiseWithoutExistsError(
		data.api.postWithEditToken({
			action: 'edit',
			title: month_cat_title,
			text: month_cat_text,
			summary: "Create" + data.script_ad,
			createonly: 1
		})
	);

	var yearCatPromise = promiseWithoutExistsError(
		data.api.postWithEditToken({
			action: 'edit',
			title: year_cat_title,
			text: year_cat_text,
			summary: "Create" + data.script_ad,
			createonly: 1
		})
	);

	return $.when(monthCatPrmoise, yearCatPromise);
};

// Step 13:
/**
 * updateOldNextLinks
 *
 * Update previous issue's "next" links
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edits
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *   @param {String} data.script_ad
 *   @param {Array} data.articles
 *   @param {Object} data.previousIssueDates
 *
 * @returns Promise of a success or failure message
 */
var updateOldNextLinks = function(data) {
	if ( !data.previousIssueDates ) {
		return $.Deferred().reject('Previous issues not found');
	}
	var prevtitles = data.articles.map(function(article) {
		if ( !data.previousIssueDates[article.section] ) {
			return '';
		}
		return data.path + "/" + data.previousIssueDates[article.section] + "/" + article.section;
	})
	.filter(function(v) {
		return v !== '';
	});

	if ( prevtitles.length === 0 ) {
		return $.Deferred().reject('Previous issues not found');
	}

	return data.api.get({
		action: 'query',
		titles: prevtitles,
		prop: 'revisions',
		rvprop: 'content',
		indexpageids: 1
	})
	.then(function(result) {
		var pageEditPromises = $.map(result.query.pages, function(page) {
			var oldwikitext = page.revisions[ 0 ][ '*' ];
			var wikitext = oldwikitext.replace(
				/({{Wikipedia:Signpost\/Template:Signpost-article-comments-end\|\|\d{4}-\d{2}-\d{2}\|)}}/,
				"$1" + data.today.iso + "}}"
			);
			return data.api.postWithEditToken({
				action: 'edit',
				title: page.title,
				text: wikitext,
				summary: "Add next edition" + data.script_ad
			});
		});
		return whenAll(pageEditPromises);
	});
};

// Step 14:
/**
 * requestWatchlistNotification
 *
 * Request a new watchlist notification
 *
 * @param {Object} data -- configuration and other data, including
 *   @param {Object} data.api -- real api for editing, or fake api for logging proposed edits
 *   @param {String} data.path
 *   @param {Object} data.today
 *     @param {String} data.today.iso
 *     @param {String} data.today.month
 *   @param {String} data.script_ad
 *
 * @returns Promise of a success or failure message
 */
var requestWatchlistNotification = function(data) {
	return data.api.postWithEditToken({
		action: 'edit',
		title: 'MediaWiki talk:Watchlist-messages',
		section: 'new',
		sectiontitle: data.today.month + ' Signpost notice',
		text: "{{sudo}}\nThe [[" + data.path + "/Archives/" + data.today.iso + "|" + data.today.month + " edition]] is now out. ~~~~",
		summary: "Requesting a notice for this month's Signpost edition" + data.script_ad
	});
};

/* ========== Main Dialog ======================================================================= */
/* ---------- DraggableGroupStack class---------------------------------------------------------- */
var DraggableGroupStack = function OoUiDraggableGroupStack( config ) {
	config = config || {};
	config.draggable = true;
	config.expanded = false;
	config.padded = true;
	config.framed = true;

	// Parent constructor
	DraggableGroupStack.parent.call( this, config );

	config.$group = this.$element;

	// Mixin constructor
	OO.ui.mixin.DraggableGroupElement.call( this, config );
};
OO.inheritClass( DraggableGroupStack, OO.ui.StackLayout );
OO.mixinClass( DraggableGroupStack, OO.ui.mixin.DraggableGroupElement );
DraggableGroupStack.prototype.removeItems = function() { return this; };
//DraggableGroupStack.prototype.addItems = OO.ui.StackLayout.prototype.addItems;

/* ---------- DraggablePanel class -------------------------------------------------------------- */
var DraggablePanel = function OoUiDraggablePanel( config ) {
	config = config || {};
	if ( config.classes && $.isArray(config.classes) ) {
		config.classes.push('SPS-dialog-DraggablePanel');
	} else {
		config.classes = ['SPS-dialog-DraggablePanel'];
	}

	// Parent constructor
	DraggablePanel.parent.call( this, config );

	// Mixin constructor
	OO.ui.mixin.DraggableElement.call( this, config );

	this.$element
		.on( 'click', this.select.bind( this ) )
		.on( 'keydown', this.onKeyDown.bind( this ) )
		// Prevent propagation of mousedown
		.on( 'mousedown', function( e ) { e.stopPropagation(); });

	// Initialization
	/*
	this.$element
		.append( this.$label, this.closeButton.$element );
	*/
};
OO.inheritClass( DraggablePanel, OO.ui.PanelLayout );
OO.mixinClass( DraggablePanel, OO.ui.mixin.DraggableElement );
/**
 * Handle a keydown event on the widget
 *
 * @fires navigate
 * @param {jQuery.Event} e Key down event
 * @return {boolean|undefined} false to stop the operation
 */
DraggablePanel.prototype.onKeyDown = function( e ) {
	var movement;

	if ( e.keyCode === OO.ui.Keys.ENTER ) {
		this.select();
		return false;
	} else if (
		e.keyCode === OO.ui.Keys.LEFT ||
		e.keyCode === OO.ui.Keys.RIGHT ||
		e.keyCode === OO.ui.Keys.UP ||
		e.keyCode === OO.ui.Keys.DOWN
	) {
		if ( OO.ui.Element.static.getDir( this.$element ) === 'rtl' ) {
			movement = {
				left: 'forwards',
				right: 'backwards'
			};
		} else {
			movement = {
				left: 'backwards',
				right: 'forwards'
			};
		}

		this.emit(
			'navigate',
			e.keyCode === OO.ui.Keys.LEFT ?
				movement.left : movement.right
		);
		return false;
	} else if (
		e.keyCode === OO.ui.Keys.UP ||
		e.keyCode === OO.ui.Keys.DOWN
	) {
		this.emit(
			'navigate',
			e.keyCode === OO.ui.Keys.UP ?
				'backwards' : 'forwards'
		);
		return false;
	}
};
/**
 * Select this item
 *
 * @fires select
 */
DraggablePanel.prototype.select = function() {
	this.emit( 'select' );
};

/* ---------- MainDialog class ------------------------------------------------------------------ */
var MainDialog = function OoUiMainDialog( config ) {
	MainDialog.super.call( this, config );
};
OO.inheritClass( MainDialog, OO.ui.ProcessDialog );
MainDialog.static.name = 'mainDialog';
/* ~~~~~~~~~~ Available actions (buttons) ~~~~~~~~~~ */
MainDialog.static.actions = [
	{ action: 'close', modes: ['welcome', 'choose', 'sort'], label: 'Cancel', flags: 'safe' },
	{ action: 'start', modes: 'welcome', label: 'Start', flags: ['primary', 'progressive'] },
	{ action: 'dryrun', modes: 'welcome', label: 'Dry-run only', flags: 'progressive' },
	{ action: 'back-to-welcome', modes: 'choose', label: 'Back', flags: 'safe' },
	{ action: 'next-to-sort', modes: 'choose', label: 'Next', flags: ['primary', 'progressive'] },
	{ action: 'back-to-choose', modes: 'sort', label: 'Back', flags: 'safe' },
	{ action: 'next-to-status', modes: 'sort', label: 'Publish', flags: ['primary', 'progressive'] },
	{ action: 'next-to-finished', modes: 'status', label: 'Continue', flags: ['primary', 'progressive'], disabled: true },
	{ action: 'abort', modes: 'status', label: 'Abort', flags: ['safe', 'destructive']},
	{ action: 'close', modes: ['finished', 'aborted'], label: 'Close', flags: ['primary', 'progressive'] },
	{ action: 'back-to-status', modes: 'finished', label: 'Back', flags: 'safe' }
];
// Get dialog height.
MainDialog.prototype.getBodyHeight = function() {
	var headHeight = this.$head.outerHeight(true);
	var footHeight = this.$foot.outerHeight(true);
	var currentPanelContentsHeight = $(this.stackLayout.currentItem.$element).children().outerHeight(true);
	return headHeight + footHeight + currentPanelContentsHeight;
};
/* ~~~~~~~~~~ Initiliase the content and layouts ~~~~~~~~~~ */
MainDialog.prototype.initialize = function() {
	MainDialog.super.prototype.initialize.apply(this, arguments);
	var dialog = this;
	this.panels = {
		welcome: new OO.ui.PanelLayout({
			$content: $('<div>').append([
				$('<p>').addClass('SPS-dialog-heading').text('Welcome to the Signpost Publishing Script'),
				$('<p>').text('Make sure each article has a fully completed {{Signpost draft}} template, and is a subpage of Wikipedia:Wikipedia_Signpost/Next_issue/'),
				$('<p>').text('"Dry-run only" mode simulates the publising process without making any actual changes – actions are logged to the browser\'s javascript console instead; previews are also available.')
			]),
			classes: [ 'one' ],
			padded: true,
			scrollable: true,
			expanded: true
		}),
		choose: new OO.ui.PanelLayout({
			$content: $('<div>').append([
				$('<p>').addClass('SPS-dialog-heading').append([
					'Select articles',
					$('<span>').addClass('SPS-dryRun').text('[dry-run only]')
				])
			]),
			classes: [ 'two' ],
			padded: true,
			scrollable: true,
			expanded: true
		}),
		sort: new OO.ui.PanelLayout({
			$content: $('<div>').append([
				$('<p>').addClass('SPS-dialog-heading').append([
					'Check order, titles, and blurbs',
					$('<span>').addClass('SPS-dryRun').text('[dry-run only]')
				])
			]),
			classes: [ 'three' ],
			padded: true,
			scrollable: true,
			expanded: true
		}),
		status: new OO.ui.PanelLayout({
			$content: $('<div>').append([
				$('<p>').addClass('SPS-dialog-heading').append([
					'Status',
					$('<span>').addClass('SPS-dryRun').text('[dry-run only]')
				])
			]),
			classes: [ 'four' ],
			padded: true,
			scrollable: true,
			expanded: true
		}),
		finished: new OO.ui.PanelLayout({
			$content: $('<div>').append([
				$('<p>').addClass('SPS-dialog-heading').append([
					'Finished!',
					$('<span>').addClass('SPS-dryRun').text('[dry-run only]')
				]),
				$('<div>').attr('id', 'SPS-previewButton-container'),
				$('<p>').text('Remember to announce the new issue on the mailing list, Twitter, and Facebook.')
			]),
			classes: [ 'five' ],
			padded: true,
			scrollable: true,
			expanded: true
		}),
		aborted: new OO.ui.PanelLayout({
			$content: $('<div>').append([
				$('<p>').addClass('SPS-dialog-heading').append([
					'Aborted',
					$('<span>').addClass('SPS-dryRun').text('[dry-run only]')
				]),
				$('<p>').append([
					'Follow the manual steps at ',
					makeLink('Wikipedia:Wikipedia Signpost/Newsroom/Resources', 'the resources page'),
					' to complete publiction.'
				])
			]),
			classes: [ 'six' ],
			padded: true,
			scrollable: true,
			expanded: true
		})
	};
	this.stackLayout = new OO.ui.StackLayout({
		items: $.map(dialog.panels, function(panel) { return panel; })
	});
	this.$body.append(this.stackLayout.$element);
};
/* ~~~~~~~~~~ Set panels contents ~~~~~~~~~~ */
/** @param {Array} titles **/
MainDialog.prototype.setChoosePanelContent = function(titles) {
	var dialog = this;
	var cachedSelectedTitles = readFromCache('selected-titles');
	var titleCheckboxes = titles.map(function(title) {
		return new OO.ui.CheckboxMultioptionWidget({
		data: title,
		selected: ( cachedSelectedTitles ) ? cachedSelectedTitles.indexOf(title) !== -1 : true,
		label: $('<span>')
			.append([
				title,
				' ',
				makeLink(title,'→')
			])
		});
	});
	this.widgets.titlesMultiselect = new OO.ui.CheckboxMultiselectWidget({
		items: titleCheckboxes,
		id: 'SPS-dialog-titlesMultiselect'
	});
	this.getPanelElement('choose')
	.find('#SPS-dialog-titlesMultiselect').remove().end()
	.append( dialog.widgets.titlesMultiselect.$element );
};
/** @param {Array} selectedTitles, @param {Boolean} startAtZeroValue **/
MainDialog.prototype.setSortPanelContent = function(articlesInfo, startAtZeroValue) {
	var makeItemContent = function(section, title, blurb) {
		return new OO.ui.ActionFieldLayout(
			new OO.ui.LabelWidget({
				label: $('<div>').append([
					$('<span>').addClass('SPS-dialog-item-section')
					.text(section+' ')
					.append(
						makeLink('Wikipedia:Wikipedia_Signpost/Next_issue/'+section, '→')
					),
					$('<div>').addClass('SPS-dialog-item-title').text(title),
					$('<div>').addClass('SPS-dialog-item-blurb').text(blurb)
				])
			}),
			new OO.ui.IconWidget({
				icon: 'draggable',
				title: 'Drag to reposition'
			})
		).$element;
	};
	var articleItems = articlesInfo.map(function(info) {
		return new DraggablePanel({
			expanded: false,
			framed: true,
			padded: false,
			data: info,
			$content: makeItemContent(info.section, info.title, info.blurb)
		});
	});
	this.widgets.sortOrderDraggabelGroup = new DraggableGroupStack({
		continuous: true,
		orientation: 'vertical',
		id: 'SPS-dialog-sortOrderDraggabelGroup',
		items: articleItems
	});
	$(this.widgets.sortOrderDraggabelGroup.$element).append(
		articleItems.map(function(item) { return item.$element; })
	);

	this.widgets.startAtZeroCheckbox = new OO.ui.CheckboxMultioptionWidget({
		selected: startAtZeroValue,
		label: 'Use announcement ("from the editors") formatting for first item',
		id: 'SPS-dialog-startAtZeroCheckbox'
	});

	this.getPanelElement('sort')
		.find('#SPS-dialog-sortOrderDraggabelGroup').remove().end()
		.find('#SPS-dialog-startAtZeroCheckbox').remove().end()
		.append([
			this.widgets.sortOrderDraggabelGroup.$element,
			this.widgets.startAtZeroCheckbox.$element,
		]);
};
MainDialog.prototype.setStatusPanelContent = function() {
	var moveDelayNotice = ( this.data.articles.length > maxMovesPerMinute ) ?
		 '&#32;(Note: an approximately ' + Math.floor(this.data.articles.length / maxMovesPerMinute) + ' minute long delay is required due to technical limitations)&#32;' : '';

	var taskDescriptions = [
		'Creating issue page',
		'Preparing articles',
		$('<span>').text('Moving articles').append(
			$('<span>').css('font-size', '88%').append(moveDelayNotice)
		),
		'Editing issue page',
		'Editing main Signpost page',
		'Creating single page edition',
		'Creating archive page',
		'Updating previous edition\'s archive page',
		'Purging pages',
		'Mass-messaging enwiki subscribers',
		'Mass-messaging global subscribers',
		'Updating current year\'s archive overview page',
		'Creating archive categories (if needed)',
		'Updating "Next" links in previous issues',
		'Requesting a new watchlist notification'
	];
	this.taskItems = taskDescriptions.map(function(description, index) {
		return new OO.ui.Widget({
			$element: $('<li>'),
			id: 'SPS-task-'+index,
			classes: ['SPS-task'],
			$content: $('<span>').append([
				description,
				'... ',
				$('<span>').addClass('SPS-task-status').text('waiting')
			]),
			data: {completed: false}
		});
	});
	this.getPanelElement('status')
		.find('#SPS-dialog-taskList').remove().end()
		.append(
			$('<ul>').attr('id', 'SPS-dialog-taskList')
			.append(
				this.taskItems.map(function(item) { return item.$element; })
			)
		);
};
MainDialog.prototype.resetFinishedPanel = function() {
	$('#SPS-previewButton-container').empty();
};

/* ~~~~~~~~~~ Process panels (read their state, and set data based on it) ~~~~~~~~~~ */
MainDialog.prototype.processWelcomePanel = function(action) {
	if ( action === 'start' ) {
		this.data.api = new mw.Api(apiConfig);
		this.data.metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php', apiConfig);
		$('.SPS-dryRun').hide();
	} else {
		this.data.api = new FakeApi(apiConfig);
		this.data.metaApi = new FakeApi(apiConfig);
		$('.SPS-dryRun').show();
	}
};
MainDialog.prototype.processChoosePanel = function() {
	if ( !this.data || !this.data.api ) {
		return;
	}
	var oldCachedTitles = readFromCache('selected-titles');
	this.data.selectedTitles = this.getSelectedTitles();
	writeToCache('selected-titles', this.data.selectedTitles);
	return oldCachedTitles;
};
MainDialog.prototype.processSortPanel = function() {
	if ( !this.data || !this.data.api ) {
		return true;
	}
	this.data.articles = this.getArticleInfosInOrder();
	writeToCache('info', this.data.articles);
	var startAtZero = this.getStartAtZeroBoolean();
	this.data.firstItemIndex = ( startAtZero ) ? 0 : 1;
	writeToCache('startAtZero', startAtZero);
};


/* ~~~~~~~~~~ Process actions ~~~~~~~~~~ */
// Get the process (steps to be done) for each action (button click).
// Generally, set the new mode, get the new panel ready, and display the new panel.
MainDialog.prototype.getActionProcess = function( action ) {
	var dialog = this;

	if ( action === 'start' || action === 'dryrun' ) {
		return new OO.ui.Process(function() {
			dialog.processWelcomePanel(action);
		})
		.next(function() {
			return dialog.getTitlesFromCacheOrApi()
			.then(dialog.cacheTitlesIfNotCached)
			.then(function(titles) { dialog.setChoosePanelContent.call(dialog, titles); });
		})
		.next(function() {
			dialog.actions.setMode('choose');
			dialog.stackLayout.setItem(dialog.panels.choose);
		});
	} else if ( action === 'next-to-sort' ) {
		return new OO.ui.Process(function() {
			return $.when(dialog.processChoosePanel())
			.then(function(oldCachedSelectedTitles) {
				return dialog.getArticleAndIssueInfo.call(dialog, oldCachedSelectedTitles);
			})
			.then(function(articleInfos, prevIssueDates, startAtZero) {
				dialog.setPrevIssueDates(prevIssueDates);
				return dialog.setSortPanelContent(articleInfos, startAtZero);
			});
		})
		.next(function() {
			dialog.actions.setMode('sort');
			dialog.stackLayout.setItem(dialog.panels.sort);
			var publishButtonLabel = ( dialog.data.api.isFake ) ? 'Simulate publishing' : 'Publish';
			dialog.getActions().getSpecial().primary.setLabel(publishButtonLabel);
		}) // Hack to calculate size from this panel, rather than the previous panel
		.next(function() {
			dialog.actions.setMode('sort');
			dialog.stackLayout.setItem(dialog.panels.sort);
			var publishButtonLabel = ( dialog.data.api.isFake ) ? 'Simulate publishing' : 'Publish';
			dialog.getActions().getSpecial().primary.setLabel(publishButtonLabel);
		});
	} else if ( action === 'next-to-status' ) {
		return new OO.ui.Process(function() {
			dialog.processSortPanel.call(dialog);
		})
		.next(dialog.setStatusPanelContent, this)
		.next(function() {
			dialog.actions.setMode('status');
			dialog.stackLayout.setItem(dialog.panels.status);
		})
		.next(dialog.resetFinishedPanel)
		.next(function() {
			dialog.doPublishing.call(dialog);
		});
	} else if ( action === 'next-to-finished' ) {
		return new OO.ui.Process(function() {
			dialog.actions.setMode('finished');
			dialog.stackLayout.setItem(dialog.panels.finished);
		});
	} else if ( action === 'abort' ) {
		return new OO.ui.Process(function() {
			dialog.data.api.abort();
			dialog.actions.setMode('aborted');
			dialog.stackLayout.setItem(dialog.panels.aborted);
		});
	} else if ( action === 'back-to-welcome' || action === 'welcome' ) {
		return new OO.ui.Process(dialog.processChoosePanel)
		.next(function() {
			dialog.actions.setMode('welcome');
			dialog.stackLayout.setItem(dialog.panels.welcome);
		});
	} else if ( action === 'back-to-choose' ) {
		return new OO.ui.Process(dialog.processSortPanel)
		.next(function() {
			dialog.actions.setMode('choose');
			dialog.stackLayout.setItem(dialog.panels.choose);
			dialog.updateSize();
		});
	} else if ( action === 'back-to-status' ) {
		return new OO.ui.Process(function() {
			dialog.actions.setMode('status');
			dialog.stackLayout.setItem(dialog.panels.status);
		}) // Hack to calculate size from this panel, rather than the previous panel
		.next(function() {
			dialog.actions.setMode('status');
			dialog.stackLayout.setItem(dialog.panels.status);
		});
	} else if ( action === 'close' ) {
		return new OO.ui.Process(dialog.processChoosePanel)
		.next(dialog.processSortPanel)
		.next(function() { dialog.close(); });
	}
	return MainDialog.super.prototype.getActionProcess.call( this, action );
};
// Set up the initial mode
MainDialog.prototype.getSetupProcess = function( data ) {
	var dialog = this;
	data = data || {};
	dialog.data = data;
	return MainDialog.super.prototype.getSetupProcess.call( this, data )
	.next( function() {
		dialog.widgets = {};
		dialog.actions.setMode( 'welcome' );
		dialog.stackLayout.setItem(dialog.panels.welcome);
		dialog.updateSize();
	}, this );
};
// Do the publishing
MainDialog.prototype.doPublishing = function() {
	var dialog = this;
	var taskComleted = function(taskNumber) {
		return dialog.taskItems[taskNumber].getData().completed;
	};
	var tasksFunctions = [
		makeIssuePage,
		prepareArticles,
		moveArticles,
		editIssueSubpage,
		editMain,
		makeSingle,
		makeArchive,
		updatePrevArchive,
		purgePages,
		massmsg,
		globalmassmsg,
		updateYearArchive,
		createArchiveCats,
		updateOldNextLinks,
		requestWatchlistNotification
	];
	var allDonePromise = tasksFunctions.reduce(
		function(previousPromise, tasksFunction, taskNumber) {
			return previousPromise
			.then(function() {
				dialog.showTaskStarted(taskNumber);
				if ( taskComleted(taskNumber) ) {
					return reflect('skipped');
				}
				return reflect( tasksFunction(dialog.getData()) );
			})
			.then(function(reflectdPromise) {
				return dialog.setTaskStatus(reflectdPromise, taskNumber);
			});
		},
		$.Deferred().resolve()
	);
	return allDonePromise.then(function() {
		var dialogSpecialActions = dialog.getActions().getSpecial();
		dialogSpecialActions.primary.setDisabled(false);
		dialogSpecialActions.safe.setDisabled(true);
	});
};

/* ~~~~~~~~~~ Helper functions: getters ~~~~~~~~~~ */
/**
 * @param {Array} oldCachedSelectedTitles
 * @returns Promise of:
 *    {Array} of objects containing article info for each selected title
 *    {String} the previous issue date
 *    {Boolean} value for 'startAtZero' checkbox
 */
MainDialog.prototype.getArticleAndIssueInfo = function(oldCachedSelectedTitles) {
	var dialog = this;
	var defaultStartAtZero = false;
	if ( oldCachedSelectedTitles ) {
		var sameTitles = ( this.data.selectedTitles.join('') === oldCachedSelectedTitles.join('') );
		var cachedPreviousIssueDates = readFromCache('previousIssueDates');
		var cachedInfo = readFromCache('info');
		var cachedStartAtZero = readFromCache('startAtZero') || false;

		if ( sameTitles && cachedPreviousIssueDates && cachedInfo ) {
			//dialog.data.prevIssueDates = cachedPreviousIssueDates;
			// Get the latest info, but in the previously set order
			return getInfo(this.data.api, oldCachedSelectedTitles, cachedPreviousIssueDates)
			.then(function(newInfo) {
				var sortingNewInfoNotPossible = newInfo.length !== cachedInfo.length;
				if ( sortingNewInfoNotPossible ) {
					// Just use the new info in the default order
					return $.Deferred().resolve(newInfo, cachedPreviousIssueDates, defaultStartAtZero);
				}
			
				// Sort the new info into the same order as the old info
				var sortedNewInfo = cachedInfo.map(function(oldArticle) {
					return newInfo.find(function(newArticle) {
						return newArticle.section === oldArticle.section;
					});
				});
			
				var hasEmptySlots = sortedNewInfo.some(function(e) { return e==null; });
				if ( hasEmptySlots ) {
					// Just use the new info in the default order
					return $.Deferred().resolve(newInfo, cachedPreviousIssueDates, defaultStartAtZero);
				}
			
				return $.Deferred().resolve(sortedNewInfo, cachedPreviousIssueDates, cachedStartAtZero);
			});
		}
	}
	return getPreviousIssueDates(this.data.api, this.data.today.year)
	.then(function(previousIssueDates) {
		return getInfo(dialog.data.api, dialog.data.selectedTitles, previousIssueDates)
		.then(function(articlesInfos) {
			return $.Deferred().resolve(articlesInfos, previousIssueDates, defaultStartAtZero);
		});
	});
};
MainDialog.prototype.getArticleInfosInOrder = function() {
	var items = this.widgets.sortOrderDraggabelGroup.getItems();
	return items.map(function(item) {
		return item.data;
	});
};
MainDialog.prototype.getPanelElement = function(panel) {
	return $('#' + this.panels[panel].getElementId()).children().first();
};
/**
 * @returns {Array} Selected titles
 */
MainDialog.prototype.getSelectedTitles = function() {
	return this.widgets.titlesMultiselect.findSelectedItemsData();
};
MainDialog.prototype.getStartAtZeroBoolean = function() {
	var checkbox = this.widgets.startAtZeroCheckbox;
	return checkbox.isSelected();
};
/** @returns Promise of an array of titles **/
MainDialog.prototype.getTitlesFromCacheOrApi = function() {
	var cachedTitles = readFromCache('titles');
	if ( cachedTitles ) {
		return $.Deferred().resolve(cachedTitles, true /* = alreadyCached */);
	}
	return getArticleTitles(this.data.api);
};

/* ~~~~~~~~~~ Helper functions: setters ~~~~~~~~~~ */
/**
 * @param {Array} titles
 * @param {Boolean} alreadyCached
 * @returns {Array} titles (passed through without modification)
 */
MainDialog.prototype.cacheTitlesIfNotCached = function(titles, alreadyCached) {
	if ( !alreadyCached ) {
		writeToCache('titles', titles);
	}
	return titles;
};
/**
 * @chainable
 * @param {Object} previousIssueDates - name:date pairs of section names and their previous issue date
 * @returns {Object} previousIssueDates
 */
MainDialog.prototype.setPrevIssueDates = function(previousIssueDates) {
	this.data.previousIssueDates = previousIssueDates;
	writeToCache('previousIssueDates', previousIssueDates);
	return previousIssueDates;
};

/* ~~~~~~~~~~ Task-related functions (status panel) ~~~~~~~~~~ */
MainDialog.prototype.showTaskStarted = function(i) {
	var task = this.taskItems[i];
	if ( task.getData().completed ) {
		return;
	}
	task.$element
		.addClass('SPS-task-doing')
		.find('.SPS-task-status')
		.text('...doing...');
};
MainDialog.prototype.showTaskDone = function(i, skipped) {
	var task = this.taskItems[i];
	var taskClass = ( skipped ) ? 'SPS-task-skipped' : 'SPS-task-done';
	var statusText = ( skipped ) ? 'Skipped.' : 'Done!';
	task.$element
		.removeClass('SPS-task-doing')
		.addClass(taskClass)
		.find('.SPS-task-status')
		.text(statusText);
};
MainDialog.prototype.showTaskFailed = function(i, reason, allowRetry, handledPromise) {
	var dialog = this;
	var task = dialog.taskItems[i];
	var taskData = task.getData();

	var retryButton = $('<button>')
		.addClass('SPS-inlineButton mw-ui-button mw-ui-progressive')
		.text('Retry')
		.click(function() {
			task.$element
				.removeClass('SPS-task-failed')
				.find('.SPS-task-errorMsg, #SPS-errorbox-'+i)
				.remove();
			taskData.completed = false;
			taskData.skipped = false;
			task.setData(taskData);
			dialog.doPublishing()
				.always(function() {
					dialog.getActionProcess('next-to-finish');
				});
		});

	var skipButton = $('<button>')
		.addClass('SPS-inlineButton mw-ui-button')
		.append([
			'Continue ',
			$('<span>').addClass('no-bold').text('(after doing step manually)')
		])
		.click(function() {
			$('#SPS-errorbox-'+i).remove();
			taskData.completed = true;
			taskData.skipped = true;
			task.setData(taskData);
			dialog.showTaskDone(i, true);
			handledPromise.resolve();
		});

	var errorActions = $('<div>')
		.attr('id', 'SPS-errorbox-'+i)
		.append([
			( allowRetry ? retryButton : ''),
			skipButton
		]);

	task.$element
		.removeClass('SPS-task-doing')
		.addClass('SPS-task-failed')
		.find('.SPS-task-status')
		.empty()
		.after(errorActions)
		.after(
			$('<span>').addClass('SPS-task-errorMsg').text(' Failed (' + reason + ')')
		);
};
MainDialog.prototype.setTaskStatus = function(result, taskNumber) {
	var handledPromise = $.Deferred();
	var task = this.taskItems[taskNumber];
	var taskData = task.getData();

	if ( result.status === "resolved" ) {
		taskData.completed = true;
		task.setData(taskData);
		this.showTaskDone(taskNumber, taskData.skipped);
		if ( !taskData.skipped && taskNumber === 3 ) {
			var editionInfo = result.value[1];
			if ( editionInfo ) {
				this.data.previousIssueDate = editionInfo.previousIssueDate;
				this.data.vol = editionInfo.vol;
				this.data.iss = editionInfo.iss;
			}
		}
		handledPromise.resolve();
	} else {
		var tasksWithoutRetryOption = [1, 2, 6];
		var allowRetry = ( tasksWithoutRetryOption.indexOf(taskNumber) === -1 );
		this.showTaskFailed(
			taskNumber,
			extraJs.makeErrorMsg(result.error[0], result.error[1]),
			allowRetry,
			handledPromise
		);
	}
	return handledPromise;
};
/* ~~~~~~~~~ Window management ~~~~~~~~~~ */
var mainWindowFactory = new OO.Factory();
mainWindowFactory.register( MainDialog );
var mainWindowManager = new OO.ui.WindowManager({ factory: mainWindowFactory });
mainWindowManager.$element.addClass('sps-oouiWindowManager').insertBefore($('#SPS-ovarlayWindowManager'));

/* ========== Portlet link ====================================================================== */
// Add link to 'More' menu which starts everything
mw.util.addPortletLink( 'p-cactions', '#', 'Publish next edition', 'ca-pubnext');
$('#ca-pubnext').on('click', function(e) {
	e.preventDefault();
	// Configuration values
	var newDate = new Date();
	var config = {
		script_page:	'User:Evad37/SPS',
		script_ad:		" ([[User:Evad37/SPS|via script]])",
		path:			"Wikipedia:Wikipedia Signpost",
		today: {
			date:	newDate,
			iso:	newDate.toISOString().slice(0, 10),
			day:	newDate.getUTCDate(),
			month:	mw.config.get('wgMonthNames')[newDate.getUTCMonth()+1],
			year:	newDate.getUTCFullYear()
		},
		size: 'larger'
	};
	config.today.dmy = config.today.day + " " + config.today.month + " " + config.today.year;
	// Open the main dialog window
	mainWindowManager.openWindow('mainDialog', config);
});

// End of full file closure wrappers
});
// </nowiki>