Jump to content

User:Novem Linguae/Scripts/VoteCounter.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Novem Linguae (talk | contribs) at 14:59, 25 June 2023 (add some ITN words (publish.php)). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
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>

// === Compiled with Novem Linguae's publish.php script ======================

$(async function() {

// === VoteCounter.js ======================================================

/*
- Gives an approximate count of keeps, deletes, supports, opposes, etc. in deletion discussions and RFCs.
	- For AFD, MFD, and GAR, displays them at top of page.
	- For everything else, displays them by the section heading.
- Counts are approximate. If people do weird things like '''Delete/Merge''', it will be counted twice.
- Adds an extra delete vote to AFDs and MFDs, as it's assumed the nominator is voting delete.
- If you run across terms that aren't counted but should be, leave a message on the talk page. Let's add as many relevant terms as we can :)

*/

$(async function() {
	let vcc = new VoteCounterController();
	await vcc.execute();
});

/*
TEST CASES:
- don't count sections (AFD): https://en.wikipedia.org/wiki/Wikipedia:Articles_for_deletion/Judd_Hamilton_(2nd_nomination)
- count sections (RFC): https://en.wikipedia.org/wiki/Wikipedia:Reliable_sources/Noticeboard/Archive_393#Discussion_(The_Economist)
- count sections and adjust !votes (RFD): https://en.wikipedia.org/wiki/Wikipedia:Redirects_for_discussion/Log/2022_January_1

BUGS:
- There's an extra delete vote in closed RFDs
*/

// TODO: write a parser that keeps track of pairs of ''', to fix issue with '''vote''' text '''vote''' sometimes counting the text between them

// TODO: handle CFD big merge lists, e.g. https://en.wikipedia.org/wiki/Wikipedia:Categories_for_discussion/Log/2021_December_10#Category:Cornish_emigrans_and_related_subcats

// === modules/VoteCounterController.js ======================================================

class VoteCounterController {
	async execute() {
		if ( ! await this._shouldRun() ) {
			return;
		}

		this.isAfd = this.title.match(/^Wikipedia:Articles_for_deletion\//i);
		this.isMfd = this.title.match(/^Wikipedia:Miscellany_for_deletion\//i);
		let isGAR = this.title.match(/^Wikipedia:Good_article_reassessment\//i);

		this.listOfValidVoteStrings = this._getListOfValidVoteStrings();

		if ( this.isAfd || this.isMfd || isGAR ) {
			this._countVotesForEntirePage();
		} else {
			this._countVotesForEachHeading();
		}
	}

	_countVotesForEntirePage() {
		// delete everything above the first heading, to prevent the closer's vote from being counted
		this.wikicode = this.wikicode.replace(/^.*?(===.*)$/s, '$1');

		// add a delete vote. the nominator is assumed to be voting delete
		if ( this.isAfd || this.isMfd ) {
			this.wikicode += "'''delete'''";
		}

		this.vcc = new VoteCounterCounter(this.wikicode, this.listOfValidVoteStrings);
		let voteString = this.vcc.getVoteString();
		if ( ! voteString ) {
			return;
		}

		let percentsHTML = '';
		if ( this.isAfd || this.isMfd ) {
			percentsHTML = this._getAfdAndMfdPercentsHtml();
		}

		// generate HTML
		let allHTML = `<div id="VoteCounter"><span style="font-weight: bold;">${voteString}</span> <small>(approximately)</small>${percentsHTML}</div>`;

		this._insertHtmlAtTopOnly(allHTML);
	}

	_countVotesForEachHeading() {
		let listOfHeadingLocations = this._getListOfHeadingLocations(this.wikicode);
		let isXFD = this.title.match(/_for_(?:deletion|discussion)\//i);
		let numberOfHeadings = listOfHeadingLocations.length;

		// foreach heading
		for ( let i = 0; i < numberOfHeadings ; i++ ) {
			let startPosition = listOfHeadingLocations[i];

			let endPosition = this._calculateSectionEndPosition(i, numberOfHeadings, this.wikicode, listOfHeadingLocations);
			
			let sectionWikicode = this.wikicode.slice(startPosition, endPosition); // slice and substring (which both use (startPos, endPos)) are the same. substr(startPos, length) is deprecated.

			if ( isXFD ) {
				sectionWikicode = this._adjustVotesForEachHeading(sectionWikicode);
			}

			this.vcc = new VoteCounterCounter(sectionWikicode, this.listOfValidVoteStrings);

			// don't display votecounter string if there's less than 3 votes in the section
			let voteSum = this.vcc.getVoteSum();
			if ( voteSum < 3 ) {
				continue;
			}

			let voteString = this.vcc.getVoteString();
			let allHTML = `<div id="VoteCounter" style="color: darkgreen; border: 1px solid black;"><span style="font-weight: bold;">${voteString}</span> <small>(approximately)</small></div>`;

			this._insertHtmlAtEachHeading(startPosition, allHTML);
		}
	}

	_adjustVotesForEachHeading(sectionWikicode) {
		// add a vote for the nominator
		let proposeMerging = sectionWikicode.match(/'''Propose merging'''/i);
		if ( proposeMerging ) {
			sectionWikicode += "'''merge'''";
		} else {
			sectionWikicode += "'''delete'''";
		}

		// delete "result of the discussion was X", to prevent it from being counted
		sectionWikicode = sectionWikicode.replace(/The result of the discussion was.*'''[^']+'''.*$/igm, '');

		return sectionWikicode;
	}

	_insertHtmlAtEachHeading(startPosition, allHtml) {
		let isLead = startPosition === 0;
		if ( isLead ) {
			// insert HTML
			$('#contentSub').before(allHtml);
		} else { // if ( isHeading )
			let headingForJQuery = this.vcc.getHeadingForJQuery(startPosition);

			let headingNotFound = ! $(headingForJQuery).length;
			if ( headingNotFound ) {
				console.error('User:Novem Linguae/Scripts/VoteCounter.js: ERROR: Heading ID not found. This indicates a bug in _convertWikicodeHeadingToHTMLSectionID() that Novem Linguae needs to fix. Please report this on his talk page along with the page name and heading ID. The heading ID is: ' + headingForJQuery)
			}

			// insert HTML
			$(headingForJQuery).parent().first().after(allHtml); // prepend is interior, before is exterior
		}
	}

	_insertHtmlAtTopOnly(allHtml) {
		$('#contentSub').before(allHtml);
	}

	_calculateSectionEndPosition(i, numberOfHeadings, wikicode, listOfHeadingLocations) {
		let lastSection = i === numberOfHeadings - 1;
		if ( lastSection ) {
			return wikicode.length;
		} else {
			return listOfHeadingLocations[i + 1]; // Don't subtract 1. That will delete a character.
		}
	}

	_getListOfHeadingLocations(wikicode) {
		let matches = wikicode.matchAll(/(?<=\n)(?===)/g);
		let listOfHeadingLocations = [0]; // start with 0. count the lead as a heading
		for ( let match of matches ) {
			listOfHeadingLocations.push(match.index);
		}
		return listOfHeadingLocations;
	}

	_getAfdAndMfdPercentsHtml() {
		let counts = {};
		let votes = this.vcc.getVotes();
		for ( let key of this.listOfValidVoteStrings ) {
			let value = votes[key];
			if ( typeof value === 'undefined' ) {
				value = 0;
			}
			counts[key] = value;
		}
		let keep = counts['keep'] + counts['stubify'] + counts['stubbify'] + counts['TNT'];
		let _delete = counts['delete'] + counts['redirect'] + counts['merge'] + counts['draftify'] + counts['userfy'];
		let total = keep + _delete;
		let keepPercent = keep / total;
		let deletePercent = _delete / total;
		keepPercent = Math.round(keepPercent * 100);
		deletePercent = Math.round(deletePercent * 100);
		let percentsHTML = `<br /><span style="font-weight: bold;">${keepPercent}% <abbr this.title="Keep, Stubify, TNT">Keep-ish</abbr>, ${deletePercent}% <abbr this.title="Delete, Redirect, Merge, Draftify, Userfy">Delete-ish</abbr></span>`;
		return percentsHTML;
	}

	async _getWikicode(title) {
		let isDeletedPage = ! mw.config.get('wgCurRevisionId');
		if ( isDeletedPage ) {
			return '';
		}

		// grab title by revision ID, not by page title. this lets it work correctly if you're viewing an old revision of the page
		let revisionID = mw.config.get('wgRevisionId');

		let api = new mw.Api();
		let response = await api.get( {
			"action": "parse",
			"oldid": revisionID,
			"prop": "wikitext",
			"formatversion": "2",
			"format": "json"
		} );
		return response['parse']['wikitext'];
	}

	/** returns the pagename, including the namespace name, but with spaces replaced by underscores */
	_getArticleName() {
		return mw.config.get('wgPageName');
	}

	_getListOfValidVoteStrings() {
		return [
			// AFD
			'keep',
			'delete',
			'merge',
			'draftify',
			'userfy',
			'redirect',
			'stubify',
			'stubbify',
			'TNT',
			// RFC
			'support',
			'oppose',
			'neutral',
			// move review
			'endorse',
			'overturn',
			'relist',
			'procedural close',
			// GAR
			'delist',
			// RFC
			'option 1',
			'option 2',
			'option 3',
			'option 4',
			'option 5',
			'option 6',
			'option 7',
			'option 8',
			'option A',
			'option B',
			'option C',
			'option D',
			'option E',
			'option F',
			'option G',
			'option H',
			'yes',
			'no',
			'bad rfc',
			'remove',
			'include',
			'exclude',
			// RSN
			'agree',
			'disagree',
			'status quo',
			'(?<!un)reliable',
			'unreliable',
			// RFD
			'(?<!re)move',
			'retarget',
			'disambiguate',
			'withdraw',
			'setindex',
			'refine',
			// MFD
			'historical', // mark historical
			// TFD
			'rename',
			// ITN
			'pull',
			'wait',
		];
	}

	async _shouldRun() {
		// don't run when not viewing articles
		let action = mw.config.get('wgAction');
		if ( action != 'view' ) {
			return false;
		}

		this.title = this._getArticleName();

		// only run in talk namespaces (all of them) or Wikipedia namespace
		let isEnglishWikipedia = mw.config.get('wgDBname') === 'enwiki';
		if ( isEnglishWikipedia ) {
			let namespace = mw.config.get('wgNamespaceNumber');
			let isNotTalkNamespace = ! mw.Title.isTalkNamespace(namespace);
			let isNotWikipediaNamespace = namespace !== 4;
			let isNotNovemLinguaeSandbox = this.title != 'User:Novem_Linguae/sandbox';
			if ( isNotTalkNamespace && isNotWikipediaNamespace && isNotNovemLinguaeSandbox ) {
				return false;
			}
		}

		// get wikitext
		this.wikicode = await this._getWikicode(this.title);
		if ( ! this.wikicode ) {
			return;
		}
		
		return true;
	}
};

// === modules/VoteCounterCounter.js ======================================================

class VoteCounterCounter {
	/** Count the votes in this constructor. Then use a couple public methods (below) to retrieve the vote counts in whatever format the user desires. */
	constructor(wikicode, votesToCount) {
		this.originalWikicode = wikicode;
		this.modifiedWikicode = wikicode;
		this.votesToCount = votesToCount;
		this.voteSum = 0;
		
		this._countVotes();

		if ( ! this.votes ) return;
		
		// if yes or no votes are not present in wikitext, but are present in the votes array, they are likely false positives, delete them from the votes array
		let yesNoVotesForSurePresent = this.modifiedWikicode.match(/('''yes'''|'''no''')/gi);
		if ( ! yesNoVotesForSurePresent ) {
			delete this.votes.yes;
			delete this.votes.no;
		}

		for ( let count of Object.entries(this.votes) ) {
			this.voteSum += count[1];
		}

		this.voteString = '';
		for ( let key in this.votes ) {
			let humanReadable = key;
			humanReadable = humanReadable.replace(/\(\?<!.+\)/, ''); // remove regex lookbehind
			humanReadable = this._capitalizeFirstLetter(humanReadable);
			this.voteString += this.votes[key] + ' ' + humanReadable + ', ';
		}
		this.voteString = this.voteString.slice(0, -2); // trim extra comma at end
		
		this.voteString = this._htmlEscape(this.voteString);
	}

	getHeadingForJQuery() {
		let firstLine = this.originalWikicode.split('\n')[0];
		let htmlHeadingID = this._convertWikicodeHeadingToHTMLSectionID(firstLine);
		let jQuerySearchString = '[id="' + this._doubleQuoteEscape(htmlHeadingID) + '"]';
		return jQuerySearchString;
	}

	getVotes() {
		return this.votes;
	}

	getVoteSum() {
		return this.voteSum;
	}

	/* HTML escaped */
	getVoteString() {
		return this.voteString;
	}

	_countRegExMatches(matches) {
		return (matches || []).length;
	}

	_capitalizeFirstLetter(str) {
		return str.charAt(0).toUpperCase() + str.slice(1);
	}
		
	_countVotes() {
		// delete all strikethroughs
		this.modifiedWikicode = this.modifiedWikicode.replace(/<strike>[^<]*<\/strike>/gmi, '');
		this.modifiedWikicode = this.modifiedWikicode.replace(/<s>[^<]*<\/s>/gmi, '');
		this.modifiedWikicode = this.modifiedWikicode.replace(/{{S\|[^}]*}}/gmi, '');
		this.modifiedWikicode = this.modifiedWikicode.replace(/{{Strike\|[^}]*}}/gmi, '');
		this.modifiedWikicode = this.modifiedWikicode.replace(/{{Strikeout\|[^}]*}}/gmi, '');
		this.modifiedWikicode = this.modifiedWikicode.replace(/{{Strikethrough\|[^}]*}}/gmi, '');

		this.votes = {};
		for ( let voteToCount of this.votesToCount ) {
			let regex = new RegExp("'''[^']{0,30}"+voteToCount+"(?!ing comment)[^']{0,30}'''", "gmi"); // limit to 30 chars to reduce false positives. sometimes you can have '''bold''' bunchOfRandomTextIncludingKeep '''bold''', and the in between gets detected as a keep vote
			let matches = this.modifiedWikicode.match(regex);
			let count = this._countRegExMatches(matches);
			if ( ! count ) continue; // only log it if there's votes for it
			this.votes[voteToCount] = count;
		}
	}

	_convertWikicodeHeadingToHTMLSectionID(lineOfWikicode) {
		// remove == == from headings
		lineOfWikicode = lineOfWikicode.replace(/^=+\s*/, '');
		lineOfWikicode = lineOfWikicode.replace(/\s*=+\s*$/, '');
		// handle piped wikilinks, e.g. [[User:abc|abc]]
		lineOfWikicode = lineOfWikicode.replace(/\[\[[^\[\|]+\|([^\[\|]+)\]\]/gi, '$1');
		// remove wikilinks
		lineOfWikicode = lineOfWikicode.replace(/\[\[:?/g, '');
		lineOfWikicode = lineOfWikicode.replace(/\]\]/g, '');
		// remove bold and italic
		lineOfWikicode = lineOfWikicode.replace(/'{2,5}/g, '');
		// convert spaces to _
		lineOfWikicode = lineOfWikicode.replace(/ /g, '_');
		// handle {{t}} and {{tlx}}
		lineOfWikicode = lineOfWikicode.replace(/\{\{t\|/gi, '{{');
		lineOfWikicode = lineOfWikicode.replace(/\{\{tlx\|/gi, '{{');
		// handle {{u}}
		lineOfWikicode = lineOfWikicode.replace(/\{\{u\|([^\}]+)\}\}/gi, '$1');
		return lineOfWikicode;
	}

	_jQueryEscape(str) {
		return str.replace(/(:|\.|\[|\]|,|=|@)/g, "\\$1");
	}

	_doubleQuoteEscape(str) {
		return str.replace(/"/g, '\\"');
	}

	_htmlEscape(unsafe)	{
		return unsafe
			.replace(/&/g, "&amp;")
			.replace(/</g, "&lt;")
			.replace(/>/g, "&gt;")
			.replace(/"/g, "&quot;")
			.replace(/'/g, "&#039;");
	}
}

});

// </nowiki>