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.
/**
 * Script for enabling temporary watchlisting of pages
 *
 */

/* jshint maxerr: 999 */

// <nowiki>

var api;

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

	api = new mw.Api();

	// Menu interface
	if (mw.config.get('wgNamespaceNumber') >= 0) {

		// For vector skin, make a submenu within the "more" dropdown, inspired by [[meta:MoreMenu]]
		var hasMenu = false;
		if (mw.config.get('skin') === 'vector') {
			hasMenu = true;
			$(mw.util.addPortletLink('p-cactions', '#', 'T-Watch...', 'ca-twatch')).css({
				'position': 'relative'
			}).append(
				$('<ul>').addClass('menu').css({
					'display': 'none',
					'background-color': '#fff',
					'border': '1px solid #aaa'
				})
			).click(function(e) {
				e.preventDefault();
			}).on('mouseenter', function() {
				$(this).find('.menu').css({
					'left': $(this).outerWidth(),
					'top': '-1px',
					'position': 'absolute'
				}).show();
			}).on('mouseleave', function() {
				$(this).find('.menu').hide();
			});

		} else if (mw.config.get('skin') === 'monobook') {
			hasMenu = true;
			$(mw.util.addPortletLink('p-cactions', '#', 'T-Watch...', 'ca-twatch')).css({
				'position': 'relative',
				'padding-bottom': '0'
			}).append(
				$('<ul>').addClass('menu').css({
					'display': 'none',
					'z-index': '1000',
					'list-style': 'none',
					'background-color': '#fff',
					'border': '1px solid #aaa',
					'margin': '0'
				})
			).click(function(e) {
				e.preventDefault();
			}).on('mouseenter', function() {
				$(this).find('.menu').css({
					'left': '0px',
					'top': $(this).outerHeight(),
					'position': 'absolute'
				}).show();
			}).on('mouseleave', function() {
				$(this).find('.menu').hide();
			});
			mw.util.addCSS(
				'#ca-twatch .menu li { display: list-item; border: none; }' +
				'#ca-twatch .menu li a { background: none !important; }' + // !important needed for IE/firefox
				'#ca-twatch .menu li:hover { text-decoration: underline; }'
			);
		}

		var menuItems = $.isArray(window.TWatch_Durations_viewing) ?
			window.TWatch_Durations_viewing :
			['1 week', '1 month'];

		menuItems.forEach(function(duration) {
			var li = mw.util.addPortletLink(hasMenu ? 'ca-twatch' : 'p-cactions',
			'#', 'Watch – ' + duration, '', 'Watchlist this page for a duration of ' + duration);
			li.addEventListener('click', function(ev) {
				ev.preventDefault();
				var watchTill = moment().add(parseDuration(duration));
				watchPage(mw.config.get('wgPageName'), watchTill.unix() * 1000);
			});
		});
	}

	// Edit page interface
	if (mw.config.get('wgAction') === 'edit' || mw.config.get('wgAction') === 'submit') {

		var $select = $('<select>').attr('id', 'watchduration').css({
			'margin-left': '5px'
		}).change(function() {
			$('#wpWatchthis')[0].checked = true;
		}).insertAfter($('#wpWatchthisWidget').parent().next());

		var options = $.isArray(window.TWatch_Durations_editing) ?
			window.TWatch_Durations_editing :
			['1 week', '2 weeks', '1 month', '2 months'];

		options.forEach(function(durtext) {
			var watchTill = moment().add(parseDuration(durtext));
			$('<option>')
				.text(durtext)
				.val(watchTill.unix() * 1000)
				.appendTo($select);
		});
		$('<option>').text('Indefinitely').val('inf').prop('selected', true).appendTo($select);

		if (window.TWatch_default_edit_watch_period) {
			$select.find('option:contains("' + window.TWatch_default_edit_watch_period + '")').prop('selected', true);
		}

		// record in pages object that the page is to be unwatched for said duration
		// watching of the page is done by mediawiki
		$('#wpSave').click(function() {
			if ($('#wpWatchthis')[0].checked) {
				var dur = $select.val();
				if (dur === 'inf') {
					return;
				}
				recordAsWatching(mw.config.get('wgPageName'), parseInt(dur));
			}
		});
	}

	// Integration with user scripts that edit pages (probably unnecessary)
	hookEventListener();

	// Special page to see list of temporarily watched pages
	if (mw.config.get('wgPageName') === 'Special:BlankPage/TempWatched' ||
		mw.config.get('wgPageName') === 'Special:BlankPage/T-Watch' ||
		mw.config.get('wgPageName') === 'Special:TempWatched') {
			buildSpecialPage();
	}

	// show 'expiring soon' alerts
	if (mw.config.get('wgCanonicalSpecialPageName') === 'Watchlist' && !window.TWatch_NoAlerts) {
		mw.hook('wikipage.content').add(addWatchlistAlerts);
	}

	// Unwatch expired pages every hour
	var nextCheckTime = mw.user.options.get('userjs-twl-nextcheck');
	if (!nextCheckTime) {  // for first-time users
		api.saveOption('userjs-twl-nextcheck', (new Date().getTime() + 1000*60*60).toString());
	}
	else if (new Date().getTime() > parseInt(nextCheckTime)) {
		removeExpiredPages();
	}

}).catch(console.error);

