User:Andrybak/Scripts/Contribs ranger.js
Appearance
< User:Andrybak | Scripts
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
This user script seems to have a documentation page at User:Andrybak/Scripts/Contribs ranger. |
/*
* 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://wiki.riteme.site/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://wiki.riteme.site/w/index.php?title=Special:Log&logid=162280736
* when viewed in a log, like this:
* https://wiki.riteme.site/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://wiki.riteme.site/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://wiki.riteme.site/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 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();
})();