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() {
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) {
if ( ! mw.config.get('wgCurRevisionId') ) return ''; // if page is deleted, return blank
let api = new mw.Api();
let response = await api.get( {
"action": "parse",
"page": title,
"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',
];
}
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}
});
// </nowiki>