/**
 * @param {string} page
 * @param {number} till_time - milliseconds since epoch
 */
function watchPage(page, till_time) {
	api.watch(page).done(function() {
		recordAsWatching(page, till_time);
		var title = new mw.Title(page);
		mw.notify(
			'"' + title.toText() + '" and its ' +
			(title.isTalkPage() ? 'associated subject' : 'talk') +
			' page have been added to your watchlist till ' +
			getString(till_time, true)
		);
	}).fail(function(err) {
		mw.notify('Couldn\'t add to watchlist due to an error. Please try again.\n Error: ' +
		JSON.stringify(err));
	});
}

/**
 * @param {string} page
 * @param {number} till_time - milliseconds since epoch
 */
function recordAsWatching(page, till_time) {
	page = new mw.Title(page).getSubjectPage().toText(); // normalize talk page to subject page
	var opt = JSON.parse(mw.user.options.get('userjs-twl-pages'));
	if (!opt) {
		opt = {};
	}
	opt[page] = till_time; // expiry timestamp
	api.saveOption('userjs-twl-pages', JSON.stringify(opt));
}

function removeExpiredPages() {
	var opt = JSON.parse(mw.user.options.get('userjs-twl-pages'));
	if (!opt) return;
	var pagesToUnwatch = [];
	$.each(opt, function(page, expiry) {
		if (new Date().getTime() > expiry) {
			pagesToUnwatch.push(page);
		}
	});

	// kludge: if more than 50 pages, unwatch 50 for now, and leave the rest for the next hour
	if (pagesToUnwatch.length > 50) {
		pagesToUnwatch = pagesToUnwatch.slice(0, 50);
	}
	api.unwatch(pagesToUnwatch).done(function() {
		// check again for expired pages after an hour
		api.saveOption('userjs-twl-nextcheck', (new Date().getTime() + 1000*60*60).toString());

		// update pages object
		pagesToUnwatch.forEach(function(page) {
			delete opt[page];
		});
		api.saveOption('userjs-twl-pages', JSON.stringify(opt));
	});
}

function addWatchlistAlerts() {
	var opt = JSON.parse(mw.user.options.get('userjs-twl-pages'));
	if (!opt) {
		return;
	}
	var threshold = moment().add(parseDuration(window.TWatch_Alert_period || '3 days'));
	$('.mw-changeslist-title').each(function() {
		var page = this.textContent;
		if (opt[page] && moment(opt[page]).isBefore(threshold)) {
			var $container = $(this).parent().parent().parent(); // li element
			if ($container.find('.twatch-expiry-alert').length === 0) {
				var exptext = moment(opt[page]).fromNow();
				$('<span>')
					.text('[expires ' + exptext + ']')
					.attr('title', 'This page will be removed from your watchlist around ' + getString(opt[page], true))
					.addClass('twatch-expiry-alert')
					.css({
						'font-size': '80%',
						'padding-left': '5px',
						'color': 'brown'
					}).appendTo($container);
			}
		}
	});
}

/**
 * @param {String} str - a string specifying duration - eg. "3 weeks", "2 months"
 * @returns {moment.duration} - moment Duration object
 */
function parseDuration(str) {
	var i;
	for (i = 0; i < str.length; i++) {
		if (str[i] < '0' || str[i] > '9') {
			break;
		}
	}
	var num = parseInt(str);
	var text = str.slice(i).trim();
	var momentObject = moment.duration(num, text);
	if (!momentObject._isValid || momentObject.asMilliseconds() === 0) {
		console.error('Invalid duration string: "' + str + '"');
	}
	return momentObject;
}

function hookEventListener() {
	mw.hook('record_watch').add(function(arg) {
		if (!arg) arg = {};
		arg.page = arg.page || mw.config.get('wgPageName');
		arg.setting = arg.setting || 'preferences'; // allows input like window.ScriptNameWatchPref
		arg.duration = arg.duration || window.tempWatchlistDefaultDuration || 'inf';
		arg.action; // 'edit', 'create', 'upload', 'move', 'delete', 'rollback'

		if (arg.setting === 'watch' || arg.setting === true) {
			if (arg.duration !== 'inf')
				recordAsWatching(arg.page, arg.duration);
			api.watch(arg.page); // client script should do this ideally, but just in case...
		} else if (arg.setting === 'preferences') {  // consult user's site preferences
			var pref;
			switch (arg.action) {
				case 'create': pref = 'watchcreations'; break;
				case 'move': pref = 'watchmoves'; break;
				case 'delete': pref = 'watchdeletions'; break;
				case 'upload': pref = 'watchuploads'; break;
				case 'rollback': pref = 'watchrollbacks'; break;
				default: pref = 'watchdefault';
			}
			if (mw.user.options.get(pref) == 1) { // dunno whether its string or number
				if (arg.duration !== 'inf')
					recordAsWatching(arg.page, arg.duration);
				api.watch(arg.page);
			}
		}
	});
}

