mic_none

User:Daniel Quinlan/Scripts/Vanilla.js Source: en.wikipedia.org/wiki/User:Daniel_Quinlan/Scripts/Vanilla.js

"use strict";

mw.loader.using(['mediawiki.util', 'user.options']).then(function () {
	// state variables
	const nsNum = mw.config.get('wgNamespaceNumber');
	const userName = mw.config.get('wgRelevantUserName');
	const userOptions = { debug: false, replace: true };
	const userPages = {};

	// only process signatures in signature namespaces
	if (nsNum % 2 === 0 && !mw.config.get('wgExtraSignatureNamespaces').includes(nsNum)) {
		vanillaMode();
		return;
	}

	// constants
	const SIGNATURE_ELEMENTS = new Set(['B', 'BDI', 'BIG', 'BR', 'CODE', 'EM', 'FONT', 'I', 'IMG', 'INS', 'KBD', 'RP', 'RT', 'RUBY', 'SMALL', 'S', 'SAMP', 'SPAN', 'STRONG', 'SUB', 'SUP', 'U']);
	const SIGNATURE_SPAN_CLASSES = new Set([null, 'FTTCmt', 'ext-discussiontools-init-replylink-buttons', 'fn', 'nickname', 'nowrap', 'signature-talk', 'skin-invert', 'vcard']);
	const SERVER_PREFIX = window.location.origin;
	const ARTICLE_PATH = mw.config.get('wgArticlePath').replace(/\$1/, '');
	const SCRIPT_PATH = mw.config.get('wgScript') + '?title=';
	const SIGNATURE_LINKS = ['User:', 'User_talk:', 'Special:Contributions/', 'Special:Log/', 'Special:EmailUser/'];
	const SIGNATURE_DELIMITERS = new Set(['', '#', '&', '/', '?']);
	const ALTERNATIVE_SUFFIXES = /^(?:[ \-\.]?(?!\w+\x29)| ?\x28(?=\w+\x29))?(?:2|alt|alternate|awb|bot|ii|mobile|onmobile|public|sock|test|too)\b\x29?/i;
	const SIGNATURE_PARAMS = new Set(['action', 'redlink', 'safemode', 'title']);
	const SIGNATURE_SEPARATOR = /(?:[\-~\xa0\xb7\u2000-\u200d\u2010-\u2017\u2022\u2026\u202f\u205f\u2060\u2190-\u2bff\u2e3a\u2e3b\u3000\u3127]+\s*|[\u{1F000}-\u{1F9FF}]+(?=[\s\xa0\u2000-\u200d\u202f\u205f\u2060\u3000]?$)|\x26(?:hairsp|nbsp|nobreak);\s*)+\s*$/iu;
	const AUTHOR_REGEX = /\#c-(.*?)-\d{4}(?:(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(?:[01]\d|2[0-3])[0-5]\d[0-5]\d(?:\-|$)|-\d\d-\d\dT\d\d:\d\d:\d\d\b)/;
	const STYLES_REGEX = /\b(?:background|background-color|border|border-color|box-shadow|color|outline|text-shadow)\s*:/i;

	// conditionally remove styles from user talk pages
	if (nsNum === 3 && userName) {
		vanillaExtract();
	}

	function vanillaExtract() {
		const getVanillaText = () => 'Vanilla ' + (userPages[userName] ? 'restore' : 'extract');

		getUserOption(userPages, 'userjs-vanilla-users');
		if (userPages[userName]) {
			renameStyleAttributes(true);
			delete userPages[userName];
			userPages[userName] = 1;
			setUserOption(userPages, 'userjs-vanilla-users');
		}

		const link = mw.util.addPortletLink('p-tb', '#', getVanillaText(), 'ca-vanilla-extract');
		link.addEventListener('click', async (event) => {
			event.preventDefault();
			if (userPages[userName]) {
				delete userPages[userName];
			} else {
				userPages[userName] = 1;
			}
			const span = link.querySelector('span');
			if (span) {
				span.textContent = getVanillaText();
			}
			renameStyleAttributes(userPages[userName]);
			setUserOption(userPages, 'userjs-vanilla-users');
		});
	}

	function renameStyleAttributes(deactivate) {
		const content = document.querySelector('#mw-content-text');
		const source = deactivate ? 'style' : 'data-vanilla-style';
		const destination = deactivate ? 'data-vanilla-style' : 'style';
		const styledElements = content.querySelectorAll(`[${source}]`);

		for (const element of styledElements) {
			const style = element.getAttribute(source);

			if (style && STYLES_REGEX.test(style)) {
				element.setAttribute(destination, style);
				element.setAttribute(source, '');
			}
		}
	}

	function vanillaMode() {
		if (mw.config.get('wgTitle') !== mw.config.get('wgUserName') + '/common.css') {
			return;
		}
		getUserOption(userOptions, 'userjs-vanilla');
		mw.util.addPortletLink('p-tb', '#', 'Vanilla mode', 'ca-vanilla-mode')
			.addEventListener('click', async (event) => {
				event.preventDefault();
				userOptions.replace = !userOptions.replace;
				const mode = userOptions.replace ? 'replacing signatures' : 'removing styling';
				try {
					setUserOption(userOptions, 'userjs-vanilla');
					mw.notify(`Now ${mode}!`, { title: 'Vanilla' });
				} catch {
					mw.notify('Failed to save options!', { type: 'error' });
				}
			});
	}

	function getUserOption(destination, optionName) {
		const userOptionsString = mw.user.options.get(optionName);

		try {
			if (userOptionsString) {
				Object.assign(destination, JSON.parse(userOptionsString));
			}
		} catch (error) {
			console.error(`Vanilla error parsing user option "${optionName}":`, error);
		}
	}

	async function setUserOption(source, optionName) {
		let jsonString = JSON.stringify(source);
		const keys = Object.keys(userPages);

		while (jsonString.length > 32768 || keys.length > 256) {
			delete userPages[keys.shift()];
			jsonString = JSON.stringify(source);
		}

		await new mw.Api().saveOption(optionName, jsonString);
	}

	function extractAuthor(span) {
		const comment = span?.getAttribute('href');
		const match = comment?.match(AUTHOR_REGEX);
		return match ? match[1] : '';
	}

	function isUserPage(node, authorString) {
		let url = node.href;
		let pagetype = 'exact';

		if (url.startsWith(SERVER_PREFIX)) {
			url = url.substring(SERVER_PREFIX.length);
		} else if (node.classList.contains('extiw')) {
			try {
				const urlObject = new URL(url);
				url = urlObject.pathname + urlObject.search + urlObject.hash;
			} catch (error) {
				console.warn(`Vanilla error parsing "${url}":`, error);
				return false;
			}
		} else if (!url && nsNum === 3 && !mw.config.get('wgRelevantPageName').includes('/') && node.classList.contains('selflink')) {
			return 'self';
		}
		if (url.startsWith(ARTICLE_PATH)) {
			url = url.substring(ARTICLE_PATH.length);
		} else if (url.startsWith(SCRIPT_PATH)) {
			url = url.substring(SCRIPT_PATH.length);
		} else {
			return false;
		}
		for (const prefix of SIGNATURE_LINKS) {
			if (url.startsWith(prefix)) {
				url = url.substring(prefix.length);
				if (url.includes('%')) {
					try {
						const decoded = decodeURIComponent(url);
						url = decoded;
					} catch (error) {
						console.warn(`Vanilla error decoding "${url}":`, error);
						return false;
					}
				}
				if (url.toLowerCase().startsWith(authorString)) {
					url = url.substring(authorString.length);
					// check for ALTERNATIVE_SUFFIXES match
					const alternativeSuffixMatch = url.match(ALTERNATIVE_SUFFIXES);
					if (alternativeSuffixMatch) {
						url = url.substring(alternativeSuffixMatch[0].length);
						pagetype = 'alternative';
					}
					if (!SIGNATURE_DELIMITERS.has(url.charAt(0))) {
						return false;
					}
					if (url.match(/\.(?:css|js|json)\b|^\/(?:[Aa]rchive|.*\/)/)) {
						return false;
					}
					const paramsIndex = node.href.indexOf('?');
					if (paramsIndex !== -1) {
						const queryString = node.href.substring(paramsIndex + 1);
						const queryParams = new URLSearchParams(queryString);
						if ([...queryParams.keys()].some(key => !SIGNATURE_PARAMS.has(key))) {
							return false;
						}
					}
					return pagetype;
				}
				return false;
			}
		}
		return false;
	}

	function isVanillaSignature(matchedNodes, author) {
		return matchedNodes.length === 4 &&
			matchedNodes[0].nodeType === Node.ELEMENT_NODE &&
			matchedNodes[0].children.length === 0 &&
			matchedNodes[0].tagName === 'A' &&
			matchedNodes[0].textContent.toLowerCase() === author.toLowerCase() &&
			matchedNodes[1].nodeType === Node.TEXT_NODE && matchedNodes[1].nodeValue === ' (' &&
			matchedNodes[2].nodeType === Node.ELEMENT_NODE &&
			matchedNodes[2].children.length === 0 &&
			matchedNodes[2].tagName === 'A' &&
			matchedNodes[2].textContent.toLowerCase() === 'talk' &&
			matchedNodes[3].nodeType === Node.TEXT_NODE && matchedNodes[3].nodeValue === ') ';
	}

	function createSignature(author) {
		const linkname = author.replace(/ /g, '_');

		const userLink = document.createElement('a');
		userLink.href = `https://demo.azizisearch.com/lite/wikipedia/page/User:${linkname}`;
		userLink.title = `User:${linkname}`;
		userLink.textContent = author;
		userLink.classList.add('vanilla-replaced');

		const talkLink = document.createElement('a');
		talkLink.href = `https://demo.azizisearch.com/lite/wikipedia/page/User_talk:${linkname}`;
		talkLink.title = `User talk:${author}`;
		talkLink.textContent = 'talk';
		talkLink.classList.add('vanilla-replaced');

		const fragment = document.createDocumentFragment();
		fragment.appendChild(userLink);
		fragment.appendChild(document.createTextNode(' ('));
		fragment.appendChild(talkLink);
		fragment.appendChild(document.createTextNode(') '));

		return fragment;
	}

	function reviewTextNode(node, author) {
		let parentNode = node.parentNode;

		while (parentNode) {
			if (parentNode.nodeType === Node.ELEMENT_NODE) {
				if (parentNode.tagName === 'A') {
					return null;
				}
				if (parentNode.tagName === 'DIV') {
					break;
				}
			}
			parentNode = parentNode.parentNode;
		}

		const anchor = document.createElement('a');
		parentNode = node.parentNode;
		anchor.href = `https://demo.azizisearch.com/lite/wikipedia/page/User:${author}`;
		anchor.textContent = node.nodeValue;
		parentNode.insertBefore(anchor, node);
		parentNode.removeChild(node);
		return parentNode;
	}

	function signatureNodes(endNode, author) {
		const authorString = author.toLowerCase().replace(/ /g, '_');
		const conditions = {};
		const matchedNodes = [];
		const authorNodes = [];
		const styleNodes = [];
		let textLength = 0;

		function checkNode(node) {
			const childNodes = node.childNodes;

			for (let i = childNodes.length - 1; i >= 0; i--) {
				if (!checkNode(childNodes[i])) {
					return false;
				}
			}

			if (node.nodeType === Node.TEXT_NODE) {
				textLength += node.nodeValue.length;
				if (textLength > 120) {
					return false;
				}
				if (node.nodeValue.toLowerCase().includes(authorString)) {
					authorNodes.push(node);
				}
			} else if (node.nodeType === Node.ELEMENT_NODE) {
				if (node.tagName === 'A') {
					const userPageCheck = isUserPage(node, authorString);
					if (userPageCheck) {
						conditions.userPage = true;
						if (userPageCheck === 'alternative') {
							conditions.alternative = true;
						}
						if (userOptions.replace && conditions.originalAuthor === undefined) {
							const authorText = node.textContent.trim();
							const searchAuthor = author.replace(/_/g, ' ').toLowerCase();
							if (authorText.toLowerCase() === searchAuthor) {
								conditions.originalAuthor = authorText;
							} else {
								const titleText = (node.getAttribute('title') || '').trim()
								if (titleText.toLowerCase().endsWith(searchAuthor)) {
									conditions.originalAuthor = titleText.slice(-searchAuthor.length);
								}
							}
						}
					} else if (node.href) {
						// allow other links until user page seen
						if (conditions.userPage) {
							return false;
						}
					} else {
						return false;
					}
				} else if (node.tagName === 'SPAN') {
					if (node.hasAttribute('data-mw-comment-start') ||
						(node.classList.length === 1 && !SIGNATURE_SPAN_CLASSES.has(node.classList[0]) && !/sig/i.test(node.classList[0])) ||
						(node.classList.length > 1 && ![...node.classList].every(name => SIGNATURE_SPAN_CLASSES.has(name)))) {
						return false;
					}
				} else if (!SIGNATURE_ELEMENTS.has(node.tagName)) {
					return false;
				}
			} else {
				return false;
			}

			if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('style')) {
				styleNodes.push(node);
			}
			return true;
		}

		function isAutosigned(node) {
			return node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SMALL' && node.classList.contains('autosigned');
		}

		function reviewPreviousText(firstMatchedNode, parentNode) {
			// check previous sibling or previous sibling of span parent nodes
			let priorNode = firstMatchedNode.previousSibling;
			if (!priorNode && parentNode.nodeType === Node.ELEMENT_NODE && parentNode.nodeName === 'SPAN') {
				priorNode = parentNode.previousSibling;
			}

			if (priorNode && priorNode.nodeType === Node.TEXT_NODE) {
				// remove signature separators
				let textBefore = priorNode.nodeValue.replace(SIGNATURE_SEPARATOR, ' ');
				// add a space if needed
				if (!textBefore.endsWith(' ')) {
					textBefore += ' ';
				}
				// replace text if modified
				if (textBefore !== priorNode.nodeValue) {
					priorNode.nodeValue = textBefore;
				}
			}
		}

		// skip unwanted nodes from end
		let node = endNode;
		for (const className of ['ext-discussiontools-init-replylink-buttons', 'ext-discussiontools-init-timestamplink']) {
			if (node && node.nodeType === Node.ELEMENT_NODE && node.getAttribute('class') === className) {
				if (node.previousSibling) {
					node = node.previousSibling;
				} else if (node.parentNode && SIGNATURE_ELEMENTS.has(node.parentNode.nodeName)) {
					// edge case: discussion tools element wrapped in a signature element
					if (node.parentNode.previousSibling) {
						node = node.parentNode.previousSibling;
					}
				}
			}
		}

		// adjust for signatures below previous sibling, possibly after non-signature content
		if (node && node.firstChild && node.firstChild.hasAttribute && node.firstChild.hasAttribute('data-mw-comment-start')) {
			if (userOptions.debug) {
				console.debug("changing node to last child after data-mw-comment-start", node);
			}
			node = node.lastChild;
		}

		// add nodes
		while (node) {
			if (checkNode(node)) {
				matchedNodes.unshift(node);
			} else {
				break;
			}
			node = node.previousSibling;
		}

		// track modified nodes
		const modified = [];

		// no user page
		if (!conditions.userPage) {
			return modified;
		}

		// signature replacement mode
		let replaced = false;
		if (userOptions.replace) {
			// remove unwanted nodes from start
			while (matchedNodes.length) {
				const firstNode = matchedNodes[0];
				if (firstNode.nodeType === Node.ELEMENT_NODE) {
					// first node is an anchor or contains an anchor
					if (firstNode.tagName === 'A' || firstNode.getElementsByTagName('A').length) {
						break
					}
					// first node is a styled signature element or has styled descendants
					if (SIGNATURE_ELEMENTS.has(firstNode.tagName) && (firstNode.hasAttribute('style') || firstNode.querySelector('[style]')))
						break;
				}
				// otherwise, remove the node
				matchedNodes.shift();
			}
			// return early if there are no nodes left
			if (!matchedNodes.length) {
				return modified;
			}
			// skip autosigned signatures
			const firstMatchedNode = matchedNodes[0];
			const parentNode = firstMatchedNode.parentNode;
			if (isAutosigned(firstMatchedNode) || isAutosigned(parentNode))
				return modified;
			// review text directly before signature
			reviewPreviousText(firstMatchedNode, parentNode);
			// signature modifications
			if (conditions.originalAuthor === undefined) {
				conditions.originalAuthor = author;
			}
			if (!conditions.alternative && !isVanillaSignature(matchedNodes, conditions.originalAuthor)) {
				// replace signature
				parentNode.classList.add('vanilla-processed');
				parentNode.insertBefore(createSignature(conditions.originalAuthor), firstMatchedNode);
				matchedNodes.forEach(node => node.parentNode.removeChild(node));
				modified.push(parentNode);
				replaced = true;
			}
		}

		if (!replaced) {
			// remove style attributes
			styleNodes.forEach(node => node.removeAttribute('style'));
			// review authorNodes
			authorNodes.forEach(node => {
				const reviewedNode = reviewTextNode(node, author);
				if (reviewedNode) {
					modified.push(reviewedNode);
				}
			});
		}

		// debugging
		if (userOptions.debug && matchedNodes.length) {
			matchedNodes.forEach(node => {
				console.debug(`${author}\t${node.nodeType}\t${node.nodeType === 3 ? node.textContent : node.outerHTML}`);
			});
		}

		// return any modified elements
		return modified;
	}

	function filterProcessed(element) {
		if (element.id === 'mw-content-text') {
			element = element.querySelector('div.mw-parser-output') ?? element;
		}
		if (element.classList.contains('vanilla-processed')) {
			return true;
		}
		element.classList.add('vanilla-processed');
		return false;
	}

	function delay(ms) {
		return new Promise(resolve => setTimeout(resolve, ms));
	}

	async function execute($content) {
		// debugging
		if (userOptions.debug) {
			console.debug("Vanilla execute: content", $content, "timestamp", performance.now());
		}
		// process each element in $content
		const modifiedElements = [];
		for (const element of $content) {
			// avoid reprocessing elements
			if (filterProcessed(element))
				continue;
			// select and process signatures
			let links = element.querySelectorAll('a.ext-discussiontools-init-timestamplink');
			let loopCount = 0;
			while (links.length) {
				// avoid infinite loops
				if (++loopCount > 100) {
					if (userOptions.debug) {
						console.warn("Vanilla excecute: maximum loop count exceeded");
					}
					break;
				}
				// process links
				if (userOptions.debug) {
					console.info(`Vanilla execute: processing ${links.length} links`);
				}
				const currentLinks = links;
				links = [];
				for (let i = 0; i < currentLinks.length; i++) {
					const link = currentLinks[i];
					if (link.hasAttribute('data-event-name')) {
						const author = extractAuthor(link);
						if (author) {
							modifiedElements.push(...signatureNodes(link, author));
						}
					} else {
						links.push(link);
					}
				}
				const delayPromise = delay(10);
				// fire the mw.hook if needed
				if (modifiedElements.length) {
					mw.hook('wikipage.content').fire($(modifiedElements));
					modifiedElements.length = 0;
				}
				await delayPromise;
			}
		}
	}

	getUserOption(userOptions, 'userjs-vanilla');
	mw.hook('wikipage.content').add(execute);
});