User:Andrybak/Scripts/Contribs ranger.js

/*

* 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 = `${count} edits`;

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 startRanger(rangeHolder, listClassName, rangeNamePrefix, actionNamePlural) {

addRadioButtons(rangeHolder, listClassName);

addOutputUi(rangeNamePrefix, actionNamePlural);

// Populate the UI immediately to direct attention of the user.

updateRangeUrl(rangeHolder);

}

function startContribsRanger() {

startRanger(ContribsRangeHolder.getInstance(), 'mw-contributions-list', "Contributions range: ", "edits");

}

function startLogRanger() {

startRanger(LogRangeHolder.getInstance(), 'mw-logevent-loglines', "Log range: ", "log actions");

}

function startHistoryRanger() {

startRanger(HistoryRangeHolder.getInstance(), 'mw-contributions-list', "History range: ", "edits");

}

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();

})();