User:Rillke/logo 2013 voy count.js

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
// Copyright 2013 by Rainer Rillke
// The script must be run in a JavaScript console.
// The POTY-object must be present ( https://commons.wikimedia.org/wiki/MediaWiki:EnhancedPOTY.js ).
// The config must be merged into the POTY object. So it's best if you run it on a gallery of the competition.
// Very hackish and crappy but... ah and it takes about an hour!
var gf, poty = POTY;

function firstItem(o) {
    for (var i in o) {
		if (o.hasOwnProperty(i)) {
			return o[i];
		}
	}
}

$.inTimestamps = function (ts, tss) {
	var found;
	$.each(tss, function (k, v) {
		if (ts === v.ts) {
			found = v;
			return false;
		}
	});
	return found;
}

var users = [],
	/*
	{ example: {
		votes: {},
		eligibility: {}
	} }
	*/
	pages2Dig = [],
	userInfo = {},
	usersToCheck = [],
	votes = 0;
	
poty.tasks =[];
poty.addTask('getDbList');
poty.nextTask();

function UserInfo(username) {
	this.name = username;
	this.votes = new UserVotes();
	this.eligibility = {};
}
UserInfo.prototype = $.extend(true, UserInfo.prototype, {
	// You MUST NOT RUN multiple queries at once!
	$checkEligibility: function () {
		var $def = $.Deferred();
		// First check, whether we have a voyager
		var ca = [],
			possibleWikis = [],
			totalVoyageEdits = 0,
			u = this.name,
			_t = this;
		var _gotCentralAuthPart = function (r) {
			if (!r.query.globaluserinfo) return;
			ca = ca.concat(r.query.globaluserinfo.merged || []);
		};
		var _generalEligibility = function () {
			// Hack into POTY script
			var poty = window.POTY;
			poty.tasks = [];
			delete poty.data.eligible;
			poty.username = window.debugPOTYUserName = u;
			poty.currentEditGroup = -1;
			poty.queriesRunning = 0;
			poty.showEligible = function () {
				console.info('eligible', poty.data.eligible);
				_t.eligibility = poty.data.eligible;
				$def.resolve();
			}
			poty.showIneligible = function () {
				console.info('ineligible', poty.data.ineligible);
				_t.eligibility = false;
				$def.resolve();
			}
			poty.data.sulmissing = false;
			poty.addTask('checkSUL');
			//poty.addTask('getDbList');
			poty.addTask('checkLocalExistingContribs');
			poty.addTask('checkGlobalExistingContribs');
			poty.nextTask();
		};
		var _wikivoyageEligibility = function () {
			var pending = 0,
				poty = window.POTY,
				contribs = 0,
				contribsByWiki = {};
			poty.checkWikiVoyageCb = function (r) {
				pending--;
				var c = r.query.usercontribs.length;
				contribs += c;
				contribsByWiki[r.requestid] = c;
				if (0 === pending) {
					_t.eligibility.voyagerInfo = contribsByWiki;
					if (contribs >= 50) {
						_t.eligibility.voyager = true;
						console.info(u + ' is a Wikivoyager!');
						$def.resolve();
					} else {
						_generalEligibility();
					}
				}
			};
			// Send-out all queries at once
			$.each(possibleWikis, function (i, wiki) {
				pending++;
				poty.queryAPI({
					action: 'query',
					list: 'usercontribs',
					ucuser: u,
					ucstart: poty.required.editsBefore,
					uclimit: 50,
					ucnamespace: 0,
					ucprop: '',
					requestid: wiki.wiki
				}, 'checkWikiVoyageCb', wiki.url + '/w/api.php');
			});
		};
		var _centralAuthDone = function () {
			$.each(ca, function (i, wiki) {
				if (/wikivoyage$/.test(wiki.wiki) && Number(wiki.editcount) > 0) {
					possibleWikis.push(wiki);
					totalVoyageEdits += wiki.editcount;
				}
			});
			if (totalVoyageEdits >= 50) {
				console.info('User ' + u + ' has possibly 50 edits spread over ' + possibleWikis.length + ' wikivoyages');
				// Now send-out the JSONP-requests
				_wikivoyageEligibility();
			} else {
				console.info('User ' + u + ' is not a voyager');
				_generalEligibility();
			}
		};

		mw.libs.commons.api.$autoQuery({
			action: 'query',
			meta: 'globaluserinfo',
			guiprop: 'merged',
			guiuser: this.name
		}).progress(_gotCentralAuthPart).done(_centralAuthDone).fail(function () {
			console.error(arguments);
		});

		return $def;
	}
});

