/*
* This user script helps linking to a limited set of a user's contributions or logged actions on a wiki.
*/
/* global mw */
(function() {
'use strict';
const USERSCRIPT_NAME = 'Contribs ranger';
const VERSION = 5;
const LOG_PREFIX = `[${USERSCRIPT_NAME} v${VERSION}]:`;
function error(...toLog) {
console.error(LOG_PREFIX, ...toLog);
}
function warn(...toLog) {
console.warn(LOG_PREFIX, ...toLog);
}
function info(...toLog) {
console.info(LOG_PREFIX, ...toLog);
}
function debug(...toLog) {
console.debug(LOG_PREFIX, ...toLog);
}
function notify(notificationMessage) {
mw.notify(notificationMessage, {
title: USERSCRIPT_NAME
});
}
function errorAndNotify(errorMessage, rejection) {
error(errorMessage, rejection);
notify(errorMessage);
}
/*
* Removes separators and timezone from a timestamp formatted in ISO 8601.
* Example:
* "2008-07-17T11:48:39Z" -> "20080717114839"
*/
function convertIsoTimestamp(isoTimestamp) {
return isoTimestamp.slice(0, 4) + isoTimestamp.slice(5, 7) + isoTimestamp.slice(8, 10) +
isoTimestamp.slice(11, 13) + isoTimestamp.slice(14, 16) + isoTimestamp.slice(17, 19);
}
/*
* Two groups of radio buttons are used:
* - contribsRangerRadioGroup0
* - contribsRangerRadioGroup1
* Left column of radio buttons defines endpoint A.
* Right column -- endpoint B.
*/
const RADIO_BUTTON_GROUP_NAME_PREFIX = 'contribsRangerRadioGroup';
const RADIO_BUTTON_GROUP_A_NAME = RADIO_BUTTON_GROUP_NAME_PREFIX + '0';
const RADIO_BUTTON_GROUP_B_NAME = RADIO_BUTTON_GROUP_NAME_PREFIX + '1';
let rangeHolderSingleton = null;
const UI_OUTPUT_LINK_ID = 'contribsRangerOutputLink';
const UI_OUTPUT_COUNTER_ID = 'contribsRangerOutputCounter';
const UI_OUTPUT_WIKITEXT = 'contribsRangerOutputWikitext';
class ContribsRangeHolder {
// indexes of selected radio buttons, which are enumerated from zero
#indexA;
#indexB;
// revisionIds for the contribs at endpoints
#revisionIdA;
#revisionIdB;
// titles of pages edited by contribs at endpoints
#titleA;
#titleB;
static getInstance() {
if (rangeHolderSingleton === null) {
rangeHolderSingleton = new ContribsRangeHolder();
}
return rangeHolderSingleton;
}
updateEndpoints(radioButton) {
const index = radioButton.value;
const revisionId = parseInt(radioButton.parentNode.dataset.mwRevid);
const permalink = radioButton.parentElement.querySelector('.mw-changeslist-date');
if (!permalink) {
errorAndNotify("Cannot find permalink for the selected radio button");
return;
}
const permalinkUrlStr = permalink.href;
if (!permalinkUrlStr) {
errorAndNotify("Cannot access the revision for the selected radio button");
return;
}
const permalinkUrl = new URL(permalinkUrlStr);
const title = permalinkUrl.searchParams.get('title');
// debug('ContribsRangeHolder.updateEndpoints', title);
if (radioButton.name === RADIO_BUTTON_GROUP_A_NAME) {
this.setEndpointA(index, revisionId, title);
} else if (radioButton.name === RADIO_BUTTON_GROUP_B_NAME) {
this.setEndpointB(index, revisionId, title);
}
}
setEndpointA(index, revisionId, title) {
this.#indexA = index;
this.#revisionIdA = revisionId;
this.#titleA = title;
}
setEndpointB(index, revisionId, title) {
this.#indexB = index;
this.#revisionIdB = revisionId;
this.#titleB = title;
}
getSize() {
return Math.abs(this.#indexA - this.#indexB) + 1;
}
getNewestRevisionId() {
return Math.max(this.#revisionIdA, this.#revisionIdB);
}
getNewestTitle() {
if (this.#revisionIdA > this.#revisionIdB) {
return this.#titleA;
} else {
return this.#titleB;
}
}
async getNewestIsoTimestamp() {
const revisionId = this.getNewestRevisionId();
const title = this.getNewestTitle();
return this.getIsoTimestamp(revisionId, title);
}
#cachedIsoTimestamps = {};
async getIsoTimestamp(revisionId, title) {
if (revisionId in this.#cachedIsoTimestamps) {
return Promise.resolve(this.#cachedIsoTimestamps[revisionId]);
}
return new Promise((resolve, reject) => {
const api = new mw.Api();
// https://en.wikipedia.org/w/api.php?action=help&modules=query%2Brevisions
const queryParams = {
action: 'query',
prop: 'revisions',
rvprop: 'ids|user|timestamp',
rvslots: 'main',
formatversion: 2, // v2 has nicer field names in responses
/*
* Class ContribsRangeHolder doesn't need conversion via decodeURIComponent, because
* the titles are gotten through URLSearchParams, which does the decoding for us.
*/
titles: title,
rvstartid: revisionId,
rvendid: revisionId,
};
api.get(queryParams).then(
response => {
// debug('Q:', queryParams);
// debug('R:', response);
const isoTimestamp = response?.query?.pages[0]?.revisions[0]?.timestamp;
if (!isoTimestamp) {
reject(`Cannot get timestamp for revision ${revisionId} of ${title}.`);
return;
}
this.#cachedIsoTimestamps[revisionId] = isoTimestamp;
resolve(isoTimestamp);
},
rejection => {
reject(rejection);
}
);
});
}
}
/*
* Extracts a relevant page's title from a link, which appears
* in entries on [[Special:Log]].
*/
function getLoggedActionTitle(url, pageLink) {
const maybeParam = url.searchParams.get('title');
if (maybeParam) {
return maybeParam;
}
if (pageLink.classList.contains('mw-anonuserlink')) {
/*
* Prefix 'User:' works in API queries regardless of localization
* of the User namespace.
* Example: https://ru.wikipedia.org/w/api.php?action=query&list=logevents&leuser=Deinocheirus&letitle=User:2A02:908:1A12:FD40:0:0:0:837A
*/
return 'User:' + url.pathname.replaceAll(/^.*\/([^\/]+)$/g, '$1');
}
return url.pathname.slice(6); // cut off `/wiki/`
}
let logRangeHolderSingleton = null;
class LogRangeHolder {
// indexes of selected radio buttons, which are enumerated from zero
#indexA;
#indexB;
// logIds for the contribs at endpoints
#logIdA;
#logIdB;
// titles of pages edited by contribs at endpoints
#titleA;
#titleB;
static getInstance() {
if (logRangeHolderSingleton === null) {
logRangeHolderSingleton = new LogRangeHolder();
}
return logRangeHolderSingleton;
}
updateEndpoints(radioButton) {
const index = radioButton.value;
const logId = parseInt(radioButton.parentNode.dataset.mwLogid);
let pageLink = radioButton.parentElement.querySelector('.mw-usertoollinks + a');
if (!pageLink) {
errorAndNotify("Cannot find pageLink for the selected radio button");
return;
}
/*
* This is a very weird way to check this, but whatever.
* Example:
* https://en.wikipedia.org/w/index.php?title=Special:Log&logid=162280736
* when viewed in a log, like this:
* https://en.wikipedia.org/wiki/Special:Log?type=protect&user=Izno&page=&wpdate=&tagfilter=&wpfilters%5B%5D=newusers&wpFormIdentifier=logeventslist&limit=4&offset=20240526233513001
*/
if (pageLink.nextElementSibling?.nextElementSibling?.className === "comment") {
// two pages are linked in the logged action, we are interested in the second page
pageLink = pageLink.nextElementSibling;
}
const pageUrlStr = pageLink.href;
if (!pageUrlStr) {
errorAndNotify("Cannot access the logged action for the selected radio button");
return;
}
const pageUrl = new URL(pageUrlStr);
const title = getLoggedActionTitle(pageUrl, pageLink);
// debug('LogRangeHolder.updateEndpoints:', radioButton, pageUrlStr, pageUrl, title, logId);
if (radioButton.name === RADIO_BUTTON_GROUP_A_NAME) {
this.setEndpointA(index, logId, title);
} else if (radioButton.name === RADIO_BUTTON_GROUP_B_NAME) {
this.setEndpointB(index, logId, title);
}
}
setEndpointA(index, logId, title) {
this.#indexA = index;
this.#logIdA = logId;
this.#titleA = title;
}
setEndpointB(index, logId, title) {
this.#indexB = index;
this.#logIdB = logId;
this.#titleB = title;
}
getSize() {
return Math.abs(this.#indexA - this.#indexB) + 1;
}
getNewestLogId() {
return Math.max(this.#logIdA, this.#logIdB);
}
getNewestTitle() {
if (this.#logIdA > this.#logIdB) {
return this.#titleA;
} else {
return this.#titleB;
}
}
async getNewestIsoTimestamp() {
const logId = this.getNewestLogId();
const title = this.getNewestTitle();
return this.getIsoTimestamp(logId, title);
}
#cachedIsoTimestamps = {};
async getIsoTimestamp(logId, title) {
if (title in this.#cachedIsoTimestamps) {
return Promise.resolve(this.#cachedIsoTimestamps[title]);
}
return new Promise((resolve, reject) => {
const api = new mw.Api();
// https://en.wikipedia.org/w/api.php?action=help&modules=query%2Blogevents
const queryParams = {
action: 'query',
list: 'logevents',
lelimit: 500,
leuser: document.getElementById('mw-input-user').querySelector('input').value,
/*
* Decoding is needed to fix `invalidtitle`:
* 'Wikipedia:Bureaucrats%27_noticeboard' -> "Wikipedia:Bureaucrats'_noticeboard"
*/
letitle: decodeURIComponent(title),
};
api.get(queryParams).then(
response => {
// debug('Q:', queryParams, logId);
// debug('R:', response);
const isoTimestamp = response.query?.logevents?.find(logevent => logevent.logid === logId)?.timestamp;
if (!isoTimestamp) {
reject(`Cannot get timestamp for logged action ${logId} of ${title}.`);
return;
}
this.#cachedIsoTimestamps[title] = isoTimestamp;
resolve(isoTimestamp);
},
rejection => {
reject(rejection);
}
);
});
}
}
let historyRangeHolderSingleton = null;
class HistoryRangeHolder {
// indexes of selected radio buttons, which are enumerated from zero
#indexA;
#indexB;
// revisionIds for the edits at endpoints
#revisionIdA;
#revisionIdB;
// the title
#title;
static getInstance() {
if (historyRangeHolderSingleton === null) {
historyRangeHolderSingleton = new HistoryRangeHolder();
}
return historyRangeHolderSingleton;
}
constructor() {
const params = new URLSearchParams(document.location.search);
this.#title = params.get('title');
}
updateEndpoints(radioButton) {
const index = radioButton.value;
const revisionId = parseInt(radioButton.parentNode.dataset.mwRevid);
const permalink = radioButton.parentElement.querySelector('.mw-changeslist-date');
if (!permalink) {
errorAndNotify("Cannot find permalink for the selected radio button");
return;
}
const permalinkUrlStr = permalink.href;
if (!permalinkUrlStr) {
errorAndNotify("Cannot access the revision for the selected radio button");
return;
}
if (radioButton.name === RADIO_BUTTON_GROUP_A_NAME) {
this.setEndpointA(index, revisionId);
} else if (radioButton.name === RADIO_BUTTON_GROUP_B_NAME) {
this.setEndpointB(index, revisionId);
}
}
setEndpointA(index, revisionId) {
this.#indexA = index;
this.#revisionIdA = revisionId;
}
setEndpointB(index, revisionId) {
this.#indexB = index;
this.#revisionIdB = revisionId;
}
getSize() {
return Math.abs(this.#indexA - this.#indexB) + 1;
}
getNewestRevisionId() {
return Math.max(this.#revisionIdA, this.#revisionIdB);
}
async getNewestIsoTimestamp() {
const revisionId = this.getNewestRevisionId();
return this.getIsoTimestamp(revisionId);
}
#cachedIsoTimestamps = {};
async getIsoTimestamp(revisionId) {
if (revisionId in this.#cachedIsoTimestamps) {
return Promise.resolve(this.#cachedIsoTimestamps[revisionId]);
}
return new Promise((resolve, reject) => {
const api = new mw.Api();
// https://en.wikipedia.org/w/api.php?action=help&modules=query%2Brevisions
const queryParams = {
action: 'query',
prop: 'revisions',
rvprop: 'ids|user|timestamp',
rvslots: 'main',
formatversion: 2, // v2 has nicer field names in responses
/*
* Class HistoryRangeHolder doesn't need conversion via decodeURIComponent, because
* the titles are gotten through URLSearchParams, which does the decoding for us.
*/
titles: this.#title,
rvstartid: revisionId,
rvendid: revisionId,
};
api.get(queryParams).then(
response => {
const isoTimestamp = response?.query?.pages[0]?.revisions[0]?.timestamp;
if (!isoTimestamp) {
reject(`Cannot get timestamp for revision ${revisionId}.`);
return;
}
this.#cachedIsoTimestamps[revisionId] = isoTimestamp;
resolve(isoTimestamp);
},
rejection => {
reject(rejection);
}
);
});
}
}
function getUrl(limit, isoTimestamp) {
const timestamp = convertIsoTimestamp(isoTimestamp);
/*
* Append one millisecond to get the latest contrib/logged action in the range.
* Assuming users aren't doing more than one edit/logged action per millisecond.
*/
const offset = timestamp + "001";
const url = new URL(document.location);
url.searchParams.set('limit', limit);
url.searchParams.set('offset', offset);
return url.toString();
}
function updateRangeUrl(rangeHolder) {
const outputLink = document.getElementById(UI_OUTPUT_LINK_ID);
outputLink.textContent = "Loading";
const outputCounter = document.getElementById(UI_OUTPUT_COUNTER_ID);
outputCounter.textContent = "...";
rangeHolder.getNewestIsoTimestamp().then(
isoTimestamp => {
const size = rangeHolder.getSize();
const url = getUrl(size, isoTimestamp);
outputLink.href = url;
outputLink.textContent = url;
outputCounter.textContent = size;
},
rejection => {
errorAndNotify("Cannot load newest timestamp", rejection);
}
);
}
function onRadioButtonChanged(rangeHolder, event) {
const radioButton = event.target;
rangeHolder.updateEndpoints(radioButton);
updateRangeUrl(rangeHolder);
}
function addRadioButtons(rangeHolder, listClass) {
const RADIO_BUTTON_CLASS = 'contribsRangerRadioSelectors';
if (document.querySelectorAll(`.${RADIO_BUTTON_CLASS}`).length > 0) {
info('Already added input radio buttons. Skipping.');
return;
}
mw.util.addCSS(`.${RADIO_BUTTON_CLASS} { margin: 0 1.75rem 0 0.25rem; }`);
const listItems = document.querySelectorAll(`.${listClass} li`);
const len = listItems.length;
listItems.forEach((listItem, listItemIndex) => {
for (let i = 0; i < 2; i++) {
const radioButton = document.createElement('input');
radioButton.type = 'radio';
radioButton.name = RADIO_BUTTON_GROUP_NAME_PREFIX + i;
radioButton.classList.add(RADIO_BUTTON_CLASS);
radioButton.value = listItemIndex;
radioButton.addEventListener('change', event => onRadioButtonChanged(rangeHolder, event));
listItem.prepend(radioButton);
// top and bottom radio buttons are selected by default
if (listItemIndex === 0 && i === 0) {
radioButton.checked = true;
rangeHolder.updateEndpoints(radioButton);
}
if (listItemIndex === len - 1 && i === 1) {
radioButton.checked = true;
rangeHolder.updateEndpoints(radioButton);
}
}
});
}
function createOutputLink() {
const outputLink = document.createElement('a');
outputLink.id = UI_OUTPUT_LINK_ID;
outputLink.href = '#';
return outputLink;
}
function createOutputCounter() {
const outputLimitCounter = document.createElement('span');
outputLimitCounter.id = UI_OUTPUT_COUNTER_ID;
return outputLimitCounter;
}
function createOutputWikitextElement(actionNamePlural) {
const outputWikitext = document.createElement('span');
outputWikitext.style.fontFamily = 'monospace';
outputWikitext.id = UI_OUTPUT_WIKITEXT;
outputWikitext.appendChild(document.createTextNode("["));
outputWikitext.appendChild(createOutputLink());
outputWikitext.appendChild(document.createTextNode(" "));
outputWikitext.appendChild(createOutputCounter());
outputWikitext.appendChild(document.createTextNode(` ${actionNamePlural}]`));
return outputWikitext;
}
function handleCopyEvent(copyEvent) {
copyEvent.stopPropagation();
copyEvent.preventDefault();
const clipboardData = copyEvent.clipboardData || window.clipboardData;
const wikitext = document.getElementById(UI_OUTPUT_WIKITEXT).innerText;
clipboardData.setData('text/plain', wikitext);
/*
* See file `ve.ce.MWWikitextSurface.js` in repository
* https://github.com/wikimedia/mediawiki-extensions-VisualEditor
*/
clipboardData.setData('text/x-wiki', wikitext);
const url = document.getElementById(UI_OUTPUT_LINK_ID).href;
const count = document.getElementById(UI_OUTPUT_COUNTER_ID).innerText;
const htmlResult = `<a href=${url}>${count} edits</a>`;
clipboardData.setData('text/html', htmlResult);
}
function createCopyButton() {
const copyButton = document.createElement('button');
copyButton.append("Copy");
copyButton.onclick = (event) => {
document.addEventListener('copy', handleCopyEvent);
document.execCommand('copy');
document.removeEventListener('copy', handleCopyEvent);
notify("Copied!");
};
return copyButton;
}
function addOutputUi(rangeNamePrefix, actionNamePlural) {
if (document.getElementById(UI_OUTPUT_LINK_ID)) {
info('Already added output UI. Skipping.');
return;
}
const ui = document.createElement('span');
ui.appendChild(document.createTextNode(rangeNamePrefix));
ui.appendChild(createOutputWikitextElement(actionNamePlural));
ui.appendChild(document.createTextNode(' '));
ui.appendChild(createCopyButton());
mw.util.addSubtitle(ui);
}
function startContribsRanger() {
const rangeHolder = ContribsRangeHolder.getInstance();
addRadioButtons(rangeHolder, 'mw-contributions-list');
addOutputUi("Contributions range: ", "edits");
// Populate the UI immediately to direct attention of the user.
updateRangeUrl(rangeHolder);
}
function startLogRanger() {
const rangeHolder = LogRangeHolder.getInstance();
addRadioButtons(rangeHolder, 'mw-logevent-loglines');
addOutputUi("Log range: ", "log actions");
// Populate the UI immediately to direct attention of the user.
updateRangeUrl(rangeHolder);
}
function startHistoryRanger() {
const rangeHolder = HistoryRangeHolder.getInstance();
addRadioButtons(rangeHolder, 'mw-contributions-list');
addOutputUi("History range: ", "edits");
// Populate the UI immediately to direct attention of the user.
updateRangeUrl(rangeHolder);
}
function onRangerType(logMessage, contribsRanger, logRanger, historyRanger, other) {
const namespaceNumber = mw.config.get('wgNamespaceNumber');
if (namespaceNumber === -1) {
const canonicalSpecialPageName = mw.config.get('wgCanonicalSpecialPageName');
if (canonicalSpecialPageName === 'Contributions') {
return contribsRanger();
}
if (canonicalSpecialPageName === 'Log') {
return logRanger();
}
info(`${logMessage}: special page "${canonicalSpecialPageName}" is not Contributions or Log.`);
} else {
const action = mw.config.get('wgAction');
if (action === 'history') {
return historyRanger();
}
info(`${logMessage}: this is a wikipage, but action '${action}' is not 'history'.`);
}
return other();
}
function startUserscript() {
info('Starting up...');
onRangerType(
'startUserscript',
startContribsRanger,
startLogRanger,
startHistoryRanger,
() => error('startUserscript:', 'Cannot find which type to start')
);
}
function getPortletTexts() {
return onRangerType(
'getPortletTexts',
() => { return { link: "Contribs ranger", tooltip: "Select a range of contributions" }; },
() => { return { link: "Log ranger", tooltip: "Select a range of log actions" }; },
() => { return { link: "History ranger", tooltip: "Select a range of page history" }; },
() => { return { link: "? ranger", tooltip: "Select a range of ?" }; }
);
}
function addContribsRangerPortlet() {
const texts = getPortletTexts();
const linkText = texts.link;
const portletId = 'ca-andrybakContribsSelector';
const tooltip = texts.tooltip;
const link = mw.util.addPortletLink('p-cactions', '#', linkText, portletId, tooltip);
link.onclick = event => {
event.preventDefault();
// TODO maybe implement toggling the UI on-off
mw.loader.using(
['mediawiki.api'],
startUserscript
);
};
}
function main() {
if (mw?.config == undefined) {
setTimeout(main, 200);
return;
}
const good = onRangerType(
'Function main',
() => true,
() => {
const userValue = document.getElementById('mw-input-user')?.querySelector('input')?.value;
const res = userValue !== null && userValue !== "";
if (!res) {
info('A log page, but user is not selected.');
}
return res;
},
() => true,
() => false
);
if (!good) {
info('Aborting.');
return;
}
if (mw?.loader?.using == undefined) {
setTimeout(main, 200);
return;
}
mw.loader.using(
['mediawiki.util'],
addContribsRangerPortlet
);
}
main();
})();