Global Review Summary v0.1
This commit is contained in:
parent
47f06b6b94
commit
1d20a5b634
3 changed files with 450 additions and 0 deletions
23
global-review-summary/README.md
Normal file
23
global-review-summary/README.md
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Global Review Summary
|
||||||
|
|
||||||
|
This script adds a 'reviews' tab to your network profile, which will show your review
|
||||||
|
totals across the network.
|
||||||
|
|
||||||
|
![](example.png)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
- Make sure you have Tampermonkey (for Chrome)
|
||||||
|
or Greasemonkey / Violentmonkey (for Firefox) installed.
|
||||||
|
|
||||||
|
- Install the userscript with
|
||||||
|
[this direct link](https://raw.githubusercontent.com/Glorfindel83/SE-Userscripts/master/global-review-summary/global-review-summary.user.js).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you have any questions, please post a comment on [this Stack Apps question](https://stackapps.com/q//34061).
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
The code of this userscript leans heavily on the
|
||||||
|
[Stack Exchange Global Flag Summary](https://stackapps.com/q/7173/34061) script by Floern.
|
BIN
global-review-summary/example.png
Normal file
BIN
global-review-summary/example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 111 KiB |
427
global-review-summary/global-review-summary.user.js
Normal file
427
global-review-summary/global-review-summary.user.js
Normal file
|
@ -0,0 +1,427 @@
|
||||||
|
// ==UserScript==
|
||||||
|
// @name Stack Exchange Global Review Summary
|
||||||
|
// @version 0.1
|
||||||
|
// @description Stack Exchange network wide review summary available in your network profile
|
||||||
|
// @author Glorfindel
|
||||||
|
// @attribution Floern (https://github.com/Floern)
|
||||||
|
// @include *://stackexchange.com/users/*/*
|
||||||
|
// @match *://*.stackexchange.com/review*
|
||||||
|
// @match *://*.stackoverflow.com/review*
|
||||||
|
// @match *://*.superuser.com/review*
|
||||||
|
// @match *://*.serverfault.com/review*
|
||||||
|
// @match *://*.askubuntu.com/review*
|
||||||
|
// @match *://*.stackapps.com/review*
|
||||||
|
// @match *://*.mathoverflow.net/review*
|
||||||
|
// @connect stackexchange.com
|
||||||
|
// @connect stackoverflow.com
|
||||||
|
// @connect superuser.com
|
||||||
|
// @connect serverfault.com
|
||||||
|
// @connect askubuntu.com
|
||||||
|
// @connect stackapps.com
|
||||||
|
// @connect mathoverflow.net
|
||||||
|
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
|
||||||
|
// @grant GM.xmlHttpRequest
|
||||||
|
// @grant GM_xmlhttpRequest
|
||||||
|
// @grant GM.addStyle
|
||||||
|
// @grant GM_addStyle
|
||||||
|
// @run-at document-end
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
let reviewSummaryTable, reviewSummaryTableBody, errorView;
|
||||||
|
|
||||||
|
let sortedColIndex = 10;
|
||||||
|
let sortedColAsc = false;
|
||||||
|
|
||||||
|
let reviewURIs = ["first-posts", "late-answers", "low-quality-posts", "suggested-edits",
|
||||||
|
"close", "reopen", "triage", "helper"];
|
||||||
|
|
||||||
|
let reviewGlobalSummaryTotals = new Array(reviewURIs.length).fill(0);
|
||||||
|
|
||||||
|
let totalsPerSite = {};
|
||||||
|
let summariesPerSite = {};
|
||||||
|
|
||||||
|
// init
|
||||||
|
(function () {
|
||||||
|
if (window.location.href.match(/\/review\W?/i)) {
|
||||||
|
showGlobalReviewSummaryLink();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.location.href.match(/:\/\/stackexchange\.com\/users\/\d+/i)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let navigation = document.querySelector('#content .contentWrapper .subheader');
|
||||||
|
if (!navigation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabbar = navigation.querySelector('.tabs');
|
||||||
|
|
||||||
|
// verify that we are in the profile of the logged in user
|
||||||
|
let tabs = tabbar.getElementsByTagName('a');
|
||||||
|
let loggedIn = false;
|
||||||
|
for (let i = 0; i < tabs.length; i++) {
|
||||||
|
if (tabs[i].textContent.trim().toLowerCase() == 'inbox') {
|
||||||
|
loggedIn = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!loggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add navigation tab for reviews
|
||||||
|
let reviewTab = document.createElement('a');
|
||||||
|
reviewTab.setAttribute('href', '?tab=reviews');
|
||||||
|
reviewTab.textContent = 'reviews';
|
||||||
|
tabs[4].parentNode.insertBefore(reviewTab, tabs[4]);
|
||||||
|
|
||||||
|
if (!window.location.href.match(/:\/\/stackexchange\.com\/users\/\d+\/.+?\?tab=reviews/i)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// unselect default tab
|
||||||
|
let selectedTab = navigation.querySelector('.youarehere');
|
||||||
|
selectedTab.className = '';
|
||||||
|
|
||||||
|
// set selected tab to reviews
|
||||||
|
reviewTab.className = 'youarehere';
|
||||||
|
|
||||||
|
// remove default content
|
||||||
|
while (navigation.nextSibling) {
|
||||||
|
navigation.parentNode.removeChild(navigation.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('title').textContent = 'Review Summary - Stack Exchange';
|
||||||
|
|
||||||
|
let container = document.createElement('div');
|
||||||
|
navigation.parentNode.appendChild(container);
|
||||||
|
|
||||||
|
// setup summary table
|
||||||
|
reviewSummaryTable = document.createElement('table');
|
||||||
|
reviewSummaryTable.id = 'review-summary-table';
|
||||||
|
reviewSummaryTable.style.width = '100%';
|
||||||
|
reviewSummaryTable.style.textAlign = 'right';
|
||||||
|
reviewSummaryTable.style.borderCollapse = 'separate';
|
||||||
|
reviewSummaryTable.style.borderSpacing = '0 5px';
|
||||||
|
reviewSummaryTable.innerHTML = `
|
||||||
|
<thead>
|
||||||
|
<tr id="review-summary-heading-labels" style="cursor:pointer">
|
||||||
|
<th style="text-align:left;width:160px" colspan="2">Site</th>
|
||||||
|
<th>FP</th>
|
||||||
|
<th>LA</th>
|
||||||
|
<th>LQP</th>
|
||||||
|
<th>SE</th>
|
||||||
|
<th>CV</th>
|
||||||
|
<th>RV</th>
|
||||||
|
<th>Tr</th>
|
||||||
|
<th>H&I</th>
|
||||||
|
<th style="padding-left:20px">total</th>
|
||||||
|
</tr>
|
||||||
|
<tr id="review-summary-global-stats">
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</tbody>
|
||||||
|
`;
|
||||||
|
container.appendChild(reviewSummaryTable);
|
||||||
|
|
||||||
|
// make columns sortable
|
||||||
|
let tableLabelNodes = reviewSummaryTable.querySelectorAll('#review-summary-heading-labels th');
|
||||||
|
for (let i = 0; i < tableLabelNodes.length; i++) {
|
||||||
|
let col = i + 1;
|
||||||
|
tableLabelNodes[i].onclick = function() {
|
||||||
|
sortedColAsc = col == sortedColIndex ? !sortedColAsc : false;
|
||||||
|
sortTable(col, sortedColAsc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewSummaryTableBody = reviewSummaryTable.getElementsByTagName('tbody')[0];
|
||||||
|
|
||||||
|
// some table CSS
|
||||||
|
GM.addStyle("#review-summary-table tbody tr:hover { background: rgba(127, 127, 127, .10); }");
|
||||||
|
GM.addStyle("#review-summary-global-stats th { border-bottom: 1px #ddd solid; }");
|
||||||
|
GM.addStyle("#review-summary-table tbody tr { counter-increment: siteNumber; }");
|
||||||
|
GM.addStyle("#review-summary-table tbody tr td:first-child::before { content: counter(siteNumber); width: 14px; " +
|
||||||
|
"margin-right: 10px; color: #bbb; font-size: 10px; display: inline-block; text-align: right; margin-left: -24px; }");
|
||||||
|
|
||||||
|
// init global review summary
|
||||||
|
updateGlobalReviewStats();
|
||||||
|
|
||||||
|
// prepare error view
|
||||||
|
errorView = document.createElement('div');
|
||||||
|
container.appendChild(errorView);
|
||||||
|
|
||||||
|
// create loading view
|
||||||
|
let loadingView = document.createElement("div");
|
||||||
|
loadingView.id = 'review-summary-loading';
|
||||||
|
loadingView.style.textAlign = 'center';
|
||||||
|
loadingView.innerHTML = '<img src="/content/img/progress-dots.gif" alt="Loading..." /><br>' +
|
||||||
|
'<span id="review-summary-loading-progress" style="color:#bbb;font-size:10px;"></span>';
|
||||||
|
container.appendChild(loadingView);
|
||||||
|
|
||||||
|
// load data
|
||||||
|
loadAccountList();
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a link to the review summary page.
|
||||||
|
*/
|
||||||
|
function showGlobalReviewSummaryLink() {
|
||||||
|
// add link to header
|
||||||
|
let link = document.createElement('a');
|
||||||
|
link.setAttribute('href', '//stackexchange.com/users/current?tab=reviews');
|
||||||
|
link.textContent = 'global summary';
|
||||||
|
var tabs = document.getElementById("tabs");
|
||||||
|
if (tabs != null) {
|
||||||
|
tabs.appendChild(link);
|
||||||
|
} else {
|
||||||
|
var header = document.querySelector('#content .subheader');
|
||||||
|
header.setAttribute('style', 'display: table');
|
||||||
|
link.setAttribute('style', 'padding-left: 20px; display: table-cell; vertical-align: middle;');
|
||||||
|
header.appendChild(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update global review summary in header.
|
||||||
|
*/
|
||||||
|
function updateGlobalReviewStats() {
|
||||||
|
var html = `<th colspan="2"></th>`;
|
||||||
|
var grandTotal = 0;
|
||||||
|
for (var i = 0; i < reviewURIs.length; i++) {
|
||||||
|
grandTotal += reviewGlobalSummaryTotals[i];
|
||||||
|
html += `<th>` + reviewGlobalSummaryTotals[i].toLocaleString() + `</th>`
|
||||||
|
}
|
||||||
|
html += `<th>` + grandTotal.toLocaleString() + `</th>`;
|
||||||
|
document.getElementById('review-summary-global-stats').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the network account list.
|
||||||
|
*/
|
||||||
|
function loadAccountList() {
|
||||||
|
let accountListUrl = '//stackexchange.com/users/current?tab=accounts';
|
||||||
|
GM.xmlHttpRequest({
|
||||||
|
method: 'GET',
|
||||||
|
url: accountListUrl,
|
||||||
|
onload: function(response) {
|
||||||
|
parseNetworkAccounts(response.response);
|
||||||
|
},
|
||||||
|
onerror: function(response) {
|
||||||
|
console.error('loadAccountList: ' + JSON.stringify(response));
|
||||||
|
showLoadingError(accountListUrl, response.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the network account list.
|
||||||
|
*/
|
||||||
|
function parseNetworkAccounts(html) {
|
||||||
|
let pageNode = document.createElement('div');
|
||||||
|
pageNode.innerHTML = html;
|
||||||
|
|
||||||
|
let accounts = [];
|
||||||
|
|
||||||
|
// iterate all accounts
|
||||||
|
let accountNodes = pageNode.querySelectorAll('.contentWrapper .account-container');
|
||||||
|
for (let i = 0; i < accountNodes.length; ++i) {
|
||||||
|
let accountNode = accountNodes[i];
|
||||||
|
|
||||||
|
let siteLinkNode = accountNode.querySelector('.account-site a');
|
||||||
|
if (!siteLinkNode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (siteLinkNode.href.indexOf('area51.stackexchange.com/') != -1) {
|
||||||
|
// use area51.meta.SE instead
|
||||||
|
siteLinkNode.href = siteLinkNode.href.replace('//area51.st', '//area51.meta.st');
|
||||||
|
}
|
||||||
|
|
||||||
|
let siteName = siteLinkNode.textContent.trim();
|
||||||
|
let siteReviewURL = siteLinkNode.href.replace(/users\/.*$/i, 'review/');
|
||||||
|
|
||||||
|
// get reputation
|
||||||
|
let reputationNode = accountNode.querySelector('.account-stat span.account-number');
|
||||||
|
if (!reputationNode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let reputation = parseInt(reputationNode.innerHTML.replace(",", ""));
|
||||||
|
// 350 is the minimum (for beta sites)
|
||||||
|
if (reputation < 350) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.push({ siteName: siteName, reviewURL: siteReviewURL });
|
||||||
|
|
||||||
|
// add meta site
|
||||||
|
if (!/(meta\.stackexchange|stackapps)\.com\//.test(siteReviewURL)) {
|
||||||
|
let metaSiteReviewURL;
|
||||||
|
if (/\.stackexchange\.com\//.test(siteReviewURL)) // SE 2.0 sites
|
||||||
|
metaSiteReviewURL = siteReviewURL.replace('.stackexchange.com', '.meta.stackexchange.com');
|
||||||
|
else if (/\/\/[a-z]{2}\.stackoverflow\.com\//.test(siteReviewURL)) // localized SO sites
|
||||||
|
metaSiteReviewURL = siteReviewURL.replace('.stackoverflow.com', '.meta.stackoverflow.com');
|
||||||
|
else // SE 1.0 sites
|
||||||
|
metaSiteReviewURL = siteReviewURL.replace('//', '//meta.');
|
||||||
|
accounts.push({ siteName: siteName + " Meta", reviewURL: metaSiteReviewURL });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// load the sites
|
||||||
|
let i = -1;
|
||||||
|
let loaded = 0;
|
||||||
|
function loadNextSite() {
|
||||||
|
i++;
|
||||||
|
if (i >= accounts.length) {
|
||||||
|
// end of list
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let account = accounts[i];
|
||||||
|
let delay = 1000;
|
||||||
|
setTimeout(function() {
|
||||||
|
loadSiteReviewSummary(account.siteName, account.reviewURL, function() {
|
||||||
|
loaded++;
|
||||||
|
document.getElementById('review-summary-loading-progress').textContent = loaded + " / " + accounts.length;
|
||||||
|
if (loaded >= accounts.length) {
|
||||||
|
// end of list
|
||||||
|
document.getElementById('review-summary-loading').style.visibility = 'hidden';
|
||||||
|
}
|
||||||
|
loadNextSite();
|
||||||
|
}, 0);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
// start 3 'threads' in parallel
|
||||||
|
loadNextSite();
|
||||||
|
loadNextSite();
|
||||||
|
loadNextSite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the review summary of the specified site.
|
||||||
|
*/
|
||||||
|
function loadSiteReviewSummary(siteName, siteReviewURL, finishedCallback, index) {
|
||||||
|
console.log('loading ' + siteName + ', ' + reviewURIs[index]);
|
||||||
|
var url = siteReviewURL + reviewURIs[index] + '/stats';
|
||||||
|
GM.xmlHttpRequest({
|
||||||
|
method: 'GET',
|
||||||
|
url: url,
|
||||||
|
onload: function(response) {
|
||||||
|
if (++index == reviewURIs.length) {
|
||||||
|
finishedCallback();
|
||||||
|
} else {
|
||||||
|
loadSiteReviewSummary(siteName, siteReviewURL, finishedCallback, index)
|
||||||
|
}
|
||||||
|
if (response.status < 400) {
|
||||||
|
parseSiteReviewSummary(siteName, siteReviewURL, response.response, index - 1);
|
||||||
|
} else {
|
||||||
|
showLoadingError(siteReviewURL, response.status);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onerror: function(response) {
|
||||||
|
console.error('loadSiteReviewSummary: ' + url);
|
||||||
|
console.error('loadSiteReviewSummary: ' + JSON.stringify(response));
|
||||||
|
if (++index == reviewURIs.length) {
|
||||||
|
finishedCallback();
|
||||||
|
} else {
|
||||||
|
loadSiteReviewSummary(siteName, siteReviewURL, finishedCallback, index)
|
||||||
|
}
|
||||||
|
showLoadingError(siteReviewURL, response.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the review site and extract the stats.
|
||||||
|
*/
|
||||||
|
function parseSiteReviewSummary(siteName, siteReviewURL, html, index) {
|
||||||
|
let pageNode = document.createElement('div');
|
||||||
|
pageNode.innerHTML = html;
|
||||||
|
|
||||||
|
// Determine # of reviews
|
||||||
|
let count = pageNode.querySelector("#badge-progress-count").innerText;
|
||||||
|
let reviews = parseInt(count.replace(",", ""), 10);
|
||||||
|
if (reviews == 0) {
|
||||||
|
// skip when no reviews
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect totals
|
||||||
|
if (typeof(totalsPerSite[siteName]) === "undefined") {
|
||||||
|
totalsPerSite[siteName] = reviews;
|
||||||
|
} else {
|
||||||
|
totalsPerSite[siteName] += reviews;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update global summary
|
||||||
|
reviewGlobalSummaryTotals[index] += reviews;
|
||||||
|
updateGlobalReviewStats();
|
||||||
|
|
||||||
|
let siteReviewSummaryTr = summariesPerSite[siteName];
|
||||||
|
if (typeof(siteReviewSummaryTr) === "undefined") {
|
||||||
|
// create table row for this site
|
||||||
|
siteReviewSummaryTr = document.createElement('tr');
|
||||||
|
let siteFaviconURL = pageNode.querySelector('link[rel*="icon"]').href;
|
||||||
|
siteReviewSummaryTr.innerHTML = `
|
||||||
|
<td style="text-align:left;width:24px"><img src="` + siteFaviconURL + `"
|
||||||
|
style="width:16px;height:16px;vertical-align:middle" /></td>
|
||||||
|
<td style="text-align:left"><a href="` + siteReviewURL + `">` + siteName + `</a></td>`
|
||||||
|
for (var i = 0; i < reviewURIs.length; i++) {
|
||||||
|
siteReviewSummaryTr.innerHTML += `<td class="` + reviewURIs[i] + `"></td>`
|
||||||
|
}
|
||||||
|
siteReviewSummaryTr.innerHTML += `<td class="total"></td>`;
|
||||||
|
reviewSummaryTableBody.appendChild(siteReviewSummaryTr);
|
||||||
|
summariesPerSite[siteName] = siteReviewSummaryTr;
|
||||||
|
}
|
||||||
|
siteReviewSummaryTr.querySelector("." + reviewURIs[index]).innerText = reviews.toLocaleString();
|
||||||
|
siteReviewSummaryTr.querySelector(".total").innerText = totalsPerSite[siteName].toLocaleString();
|
||||||
|
|
||||||
|
// keep order
|
||||||
|
sortTable(sortedColIndex, sortedColAsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the previous non-text sibling node.
|
||||||
|
*/
|
||||||
|
function previousElementSibling(node) {
|
||||||
|
do {
|
||||||
|
node = node.previousSibling;
|
||||||
|
} while (node && node.nodeType !== 1);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an error.
|
||||||
|
*/
|
||||||
|
function showLoadingError(url, statuscode) {
|
||||||
|
let errorMsg = document.createElement("div");
|
||||||
|
errorMsg.innerHTML = 'Failed to load <a href="' + url + '">' + url + '</a> with status ' + statuscode + '';
|
||||||
|
errorView.appendChild(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort the table by column index `col` and bool `asc`.
|
||||||
|
*/
|
||||||
|
function sortTable(col, asc) {
|
||||||
|
sortedColIndex = col;
|
||||||
|
let trs = Array.prototype.slice.call(reviewSummaryTableBody.rows, 0);
|
||||||
|
asc = -((+asc) || -1);
|
||||||
|
if (col == 1) {
|
||||||
|
trs = trs.sort(function (a, b) {
|
||||||
|
return asc * (a.cells[col].textContent.trim().localeCompare(b.cells[col].textContent.trim()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
trs = trs.sort(function (a, b) {
|
||||||
|
let va = parseInt(a.cells[col].textContent.replace(/\D/g, '')) || 0;
|
||||||
|
let vb = parseInt(b.cells[col].textContent.replace(/\D/g, '')) || 0;
|
||||||
|
if (va != vb) // primary order
|
||||||
|
return asc * (vb - va);
|
||||||
|
else // secondary order
|
||||||
|
return a.cells[1].textContent.trim().localeCompare(b.cells[1].textContent.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < trs.length; ++i) reviewSummaryTableBody.appendChild(trs[i]);
|
||||||
|
}
|
Loading…
Reference in a new issue