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.

// <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 () {

	await mw.loader.using(  'mediawiki.api' ], async function () {

		await ( new VoteCounterController() ).execute();

	} );

} );



/*

TEST CASES:

- don't count sections (AFD): /info/en/?search=Wikipedia:Articles_for_deletion/Judd_Hamilton_(2nd_nomination)

- count sections (RFC): /info/en/?search=Wikipedia:Reliable_sources/Noticeboard/Archive_393#Discussion_(The_Economist)

- count sections and adjust !votes (RFD): /info/en/?search=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. /info/en/?search=Wikipedia:Categories_for_discussion/Log/2021_December_10#Category:Cornish_emigrans_and_related_subcats

// TODO: put a "days/hours left" timer at the top of AFDs. will need to check for relisting messages, and for page creation date





// === 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 );

		const 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 );

		const voteString = this.vcc.getVoteString();

		if ( !voteString ) {

			return;

		}



		let percentsHTML = '';

		if ( this.isAfd || this.isMfd ) {

			percentsHTML = this._getAfdAndMfdPercentsHtml();

		}



		// generate HTML

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



		this._insertHtmlAtTopOnly( allHTML );

	}



	_countVotesForEachHeading() {

		const listOfHeadingLocations = this._getListOfHeadingLocations( this.wikicode );

		const isXFD = this.title.match( /_for_(?:deletion|discussion)\//i );

		const numberOfHeadings = listOfHeadingLocations.length;



		// foreach heading

		for ( let i = 0; i < numberOfHeadings; i++ ) {

			const startPosition = listOfHeadingLocations i ];



			const 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

			const voteSum = this.vcc.getVoteSum();

			if ( voteSum < 3 ) {

				continue;

			}



			const voteString = this.vcc.getVoteString();

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



			this._insertHtmlAtEachHeading( startPosition, allHTML );

		}

	}



	_adjustVotesForEachHeading( sectionWikicode ) {

		// add a vote for the nominator

		const 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 ) {

		const isLead = startPosition === 0;

		if ( isLead ) {

			// insert HTML

			$( '#contentSub' ).before( allHtml );

		} else { // if ( isHeading )

			const headingForJQuery = this.vcc.getHeadingForJQuery( startPosition );



			const 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 );

		}

	}



	_insertHtmlAtTopOnly( allHtml ) {

		$( '#contentSub' ).before( allHtml );

	}



	_calculateSectionEndPosition( i, numberOfHeadings, wikicode, listOfHeadingLocations ) {

		const 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 ) {

		const matches = wikicode.matchAll( /(?<=\n)(?===)/g );

		const listOfHeadingLocations =  0 ]; // start with 0. count the lead as a heading

		for ( const match of matches ) {

			listOfHeadingLocations.push( match.index );

		}

		return listOfHeadingLocations;

	}



	_getAfdAndMfdPercentsHtml() {

		const counts = {};

		const votes = this.vcc.getVotes();

		for ( const key of this.listOfValidVoteStrings ) {

			let value = votes key ];

			if ( typeof value === 'undefined' ) {

				value = 0;

			}

			counts key  = value;

		}

		const keep = counts.keep + counts.stubify + counts.stubbify + counts.TNT;

		const _delete = counts.delete + counts.redirect + counts.merge + counts.draftify + counts.userfy;

		const total = keep + _delete;

		let keepPercent = keep / total;

		let deletePercent = _delete / total;

		keepPercent = Math.round( keepPercent * 100 );

		deletePercent = Math.round( deletePercent * 100 );

		const 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() {

		const 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

		const revisionID = mw.config.get( 'wgRevisionId' );

		if ( !revisionID ) {

			return '';

		}



		const api = new mw.Api();

		const 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',

			'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',

			'no change',

			// move review

			'endorse',

			'overturn',

			'relist',

			'procedural close',

			// GAR

			'delist',

			// 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',

			// AARV

			'bad block',

			'do not endorse',

			// AN RFC challenge

			'vacate'

		];

	}



	async _shouldRun() {

		// don't run when not viewing articles

		const 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

		const isEnglishWikipedia = mw.config.get( 'wgDBname' ) === 'enwiki';

		if ( isEnglishWikipedia ) {

			const namespace = mw.config.get( 'wgNamespaceNumber' );

			const isNotTalkNamespace = !mw.Title.isTalkNamespace( namespace );

			const isNotWikipediaNamespace = namespace !== 4;

			const 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

		const yesNoVotesForSurePresent = this.modifiedWikicode.match( /('''yes'''|'''no''')/gi );

		if ( !yesNoVotesForSurePresent ) {

			delete this.votes.yes;

			delete this.votes.no;

		}



		for ( const count of Object.entries( this.votes ) ) {

			this.voteSum += count 1 ];

		}



		this.voteString = '';

		for ( const 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() {

		const firstLine = this.originalWikicode.split( '\n' )[ 0 ];

		const htmlHeadingID = this._convertWikicodeHeadingToHTMLSectionID( firstLine );

		// Must use [id=""] instead of # here, because the ID may have characters not allowed in a normal ID. A normal ID can only have [a-zA-Z0-9_-], and some other restrictions.

		const 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 ( const voteToCount of this.votesToCount ) {

			const 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

			const matches = this.modifiedWikicode.match( regex );

			const 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, '' );

		// handle {{t}} and {{tlx}}

		lineOfWikicode = lineOfWikicode.replace( /\{\{t\|/gi, '{{' );

		lineOfWikicode = lineOfWikicode.replace( /\{\{tlx\|/gi, '{{' );

		// handle {{u}}

		lineOfWikicode = lineOfWikicode.replace( /\{\{u\|([^}]+)\}\}/gi, '$1' );

		// convert multiple spaces to one space

		lineOfWikicode = lineOfWikicode.replace( / {2,}/gi, ' ' );



		// convert spaces to _

		lineOfWikicode = lineOfWikicode.replace( / /g, '_' );



		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>