User:Novem Linguae/Scripts/VoteCounter.js

//

// === 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): 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 // 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 = ` ${ voteString } (approximately) ${ percentsHTML } `;

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 = ` ${ voteString } (approximately) `;

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 = ` ${ keepPercent }% Keep-ish, ${ deletePercent }% Delete-ish `; 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>/gmi, '' ); this.modifiedWikicode = this.modifiedWikicode.replace( / [^<]*<\/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. 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 undefined and 		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, '&gt;' ) .replace( /"/g, '&quot;' )			.replace( /'/g, '&#039;' );	} }

});

//