User:Suffusion of Yellow/mark-reverted.js
/*
* mark-reverted.js
*
* Highlights diffs and permalinks by status: live, reverted, or unknown.
* Should work on any page. Based on revision SHA1 only.
*/
//
(function() {
/* globals $, mw */
'use strict';
const MESSAGES = {
'mr-activate-text' : "Mark reverted",
'mr-activate-title' : "Highlight links by status (reverted, live, or unknown)",
'mr-link-unknown-title' : "This status of this edit could not be determined",
'mr-link-reverted-title' : "This edit has been reverted at least once",
'mr-link-live-title' : "This edit has identical text with the current revision",
'mr-link-error-title' : "An error occured while determining the status of this edit (see browser console)",
'mr-disallow-toomany': "There are $1 unique pages linked from here. Try again on a page with fewer links.",
'mr-warn-toomany' : "There are $1 unique pages linked from here. Continue?",
};
const WINDOW_SIZE = 30; // Always look AT LEAST this far ahead/back
/*
* It's possible for there to be 5000 unique titles linked from
* one user contributions page. That would be 10000 API requests!
* Set some sensible limits.
*/
const SOFT_PAGE_LIMIT = 100; // Prompt first
const HARD_PAGE_LIMIT = 1000; // Nope
const MAX_CONCURRENT_REQUESTS = 10;
const CSS_PAGE = "https://en.wikipedia.org/w/index.php?title=User:Suffusion_of_Yellow/mark-reverted.css&action=raw&ctype=text/css";
const API_USER_AGENT = "mark-reverted/0.1 (https://en.wikipedia.org/wiki/User:Suffusion_of_Yellow/mark-reverted.js)";
var api;
/*
* Silently ignore API errors, but log them to the console.
* We are making LOTS of requests and there's no need to panic
* if one goes missing. Results will be "good enough".
*/
function handleApiError(code, details) {
if (typeof code != 'string')
throw code; // Something went very wrong
if (code == "http" && details.textStatus == "abort")
return; // Aborted by user, not an error
console.log((code == "http") ?
"HTTP error: " + details.textStatus :
"API returned error \"" + code + "\": " + details.error.info);
}
/*
* Get a batch of revisions and mark all revisions as
* "live" if the rev_sha1 is same as the current rev_sha1
* "reverted" if:
* Some later revision has a rev_sha1, S
* AND some earlier revision has the same rev_sha1, S
* AND the revision itself does NOT have rev_sha1 S
* "unknown" otherwise
*/
async function getRevisions(state, revid, dir) {
let page = state.page, revlist = state.revlist;
let revmap = state.revmap, shamap = state.shamap;
let start = revid, idx = revmap.get(revid);
if (idx !== undefined) {
if (dir == "newer" ||
revlist.length - idx > WINDOW_SIZE ||
revlist[revlist.length - 1].parentid === 0)
return; // Fully cached
start = revlist[revlist.length - 1].revid; // Partly cached
}
let r = await api.get( {
action : 'query',
prop : 'revisions',
pageids : page.pageid,
rvprop : "ids|sha1",
rvstartid : start,
rvdir : dir,
rvlimit : WINDOW_SIZE
}).catch(handleApiError);
let revisions;
try {
revisions = r.query.pages[page.pageid].revisions;
if (dir == "newer")
revisions.reverse();
} catch(e) {
return;
}
for (let rev of revisions) {
if (revmap.get(rev.revid))
continue;
rev.status = "unknown";
revmap.set(rev.revid, revlist.length);
revlist.push(rev);
if (rev.sha1 !== undefined) {
if (revlist[0].revid == page.lastrevid &&
rev.sha1 == revlist[0].sha1)
rev.status = "live";
let last = shamap.get(rev.sha1);
if (last !== undefined)
for (let j = last; j < revlist.length - 1; j++)
if (revlist[j].status == "unknown" &&
revlist[j].sha1 !== rev.sha1)
revlist[j].status = "reverted";
shamap.set(rev.sha1, revlist.length - 1);
}
}
}
/*
* Mark all links for a given page.
* NOT concurrent; makes caching tricky
*/
async function markAllForPage(page, links) {
let state = {
page : page,
revlist : [],
revmap : new Map(),
shamap : new Map()
};
for (let rev of page.revisions) {
await getRevisions(state, rev.revid, "newer");
await getRevisions(state, rev.revid, "older");
}
for (let rev of page.revisions) {
let r = state.revmap.get(rev.revid);
let result = r !== undefined ? state.revlist[r].status : "error";
links.get(rev.revid).addClass("mr-" + result);
links.get(rev.revid).prop("title",
mw.msg("mr-link-" + result + "-title"));
}
}
/*
* Concurrently mark all links for all pages
*/
async function markAll(pages, links) {
let pending = [];
for(let [id, page] of pages) {
let idx = pending.length < MAX_CONCURRENT_REQUESTS ?
pending.length : await Promise.race(pending);
pending[idx] = markAllForPage(page, links).then(() => idx);
}
}
/*
* Find out what page is associated with each revision,
* and create a list of revisions for each page.
*/
async function getPageInfo(links) {
const BATCH_SIZE = 50;
let pages = new Map();
let revids = [...links.keys()];
for(let i = 0; i < revids.length; i += BATCH_SIZE) {
let response = await api.get({
action : 'query',
prop : 'revisions|info',
rvprop : "ids|timestamp",
revids : revids.slice(i, i + BATCH_SIZE).join("|")
}).catch(handleApiError);
if (!response.query || !response.query.pages)
continue; // All the revids were bad, perhaps?
for (let id in response.query.pages) {
let page = pages.get(id);
if (!page)
pages.set(id, response.query.pages[id]);
else {
page.revisions.push(...response.query.pages[id].revisions);
}
}
}
/*
* Sort by timestamp (newest first), then by revid (largest first),
* and remove duplicates
*/
for (let [id, page] of pages) {
let r = page.revisions.slice();
r.sort((a, b) => {
if (a.timestamp == b.timestamp)
return a.revid == b.revid ? 0 : a.revid > b.revid ? -1 : 1;
else
return a.timestamp > b.timestamp ? -1 : 1;
});
page.revisions = [];
for(let i = 0; i < r.length; i++)
if (i == 0 || r[i].revid != r[i - 1].revid)
page.revisions.push(r[i]);
}
return pages;
}
/*
* Extract revision ID from various forms of links,
* (...diff=prev&oldid=xxx, Special:Diff/xxx, etc.)
*/
function parseLink(link) {
let diff = null, oldid = null, match, p;
try {
p = new URL(link);
} catch(e) {
return null;
}
if (p.host !== mw.config.get('wgServerName'))
return null;
if (p.pathname == mw.config.get('wgScript')) {
diff = p.searchParams.get("diff");
oldid = p.searchParams.get("oldid");
console.log(link, p, p.searchParams, diff, oldid);
} else if ((match = p.pathname.match(/^\/wiki\/Special:Diff\/([^\/]+)$/i))) {
diff = match[1];
} else if ((match = p.pathname.match(/^\/wiki\/Special:PermanentLink\/([^\/]+)$/i))) {
oldid = match[1];
} else if ((match = p.pathname.match(/^\/wiki\/Special:Diff\/([^\/]+)\/([^\/]+)$/i))) {
oldid = match[1];
diff = match[2];
}
switch(diff) {
case null:
case "prev":
return parseInt(oldid) || null;
case "cur":
case "next":
return null; // Not yet implemented
default:
return parseInt(diff) || null;
}
return null;
}
async function activate(event) {
event.preventDefault();
if (api)
api.abort();
else {
api = new mw.Api({
ajax: {
headers: {
'Api-User-Agent' : API_USER_AGENT
}
}
});
mw.loader.load(CSS_PAGE, "text/css");
}
let links = new Map();
/*
* Find any element with an associated revid, or any link
* that is NOT descended from an element with a revid
*/
let $elems =
$('#mw-content-text [data-mw-revid], #mw-content-text a:not([data-mw-revid] a)');
for (let e of $elems) {
let $elem, revid = $(e).data('mwRevid') || parseLink(e.href);
// Not a permalink or diff
if (!revid)
continue;
// No data-mw-revid in ancestor
if (mw.config.get('wgCanonicalSpecialPageName') == "AbuseLog")
$elem = $(e).closest('li');
else
$elem = $(e);
$elem.removeClass("external damaging mr-reverted mr-unknown mr-live mr-error");
if (!links.get(revid))
links.set(revid, $elem);
else
links.set(revid, links.get(revid).add($elem));
}
let pages = await getPageInfo(links);
if (pages.size > HARD_PAGE_LIMIT) {
alert(mw.msg('mr-disallow-toomany', pages.size));
return;
} else if (pages.size > SOFT_PAGE_LIMIT) {
if (!confirm(mw.msg('mr-warn-toomany', pages.size)))
return;
}
markAll(pages, links);
}
$.when(mw.loader.using( ["mediawiki.util",
"mediawiki.api"] ),
$.ready).then(() => {
mw.messages.set(MESSAGES);
$(mw.util.addPortletLink(
"p-tb",
"#",
mw.msg('mr-activate-text'),
't-markreverted',
mw.msg('mr-activate-title')
)).click(activate);
});
})();
//