User:Andrybak/Scripts/Not around.js

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.
// <nowiki>
/*
 *
 * Copyright (c) 2024 Andrei Rybak
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

(function() {
	'use strict';

	const config = {
		wikipage: '[[w:User:Andrybak/Scripts/Not around|Not around]]',
		version: '3.3'
	};

	const USERSCRIPT_NAME = 'Not around userscript';
	const LOG_PREFIX = `[${USERSCRIPT_NAME}]:`;

	function error(...toLog) {
		console.error(LOG_PREFIX, ...toLog);
	}

	function warn(...toLog) {
		console.warn(LOG_PREFIX, ...toLog);
	}

	function info(...toLog) {
		console.info(LOG_PREFIX, ...toLog);
	}

	function debug(...toLog) {
		console.debug(LOG_PREFIX, ...toLog);
	}

	function notify(notificationMessage) {
		mw.notify(notificationMessage, {
			title: USERSCRIPT_NAME
		});
	}
	
	function errorAndNotify(errorMessage, rejection) {
		error(errorMessage, rejection);
		notify(errorMessage);
	}

	const ABSENSE_YEARS_MINIMUM = 6;
	const mw = window.mw;
	const DEBUG = false;

	function constructAd() {
		return `using ${config.wikipage} v${config.version}`;
	}

	function constructEditSummary(username, lastContribYear) {
		return `/* top */ add [[Template:Not around]] – user ${username} hasn't edited since ${lastContribYear} (${constructAd()})`;
	}

	/**
	 * Asynchronously load specified number of contributions of specified username.
	 */
	function loadNLastUserContribs(username, n) {
		const api = new mw.Api();
		return api.get({
			action: 'query',
			list: 'usercontribs',
			ucuser: username,
			uclimit: n
		});
	}

	/**
	 * Asynchronously load the very last contribution of specified username.
	 */
	function loadLastUserContrib(username) {
		return new Promise((resolve, reject) => {
			loadNLastUserContribs(username, 1).then(response => {
				debug(response);
				const lastContrib = response.query.usercontribs[0];
				resolve(lastContrib);
			}, rejection => {
				reject(rejection);
			});
		});
	}

	function isoStringToYear(timestamp) {
		const d = new Date(timestamp);
		return d.getUTCFullYear();
	}

	function loadCurrentWikitext(pagename) {
		return new Promise((resolve, reject) => {
			const api = new mw.Api();
			api.get({
				action: 'query',
				titles: pagename,
				prop: 'revisions',
				rvprop: 'content',
				rvslots: 'main',
				/* v2 has nicer field names in responses to this request */
				formatversion: 2
			}).then(response => {
				resolve(response.query.pages[0].revisions[0].slots.main.content);
			}, rejection => {
				reject(rejection);
			});
		});
	}

	function addNotAroundTemplateIfAbsent(username, lastContribYear) {
		info(`${username} hasn't edited since ${lastContribYear}.`);

		const userTalkPageTitle = 'User_talk:' + username;
		loadCurrentWikitext(userTalkPageTitle).then(wikitext => {
			/*
			 * TODO: The checks below are not enough: a mangled template invocation with spaces, like
			 * TODO:    {{  not around}}
			 * TODO: will not be detected.
			 */
			if (wikitext.includes('{{Not around') || wikitext.includes('{{not around')) {
				info(userTalkPageTitle + ' already has the template. Showing it to the user and aborting.');
				location.assign('/wiki/' + userTalkPageTitle);
				return;
			}
			const newWikitext = `{{Not around|date=${lastContribYear}}}\n` + wikitext;
			const editSummary = constructEditSummary(username, lastContribYear);
			if (DEBUG) {
				debug(newWikitext.slice(0, 40));
				debug(editSummary);
			}
			const api = new mw.Api();
			api.postWithEditToken({
				action: 'edit', /* TODO figure out how to do a preview instead of 'edit' */
				title: userTalkPageTitle,
				text: newWikitext,
				summary: editSummary
			}).then(response => {
				// Show the edit performed by `postWithEditToken` to the user of the script.
				loadLastUserContrib(mw.user.getName()).then(theEdit => {
					location.assign('/wiki/Special:Diff/' + theEdit.revid);
				}, rejection => {
					errorAndNotify(`Cannot load last contribution by ${mw.user.getName()}.`, rejection);
				});
			}, rejection => {
				errorAndNotify(`Cannot edit page [[${userTalkPageTitle}]]`, rejection);
			});
		});
	}

	function runPortlet () {
		const username = mw.config.get('wgRelevantUserName');
		if (!username) {
			errorAndNotify('Cannot find a username', null);
			return;
		}
		loadLastUserContrib(username).then(lastContrib => {
			if (!lastContrib) {
				notify(`User ${username} has zero contributions. Aborting.`);
				return;
			}
			if (lastContrib.user != username) {
				errorAndNotify(`Received wrong user. Actual ${lastContrib.user} ≠ expected ${username}. Aborting.`, null);
				return;
			}
			const lastContribYear = isoStringToYear(lastContrib.timestamp);
			const currentYear = new Date().getUTCFullYear();
			info('Last edit timestamp =', lastContrib.timestamp);
			// check how long ago was the last contribution
			if (currentYear - lastContribYear >= ABSENSE_YEARS_MINIMUM) {
				addNotAroundTemplateIfAbsent(username, lastContribYear);
			} else {
				notify(`${username} is still an active user. Last edit was in year ${lastContribYear}. Aborting.`);
			}
		}, rejection => {
			errorAndNotify(`Cannot load contributions of ${username}. Aborting.`, rejection);
		});
	}

	function lazyLoadNotAround() {
		debug('Loading...');
		const namespaceNumber = mw.config.get('wgNamespaceNumber');
		/* "Special", "User", and "User talk" */
		if (namespaceNumber === -1 || namespaceNumber === 2 || namespaceNumber === 3) {
			if (!mw.loader.using) {
				warn('Function mw.loader.using is no loaded yet. Retrying...');
				setTimeout(lazyLoadNotAround, 300);
				return;
			}
			mw.loader.using(
				['mediawiki.util'],
				() => {
					const link = mw.util.addPortletLink('p-cactions', '#', 'Not around', 'ca-notaround', 'add template {{Not around}}');
					if (!link) {
						info('Cannot create portlet link (mw.util.addPortletLink). Assuming unsupported skin. Aborting.');
						return;
					}
					link.onclick = event => {
						event.preventDefault();
						mw.loader.using('mediawiki.api', runPortlet);
					};
				},
				(e) => {
					error('Cannot add portlet link', e);
				}
			);
		} else {
			warn('Triggered on a bad namespace =', namespaceNumber);
		}
	}

	if (document.readyState !== 'loading') {
		lazyLoadNotAround();
	} else {
		warn('Cannot load yet. Setting up a listener...');
		document.addEventListener('DOMContentLoaded', lazyLoadNotAround);
	}
})();
// </nowiki>