function UserVotes() {
	this.votesByCandidate = {};
	this.votesByTimestamp = [];
}
UserVotes.prototype = $.extend(true, UserVotes.prototype, {
	add: function (candidate, timestamp, diff) {
		// Votes must be pre-registered
		// This prevents adding votes that were removed by the user later
		if (timestamp && undefined === this.votesByCandidate[candidate]) {
			return;
		}

		// Pre-register a vote
		if (!timestamp) {
			return this.votesByCandidate[candidate] = '';
		}

		// The date must be greater than existing dates
		// This is to find the occurence of the user's last choice for one candidate
		// The API-timestamp is in format YYYY-MM-ddThh:mm:ssZ
		if (timestamp > this.votesByCandidate[candidate]) {
			this.votesByCandidate[candidate] = timestamp;
			// The rules were clear in this regard. If a user managed to vote 2 logos the same time, 
			// this is not our problem but let's log it
			var v = $.inTimestamps(timestamp, this.votesByTimestamp);
			if (v) {
				console.warn("2 votes at same time encountered: " + v.ts + ", " + candidate + " |" + timestamp);
			}
			this.votesByTimestamp.push({
				ts: timestamp,
				candidate: candidate,
				diff: diff
			});
		}
	},
	// Return the first 3 candidates chosen by the user but
	// sort them by occurence of the last vote so their 1st 2nd and 3rd choice are correctly reported
	getValidVotes: function () {
		// First, drop everything that is not the last vote for one file
		var _t = this,
			usableVotes = $.grep(this.votesByTimestamp, function (vote, i) {
				return vote.ts === _t.votesByCandidate[vote.candidate];
			});

		usableVotes.sort(function (a, b) {
			return (a.ts > b.ts) ? 1 : -1;
		});

		// Now we have oldest votes first. Return the 3 first voted candidates
		var tooMany = usableVotes.slice(3);
		if (tooMany.length) console.info('Too many times voted: ', tooMany);
		return usableVotes.slice(0, 3);
	},
	eliminate = function(candidate) {
		var vs = this.getValidVotes(),
			x;

		$.each(vs, function(i, vote) {
			if (vote.candidate === candidate) {
				x = vs.splice(i, 1);
				return false;
			}
		});
		this.votesByTimestamp = vs;
		return x;
	}
});

var displayCandatesInfo = function () {
	var tbl = [],
		byCandidate = {}, c = 0;
	var td = function (inner) {
		return '<td>' + inner + '</td>';
	};
	var file = function (k) {
		return td('[[File:' + k + '|135px]]<br />[[File:' + k + '|16px]] <tt>' + k + '</tt>');
	};
	var voters = function (cand) {
		var out = [];
		$.each(cand, function (i, v) {
			var item = '[[User:' + v.user + '|' + v.user + ']] (' + (v.number + 1) + ')';

			if (v.eligibility.voyager) {
				item = '<span style="background:#0DA">' + item + '</span>';
			}
			out.push(item);
		});
		return td(out.join(', '));
	};
	var getVotes = function (cand, round, voyager) {
		var count = 0;
		$.each(cand, function (i, v) {
			if (v.number === round) {
				if (voyager && !v.eligibility.voyager) return;
				count++;
			}
		});
		return count;
	};
	$.each(window.voyResults, function (u, userInfo) {
		var votes = userInfo.votes.getValidVotes();
		$.each(votes, function (i, v) {
			if (!(v.candidate in byCandidate)) byCandidate[v.candidate] = [];
			var cand = byCandidate[v.candidate];
			v.user = u;
			v.eligibility = userInfo.eligibility;
			v.number = i;
			cand.push(v);
			if (!cand.voyagers) cand.voyagers = 0;
			if (v.eligibility.voyager) cand.voyagers++;
		});
	});
	$.each(byCandidate, function (k, cand) {
		c++;
		var r0v = getVotes(cand, 0, true),
			r0a = getVotes(cand, 0, false),
			r0r = Math.min(r0v * 2, r0a);

		tbl.push('<tr>' + td(c) + file(k) + td(cand.voyagers) + td(cand.length) + voters(cand) +
			td(r0a) + td(r0v) + td(r0r) + '</tr>');
	});

	$('<pre>').text(tbl.join('\n')).appendTo('body');
};

