// ==UserScript== // @name Stack Exchange Global Flag Summary // @namespace http://floern.com/ // @version 1.2.3 // @description Stack Exchange networkwide flag summary available in your network profile // @author Floern // @include *://stackexchange.com/users/*/* // @match *://*.stackexchange.com/users/flag-summary/* // @match *://*.stackoverflow.com/users/flag-summary/* // @match *://*.superuser.com/users/flag-summary/* // @match *://*.serverfault.com/users/flag-summary/* // @match *://*.askubuntu.com/users/flag-summary/* // @match *://*.stackapps.com/users/flag-summary/* // @match *://*.mathoverflow.net/users/flag-summary/* // @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 // @updateURL https://raw.githubusercontent.com/Floern/stackoverflow/master/userscripts/SE_global_flag_summary.meta.js // @downloadURL https://raw.githubusercontent.com/Floern/stackoverflow/master/userscripts/SE_global_flag_summary.user.js // ==/UserScript== let flagSummaryTable, flagSummaryTableBody, errorView; let sortedColIndex = 2; let sortedColAsc = false; let flagGlobalSummaryStats = { sumFlagsTotal: 0, sumFlagsDeclined: 0, sumFlagsDisputed: 0, sumFlagsRetracted: 0, sumFlagsExpired: 0, sumFlagsPending: 0, sumFlagsHelpful: 0 }; let rateLimited = false; let reloadIcon = `iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADrUlEQVRYR+1WS2gTURRN/OFfURER/10IUtqmS WYmEQz+EFEXohVxIaLgStHMZJLUFmapuBBRKlbnk4+/xi5c+NkIipui1i4FUXShLiqiiPihfuK5yTOdycwkqdaK4IFDMvPuuXPfffe9 +zwjgYCohzklO5U9jjw42TjFi+oDIXp2Bnv1e4goxvhwLFUXkPRgsDW7WpD1Zk46s9i/t3MsM7EAAeghScsLktYXkjKz2euhQYh2TRB i2k5e0m7A0Xs4/E5OB6l+FyT1rSDp3Vxc2xpRbo1hUg8vG7mfdoKo3fMoyig2VB0tudxopG8XhC+tH6xM2D8RYvoO+pgQ1W4OvldfRR SlFFxFNCrGdDi5bnY8VCJb3chMT+m51gC4eHZeSNQemp0NB2sKoEHKTMJ69zo5ICK93zCzO3xMOxmUU+18TD8Kx9fx7oOTvZk1BYBCO uUkBgdQhCf8ifQCZmqBP9E5DUG1IeXvHLQFVg2Ai54RaIZ2sfqak9WVzKwETtJXCbHU5kAivZuLGwfxAQX2hl1fZNUAkMZrDsKPCGAF M7EAdZKkLeigcSQC6G9pyY1mciv4A8Yip9ljSVqZiR35vBfj+xC4Q9bsxKH0mCntgJNouQAB9VNRMhNXIP17sPZfCxoEg1r5gi08gJr 4SCwcUkg/L+s9TGIHKv+qLQCsJxuuirCszqUs+sXzs5YpuclEv9I5kYj/42jtXdNPoJly+7NTzaTDiA3/x78HXETm86K+tqH9gq9RyT URG1ov1bNhO5rbUnXU34nNyXMbfYcym4Lx1DYUIip8kMtdTsJyYAd1OBT1E9d2jL59ulzgwmdU7UzmiAh2AG29ci0CcN9VQwiAzoeng aS2hElt4CW93UkXko3VzMQOdDbVUeRCzOZ5MJmeyeQl4DxZg7EBB/u+irchrHemXFRgsf0eD8Qz4QblUn1j4uKin+cEzmIvkxfAx9Qt 0NjacuGUjOvrmJkzXAMAyQHSmg7I2aXM3IS8N5RI+3D0XnZrTOgX1U/USgGYiMun9gi/50OiquIy0oXnZ2U2FmL8Ti39xHJ7HS7Sx6k XsE9UhuX2KmmfOFk/TL9mh7WSlgzF2FHzxwnWAIo3l4Co+rCut83Oa+BdQdYizG3tCEnGdsz4MzmxXp3y3mDMaMKMjqCY7sPmDfV6si v0fZGetV7UwzFeSvmLml8EJxkb4JguEi9c9qyXejv1emRmYVi+MoWe2djwgI+n1uPq1Mce/w4wqzns7x+Ex/MDD9PvSmMer2UAAAAAS UVORK5CYII=`; // init (function () { if (window.location.href.match(/\/users\/flag-summary\/\d+/i)) { showGlobalFlagSummaryLink(); 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 flags let flagTab = document.createElement('a'); flagTab.setAttribute('href', '?tab=flags'); flagTab.textContent = 'flags'; tabs[4].parentNode.insertBefore(flagTab, tabs[4]); if (!window.location.href.match(/:\/\/stackexchange\.com\/users\/\d+\/.+?\?tab=flags/i)) { return; } // unselect default tab let selectedTab = navigation.querySelector('.youarehere'); selectedTab.className = ''; // set selected tab to flags flagTab.className = 'youarehere'; // remove default content while (navigation.nextSibling) { navigation.parentNode.removeChild(navigation.nextSibling); } document.querySelector('title').textContent = 'Flag Summary - Stack Exchange'; let container = document.createElement('div'); navigation.parentNode.appendChild(container); // setup summary table flagSummaryTable = document.createElement('table'); flagSummaryTable.id = 'flag-summary-table'; flagSummaryTable.style.width = '100%'; flagSummaryTable.style.textAlign = 'right'; flagSummaryTable.style.borderCollapse = 'separate'; flagSummaryTable.style.borderSpacing = '0 5px'; flagSummaryTable.innerHTML = ` Site helpful declined disputed expired retracted pending total helpful % last flag `; container.appendChild(flagSummaryTable); // make columns sortable let tableLabelNodes = flagSummaryTable.querySelectorAll('#flag-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); }; } flagSummaryTableBody = flagSummaryTable.getElementsByTagName('tbody')[0]; // some table CSS GM.addStyle("#flag-summary-heading-labels th { padding-top:6px; cursor:pointer; }"); GM.addStyle("#flag-summary-global-stats th { padding-bottom:4px; border-bottom:1px #ddd solid; }"); GM.addStyle("#flag-summary-table tbody tr:hover { background:rgba(127,127,127,.10); }"); GM.addStyle("#flag-summary-table tbody tr { counter-increment:siteNumber; }"); GM.addStyle("#flag-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; }"); GM.addStyle("#flag-summary-table tbody tr td:last-child { width: 0px; position: relative; }"); GM.addStyle("#flag-summary-table .reloadbutton { cursor:pointer; width:24px; position:absolute; top:0; left:0; " + "z-index:1; display:none; }"); GM.addStyle("#flag-summary-table tbody tr:hover .reloadbutton { display:block; }"); GM.addStyle("#flag-summary-table tbody tr td:last-child::after { content:''; width:24px; display:inline-block; " + "position:absolute; height:100%; top:0;}"); // init global flag summary updateGlobalFlagStats(); // prepare error view errorView = document.createElement('div'); container.appendChild(errorView); // create loading view let loadingView = document.createElement('div'); loadingView.id = 'flag-summary-loading'; loadingView.style.textAlign = 'center'; loadingView.innerHTML = 'Loading...
' + ''; container.appendChild(loadingView); rateLimited = false; // load data loadAccountList(); })(); /** * Add a link to the flag summary page. */ function showGlobalFlagSummaryLink() { let header = document.querySelector('#content .subheader'); if (!header) { return; } // add link to header let segfsLink = document.createElement('a'); segfsLink.setAttribute('href', 'https://stackexchange.com/users/current?tab=flags'); segfsLink.textContent = 'Global Flag Summary'; segfsLink.style.float = 'right'; segfsLink.style.paddingTop = '13px'; header.insertBefore(segfsLink, header.firstChild); } /** * Update global flag summary in header. */ function updateGlobalFlagStats() { let realTotal = flagGlobalSummaryStats.sumFlagsHelpful + flagGlobalSummaryStats.sumFlagsDeclined; let helpfulFraction = realTotal == 0 ? 0 : (flagGlobalSummaryStats.sumFlagsHelpful / realTotal); document.getElementById('flag-summary-global-stats').innerHTML = ` ` + formatFlagCount(flagGlobalSummaryStats.sumFlagsHelpful) + ` ` + formatFlagCount(flagGlobalSummaryStats.sumFlagsDeclined) + ` ` + formatFlagCount(flagGlobalSummaryStats.sumFlagsDisputed) + ` ` + formatFlagCount(flagGlobalSummaryStats.sumFlagsExpired) + ` ` + formatFlagCount(flagGlobalSummaryStats.sumFlagsRetracted) + ` ` + formatFlagCount(flagGlobalSummaryStats.sumFlagsPending) + ` ` + formatFlagCount(flagGlobalSummaryStats.sumFlagsTotal) + ` ` + (realTotal == 0 ? '' : formatFlagPercentage(helpfulFraction)) + ` `; } /** * Load the network account list. */ function loadAccountList() { let accountListUrl = 'https://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, null); } }); } /** * Parse the network account list. */ function parseNetworkAccounts(html) { let parser = new DOMParser(); let pageNode = parser.parseFromString(html, 'text/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 siteFlagSummaryUrl = siteLinkNode.href.replace(/users\/(\d+)\/.*$/i, 'users/flag-summary/$1'); // get badge count, used for prioritization let badgeCount = 0; let badgeNodes = accountNode.querySelectorAll('.badgecount'); for (let j = 0; j < badgeNodes.length; ++j) { badgeCount += parseInt(badgeNodes[j].textContent.trim()); } accounts.push({siteName: siteName, flagSummaryUrl: siteFlagSummaryUrl, loadPriority: badgeCount}); // add meta site if (!/(meta\.stackexchange|area51\.stackexchange|stackapps)\.com\//.test(siteFlagSummaryUrl)) { let metaSiteFlagSummaryUrl; if (/\.stackexchange\.com\//.test(siteFlagSummaryUrl)) // SE 2.0 sites metaSiteFlagSummaryUrl = siteFlagSummaryUrl.replace('.stackexchange.com', '.meta.stackexchange.com'); else if (/\/\/[a-z]{2}\.stackoverflow\.com\//.test(siteFlagSummaryUrl)) // localized SO sites metaSiteFlagSummaryUrl = siteFlagSummaryUrl.replace('.stackoverflow.com', '.meta.stackoverflow.com'); else // SE 1.0 sites metaSiteFlagSummaryUrl = siteFlagSummaryUrl.replace('//', '//meta.'); accounts.push({siteName: siteName + " Meta", flagSummaryUrl: metaSiteFlagSummaryUrl, loadPriority: badgeCount - 1.5}); } } // sort by badge count desc, so we load sites with more badges earlier, since those have higher chances having our flags accounts = accounts.sort(function (a, b) { return b.loadPriority - a.loadPriority; }); // 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 = (i < 25 ? 100 : (i < 160 ? 450 : 1111)); setTimeout(function() { if (rateLimited) { return; } loadSiteFlagSummary(account.siteName, account.flagSummaryUrl, function() { loaded++; let progressText = document.getElementById('flag-summary-loading-progress'); if (rateLimited) { progressText.textContent = 'aborted (rate limited)'; document.getElementById('flag-summary-loading-anim').style.visibility = 'hidden'; } else { progressText.textContent = loaded + ' / ' + accounts.length; } if (loaded >= accounts.length) { // end of list document.getElementById('flag-summary-loading').style.visibility = 'hidden'; } }); loadNextSite(); }, delay); } loadNextSite(); } /** * Load the flag summary of the specified site. */ function loadSiteFlagSummary(siteName, siteFlagSummaryUrl, finishedCallback) { console.log('loading ' + siteName); GM.xmlHttpRequest({ method: 'GET', url: siteFlagSummaryUrl, onload: function(response) { if (response.status < 400) { parseSiteFlagSummary(siteName, siteFlagSummaryUrl, response.response); } else { showLoadingError(siteFlagSummaryUrl, response.status, siteName); if (response.status == 429) { rateLimited = true; } } finishedCallback(); }, onerror: function(response) { console.error('loadSiteFlagSummary: ' + siteFlagSummaryUrl); console.error('loadSiteFlagSummary: ' + JSON.stringify(response)); showLoadingError(siteFlagSummaryUrl, response.status, siteName); finishedCallback(); } }); } /** * Parse the flag summary site and extract the stats. */ function parseSiteFlagSummary(siteName, siteFlagSummaryUrl, html) { let parser = new DOMParser(); let pageNode = parser.parseFromString(html, 'text/html'); let sumFlagsTotal = 0; let sumFlagsDeclined = 0; let sumFlagsDisputed = 0; let sumFlagsRetracted = 0; let sumFlagsExpired = 0; let sumFlagsPending = 0; let sumFlagsHelpful = 0; // search for flag stats let flagCountNodes = pageNode.querySelectorAll('#sidebar ul li a[href*="status="]'); for (let i = 0; i < flagCountNodes.length; i++) { let flagType = parseInt(flagCountNodes[i].href.replace(/^.+?\bstatus=(\d+).*$/, '$1')); let flagCount = parseInt(flagCountNodes[i].children[1].textContent.replace(/\D/g, '')); sumFlagsTotal += flagCount; switch (flagType) { case 1: // pending sumFlagsPending += flagCount; break; case 2: // helpful sumFlagsHelpful += flagCount; break; case 3: // declined sumFlagsDeclined += flagCount; break; case 4: // disputed sumFlagsDisputed += flagCount; break; case 5: // expired sumFlagsExpired += flagCount; break; case 6: // retracted sumFlagsRetracted += flagCount; break; default: console.error('parseSiteFlagSummary: unknown flag type #' + flagType); break; } } if (sumFlagsTotal == 0) { // skip site with no flags return; } // update global summary flagGlobalSummaryStats.sumFlagsTotal += sumFlagsTotal; flagGlobalSummaryStats.sumFlagsDeclined += sumFlagsDeclined; flagGlobalSummaryStats.sumFlagsDisputed += sumFlagsDisputed; flagGlobalSummaryStats.sumFlagsRetracted += sumFlagsRetracted; flagGlobalSummaryStats.sumFlagsExpired += sumFlagsExpired; flagGlobalSummaryStats.sumFlagsPending += sumFlagsPending; flagGlobalSummaryStats.sumFlagsHelpful += sumFlagsHelpful; updateGlobalFlagStats(); // compute helpful percentage let realTotal = sumFlagsHelpful + sumFlagsDeclined; let helpfulFraction = realTotal == 0 ? 0 : (sumFlagsHelpful / realTotal); // get most recent flag date let flagHistoryDates = pageNode.querySelectorAll('.user-flag-history .mod-flag .relativetime'); let mostRecentflagHistoryDateNode = flagHistoryDates[0]; let lastFlagTimestamp = mostRecentflagHistoryDateNode.title; let lastFlagTimeDisplay = formatTimeRelative(lastFlagTimestamp); // get site icon let siteFaviconURL = pageNode.querySelector('link[rel*="icon"]').href; // create table row for this site let siteFlagSummaryTr = document.createElement('tr'); siteFlagSummaryTr.innerHTML = ` ` + siteName + ` ` + formatFlagCount(sumFlagsHelpful) + ` ` + formatFlagCount(sumFlagsDeclined) + ` ` + formatFlagCount(sumFlagsDisputed) + ` ` + formatFlagCount(sumFlagsExpired) + ` ` + formatFlagCount(sumFlagsRetracted) + ` ` + formatFlagCount(sumFlagsPending) + ` ` + formatFlagCount(sumFlagsTotal) + ` ` + (realTotal == 0 ? '–' : formatFlagPercentage(helpfulFraction)) + ` ` + lastFlagTimeDisplay + ` `; let reloadButton = siteFlagSummaryTr.getElementsByClassName('reloadbutton')[0]; reloadButton.onclick = function(e) { reloadButton.innerHTML = '. . .'; loadSiteFlagSummary(siteName, siteFlagSummaryUrl, function() { // remove old row flagSummaryTableBody.removeChild(siteFlagSummaryTr); flagGlobalSummaryStats.sumFlagsTotal -= sumFlagsTotal; flagGlobalSummaryStats.sumFlagsDeclined -= sumFlagsDeclined; flagGlobalSummaryStats.sumFlagsDisputed -= sumFlagsDisputed; flagGlobalSummaryStats.sumFlagsRetracted -= sumFlagsRetracted; flagGlobalSummaryStats.sumFlagsExpired -= sumFlagsExpired; flagGlobalSummaryStats.sumFlagsPending -= sumFlagsPending; flagGlobalSummaryStats.sumFlagsHelpful -= sumFlagsHelpful; updateGlobalFlagStats(); }); }; let anchorBottom = window.pageYOffset > 9 && (window.innerHeight + window.pageYOffset) >= document.body.offsetHeight - 2; flagSummaryTableBody.appendChild(siteFlagSummaryTr); // keep order sortTable(sortedColIndex, sortedColAsc); if (anchorBottom) { // keep scroll position at bottom window.scrollTo(window.pageXOffset, document.body.scrollHeight); } } /** * Format flag count, empty if 0. */ function formatFlagCount(flagCount) { if (flagCount == 0) return ''; else return flagCount; } /** * Format helpful flag percentage. */ function formatFlagPercentage(fraction) { return (fraction * 100).toFixed(2) + '%'; } /** * Format relative time. */ function formatTimeRelative(e) { if (null != e && 20 == e.length) { e = e.substr(0, 10) + "T" + e.substr(11, 10); let date = new Date(e), dsecs = Math.floor((Date.now() - date.getTime()) / 1e3), ddays = Math.floor(dsecs / 86400); if (0 <= ddays && ddays < 7 || ddays == 42) { if (dsecs < 2) return 'just now'; if (dsecs < 60) return dsecs + ' secs ago'; if (dsecs < 120) return '1 min ago'; let dmins = Math.floor(dsecs / 60); if (dmins < 60) return dmins + ' mins ago'; if (dmins < 120) return '1 hour ago'; let dhrs = Math.floor(dmins / 60); if (dhrs < 18) return dhrs + ' hours ago'; if (dhrs < 48) return 'yesterday'; else return ddays + ' days ago'; } else { let months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return months[date.getMonth()] + ' ' + date.getDate() + (date.getFullYear() != new Date().getFullYear() ? " '" + date.getFullYear() % 100 : ''); } } else { return e; } } /** * Show an error. */ function showLoadingError(url, statuscode, siteName) { let errorMsg = document.createElement('div'); errorMsg.style.paddingTop = '4px'; let errorHtml = 'Failed to load ' + (siteName || url) + ' '; if (statuscode <= 0 && siteName) errorHtml += '(retry)'; else if (statuscode == 429) errorHtml += '(rate limited)'; else errorHtml += 'with status ' + statuscode; errorMsg.innerHTML = errorHtml; let retrybtn = errorMsg.getElementsByClassName('segfs-retry'); if (retrybtn.length > 0) { retrybtn[0].onclick = function(e) { loadSiteFlagSummary(siteName, url, function(){}); errorMsg.outerHTML = ''; return false; }; } errorView.appendChild(errorMsg); } /** * Sort the table by column index `col` and bool `asc`. */ function sortTable(col, asc) { sortedColIndex = col; let trs = [].slice.call(flagSummaryTableBody.rows, 0); asc = -((+asc) || -1); if (col == 1) { // site name trs = trs.sort(function (a, b) { return asc * (a.cells[col].textContent.trim().localeCompare(b.cells[col].textContent.trim())); }); } else if (col == 10) { // date trs = trs.sort(function (a, b) { return asc * (b.cells[col].title.localeCompare(a.cells[col].title)); }); } 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) flagSummaryTableBody.appendChild(trs[i]); }