User:Novem Linguae/Scripts/VoteCounter.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
Documentation for this user script can be added at User:Novem Linguae/Scripts/VoteCounter. |
// <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): 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 = `<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, '&' )
.replace( /</g, '<' )
.replace( />/g, '>' )
.replace( /"/g, '"' )
.replace( /'/g, ''' );
}
}
});
// </nowiki>