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 :)
TEST CASES:
- AFD: https://en.wikipedia.org/wiki/Wikipedia:Articles_for_deletion/Judd_Hamilton_(2nd_nomination)
- RFC: https://en.wikipedia.org/wiki/Wikipedia:Reliable_sources/Noticeboard/Archive_393#Discussion_(The_Economist)
*/
$(async function() {
let vcc = new VoteCounterController();
await vcc.execute();
});
// 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;
}
let isAFD = this.title.match(/^Wikipedia:Articles_for_deletion\//i);
let isMFD = this.title.match(/^Wikipedia:Miscellany_for_deletion\//i);
let isGAR = this.title.match(/^Wikipedia:Good_article_reassessment\//i);
let listOfValidVoteStrings = this._getListOfValidVoteStrings();
if ( isAFD || isMFD || isGAR ) {
// 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 ( isAFD || isMFD ) {
this.wikicode += "'''delete'''";
}
let vc = new Counter(this.wikicode, listOfValidVoteStrings);
let voteString = vc.getVoteString();
if ( ! voteString ) {
return;
}
if ( isAFD || isMFD ) {
percentsHTML = this._getAfdAndMfdPercentsHtml();
}
allHTML = `<div id="VoteCounter"><span style="font-weight: bold;">${voteString}</span> <small>(approximately)</small>${percentsHTML}</div>`;
$('#contentSub').before(allHTML);
} else {
// make a list of the strpos of each heading
let matches = this.wikicode.matchAll(/(?<=\n)(?===)/g);
let sections = [0];
for ( let match of matches ) {
sections.push(match.index);
}
let isXFD = this.title.match(/_for_(?:deletion|discussion)\//i);
// now that we know where the fenceposts are, calculate everything else, then inject the appropriate HTML
let sectionsLength = sections.length;
for ( let i = 0; i < sectionsLength ; i++ ) {
let startPosition = sections[i];
let lastSection = i === sectionsLength - 1;
let endPosition;
if ( lastSection ) {
endPosition = this.wikicode.length;
} else {
endPosition = sections[i + 1]; // Don't subtract 1. That will delete a character.
}
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 ) {
let proposeMerging = sectionWikicode.match(/'''Propose merging'''/i);
// add a vote for the nominator
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(?::'')? '''[^']+'''/ig, '');
}
let vc = new VoteCounterCounter(sectionWikicode, listOfValidVoteStrings);
let voteSum = vc.getVoteSum();
if ( voteSum < 3 ) continue;
let voteString = vc.getVoteString();
let allHTML = `<div id="VoteCounter" style="color: darkgreen; border: 1px solid black;"><span style="font-weight: bold;">${voteString}</span> <small>(approximately)</small></div>`;
let isLead = startPosition === 0;
if ( isLead ) {
$('#contentSub').before(allHTML);
} else {
let headingForJQuery = vc.getHeadingForJQuery(startPosition);
if ( ! $(headingForJQuery).length ) {
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)
}
$(headingForJQuery).parent().first().after(allHTML); // prepend is interior, before is exterior
}
}
}
}
_getAfdAndMfdPercentsHtml() {
let counts = {};
let votes = vc.getVotes();
for ( let key of 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(wikicode) {
// remove == == from headings
wikicode = wikicode.replace(/^=+\s*/, '');
wikicode = wikicode.replace(/\s*=+$/, '');
// remove wikilinks
wikicode = wikicode.replace(/\[\[:?/g, '');
wikicode = wikicode.replace(/\]\]/g, '');
// remove bold and italic
wikicode = wikicode.replace(/'{2,5}/g, '');
// convert spaces to _
wikicode = wikicode.replace(/ /g, '_');
return wikicode;
}
_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>