// Do one round of the IRV
var irv = function () {
	// First votes by candidate
	var fvbc = {},
	// candidates by vote count
		cbvc = [];
		
	$.each(window.voyResults, function (u, userInfo) {
		var v = userInfo.votes.getValidVotes(),
			firstVote = v[0];
		// User's votes were shifted already
		if (!firstVote) return;
		fvbc[firstVote.candidate] = fvbc[firstVote.candidate] || { voy:[], n:[] };
		if (userInfo.eligibility.voyager) {
			fvbc[firstVote.candidate].voy.push(firstVote);
		}
		fvbc[firstVote.candidate].n.push(firstVote);
	});
	
	// Group by vote count
	$.each(fvbc, function (c, votes) {
		var len = Math.min(votes.voy.length * 2, votes.n.length);
		cbvc[len] = cbvc[len] || [];
		cbvc[len].push(c);
	});
	
	// Look whom to drop
	var toDrop;
	$.each(cbvc, function (count, candidates) {
		if (candidates && candidates.length) {
			toDrop = candidates;
			return false;
		}
	});
	
	// Shift votes
	var toReassign = [];
	$.each(toDrop, function (i, candidate) {
		console.log('Dropping ' + candidate);
		$('<pre>').text('Dropping ' + candidate).appendTo('body');
		$.each(window.voyResults, function(u, userInfo) {
			////v.shiftOne();
			var r = userInfo.votes.eliminate(candidate);
			if (r) {
				toReassign.push(userInfo.name);
				console.log('@' + userInfo.name);
			}
		});
	});
	$('<pre>').text('Voters of dropped candidate (to have their vote re-assigned): ' + toReassign.join(',')).appendTo('body');
	// Candidate dropped.
};

var displayVoterInfo = function () {
	var tbl = [],
		c = 0,
		voyagers = 0;
	var td = function (inner) {
		return '<td>' + inner + '</td>';
	};
	var voyage2Table = function (vi, u) {
		voyagers++;
		var vth = '',
			vtb = '';
		$.each(vi, function (db, edits) {
			var lang = db.replace('wikivoyage', '');
			vth += '<th>[[:voy:' + lang + ':' + u + '|' + lang + ']]</th>';
			vtb += td(edits);
		});
		return '<table><tr>' + vth + '</tr><tr>' + vtb + '</tr></table>';
	};
	var elig2UI = function (usi) {
		if (!usi.eligibility) {
			return 'not verified';
		} else if (usi.eligibility.voyager) {
			return 'voyager ' + voyage2Table(usi.eligibility.voyagerInfo, usi.name);
		} else if (usi.eligibility.edits) {
			return 'general (' + usi.eligibility.on.name + ')';
		}
	};
	var votes2UI = function (usi) {
		var out = '';
		$.each(usi.votes.getValidVotes(), function (i, obj) {
			out += '\n# [//meta.wikimedia.org/wiki/?diff=' + obj.diff + ' ' + obj.ts.replace('T', ' ').replace('Z', '') + ' ++ ' + obj.candidate + ']'
		});
		return out;
	};
	$.each(window.voyResults, function (u, userInfo) {
		c++;
		if (u === "Rillke") console.log(userInfo.votes.getValidVotes())
		tbl.push('<tr>' + td(c) + td(u) + td(elig2UI(userInfo)) + '\n' + td(votes2UI(userInfo)) + '\n' + '</tr>');
	});
	$('<pre>').text(tbl.join('\n')).appendTo('body');
};