function buildSpecialPage() {
	$('#firstHeading').text('Temporarily watched pages');
	document.title = 'Temporarily watched pages';
	$('#mw-content-text').empty();

	var opt = JSON.parse(mw.user.options.get('userjs-twl-pages'));

	var $ul = $('<ul>');
	$.each(opt, function(page, expiry) {
		$ul.append(
			$('<li>').html(
				'<a href="' + mw.util.getUrl(page) + '" title="' + page + '">'+ page + '</a>: ' +
				getString(expiry)
			)
		);
	});
	$('#mw-content-text').append(
		$('<p>').text('The following pages are set to be automatically unwatched after the given time in ' + getTimeZoneString() + ' time zone:'),
		$('<p>').html('This list may include any pages that you may have subsequently unwatched manually, <a id="purgeunwatchedpages">click here to purge such pages</a>.'),
		$ul
	);
	$('#purgeunwatchedpages').click(function() {

		$ul.replaceWith('Purging...');
		var arrayOfPages = Object.keys(opt); // ASSUME < 50 for now
		if (arrayOfPages.length > 50) {
			alert('You have more than 50 pages here: purge feature coming soon');
			return;
		}
		api.get({
			"action": "query",
			"format": "json",
			"prop": "info",
			"titles": arrayOfPages,
			"inprop": "watched"
		}).then(function(json) {
			Object.values(json.query.pages).forEach(function(info) {
				if (info.watched === undefined) {
					delete opt[info.title];
				}
			});
			opt = JSON.stringify(opt);
			api.saveOption('userjs-twl-pages', opt).then(function() {
				mw.user.options.set('userjs-twl-pages', opt);
				buildSpecialPage();
			});
		});


		// var arrayOfArrays = arrayChunk(arrayOfPages, 50);
		// arrayOfArrays.forEach(function(array) {
		// 	api.get({
		// 		"action": "query",
		// 		"format": "json",
		// 		"prop": "info",
		// 		"titles": array,
		// 		"inprop": "watched"
		// 	}).then(function(json) {
		// 		Object.values(json.query.pages).forEach(function(info) {
		// 			if (info.watched === undefined) {
		// 				delete opt[info.title.replace(/ /g, '_')];
		// 			}
		// 		});
		// 	});
		// });

	});
}

// HELPER FUNCTIONS:

/**
 * @param {number} date - milliseconds since epoch
 */
function getString(date, withzone) {
	return moment(date).utcOffset(getUserTimeZone()).format('HH:mm, D MMMM YYYY') +
	(withzone ? (' (' + getTimeZoneString() + ').') : '');
}

function getUserTimeZone() {
	if (window.userTimeZone) { // cache it
		return window.userTimeZone;
	}
	var pref = mw.user.options.get('timecorrection');
	if (pref.indexOf('ZoneInfo|') === 0) {
		window.userTimeZone = parseInt(pref.slice('ZoneInfo|'.length));
	} else if (pref.indexOf('Offset|') === 0) {
		window.userTimeZone = parseInt(pref.slice('Offset|'.length));
	} else if (pref === 'System|0') {
		window.userTimeZone = 0;
	} else {
		console.error('[W-Ping]: unparsable time zone: ' + pref);
	}
	return window.userTimeZone;
}

function getTimeZoneString(timecorrection) {
	timecorrection = timecorrection || getUserTimeZone();
	var negative = false;
	if (timecorrection < 0) {
		timecorrection = -timecorrection;
		negative = true;
	}
	var hourCorrection = parseInt(timecorrection/60);
	hourCorrection = (hourCorrection < 10 ? '0' : '') + hourCorrection.toString();

	var minuteCorrection = timecorrection % 60;
	minuteCorrection = (minuteCorrection < 10 ? '0' : '') + minuteCorrection.toString();

	return 'UTC' + (negative ? '–' : '+') + hourCorrection + minuteCorrection;
}

// function arrayChunk(arr, size) {
// 	var result = [];
// 	var current;
// 	for (var i = 0; i < arr.length; ++i) {
// 		if (i % size === 0) { // when 'i' is 0, this is always true, so we start by creating one.
// 			current = [];
// 			result.push(current);
// 		}
// 		current.push(arr[i]);
// 	}
// 	return result;
// }

// </nowiki>