"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);
});