var exportJSON = function() {
	mw.loader.load('jquery.json', function() {
		$('<pre>').text($.toJSON(window.voyResults)).appendTo('body');
	});
};

var fetchVoterInfo = function () {
	console.info("Now fetching voter info.", userInfo);

	var _next = function () {
		if (usersToCheck.length === 0) {
			window.voyResults = userInfo;
			console.log(window.voyResults);
			displayVoterInfo();
			displayCandatesInfo();
			exportJSON();
			alert("Done!");
			return;
		}
		usersToCheck.pop().$checkEligibility().done(_next).fail(_next);
	};
	_next();
};

var currentVoters = [],
	previousVoters = [];

var gotPageHist = function (r) {
	var pg = firstItem(r.query.pages);

	console.log('Doing ' + pg.title, r);
	$.each(pg.revisions, function (ir, rv) {
		var c = rv['*'],
			t = pg.title,
			f = poty.getFileNameFromPageName(t),
			m = c.match(poty.genericVoteRegExp),
			l = 0;

		// Empty page? First revision?
		if (!m) return;
		currentVoters = [];
		$.each(m, function (i, vote) {
			var u = vote.replace(/\n# \[\[User:(.+?)\|.+\]\]/, '$1');
			currentVoters.push(u);
			// Was this a new voter?
			if ($.inArray(u, previousVoters) === -1) {
				if (u !== rv.user) console.warn("Vote manipulation detected: " + rv.user + " claims to be " + u, t, rv.timestamp, previousVoters.length);
				if (!(u in userInfo)) {
					console.info(u + " is not in userInfo object! Skipping.");
				} else {
					userInfo[u].votes.add(f, rv.timestamp, rv.revid);
				}
			}
		});
		previousVoters = currentVoters;
	});
};

var digPageHist = function (result) {
	previousVoters = [];
	if (!pages2Dig.length) return fetchVoterInfo();
	mw.libs.commons.api.$autoQuery({
		action: 'query',
		titles: pages2Dig.pop(),
		prop: 'revisions',
		rvprop: 'content|user|ids|timestamp',
		rvlimit: 50,
		rvdir: 'newer'
	}).progress(gotPageHist).done(digPageHist).fail(function () {
		console.error(arguments);
	});
};

poty.genericVoteRegExp = new RegExp(poty.mdEscapeSpecial($.escapeRE(poty.votingFormat)).replace(/%UserName%/g, '[^\\|\\[\\]]+'), 'g');
var _gotPages = function (r) {
	var pgs = r.query.pages;
	$.each(pgs, function (ids, pg) {
		var c = pg.revisions[0]['*'],
			t = pg.title,
			f = poty.getFileNameFromPageName(t),
			m = c.match(poty.genericVoteRegExp),
			l = 0;

		if (!f) return;
		if (m) {
			l = m.length;
			votes += l;
			$.each(m, function (i, vote) {
				var u = vote.replace(/\n# \[\[User:(.+?)\|.+\]\]/, '$1');
				if ($.inArray(u, users) < 0) {
					users.push(u);
					userInfo[u] = new UserInfo(u);
					usersToCheck.push(userInfo[u]);
				}
				// Pre-register candidate
				userInfo[u].votes.add(f);
			});
			pages2Dig.push(pg.title);
		}
	});
	console.info("Votes pre-registered.", userInfo);
	digPageHist();
};


mw.libs.commons.api.$autoQuery({
	action: 'query',
	generator: 'allpages',
	gapnamespace: 0,
	gapfilterredir: 'nonredirects',
	gaplimit: 100,
	gapprefix: 'Wikivoyage/Logo/2013/R2/v/',
	prop: 'revisions',
	rvprop: 'content'
}).done(_gotPages).fail(function () {
	console.error(arguments);
});