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 on a wiki.
*/
/* global mw */
(function() {
'use strict';
const USERSCRIPT_NAME = 'Contribs ranger';
const LOG_PREFIX = `[${USERSCRIPT_NAME}]:`;
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 diffUrlStr = radioButton.parentElement.querySelector('.mw-changeslist-diff').href;
const diffUrl = new URL(diffUrlStr);
const title = diffUrl.searchParams.get('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();
const queryParams = {
action: 'query',
prop: 'revisions',
rvprop: 'ids|user|timestamp',
rvslots: 'main',
formatversion: 2, // v2 has nicer field names in responses
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);
}
);
});
}
}
function getUrl(limit, isoTimestamp) {
const timestamp = convertIsoTimestamp(isoTimestamp);
/*
* Append one millisecond to get the latest contrib in the range.
* Assuming users aren't doing more than one edit 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) {
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 contribsListItems = document.querySelectorAll('.mw-contributions-list li');
const len = contribsListItems.length;
contribsListItems.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() {
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(" edits]"));
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() {
if (document.getElementById(UI_OUTPUT_LINK_ID)) {
info('Already added output UI. Skipping.');
return;
}
const ui = document.createElement('span');
ui.appendChild(document.createTextNode("Contributions range: "));
ui.appendChild(createOutputWikitextElement());
ui.appendChild(document.createTextNode(' '));
ui.appendChild(createCopyButton());
mw.util.addSubtitle(ui);
}
function startContribsRanger() {
info('Starting up...');
const rangeHolder = ContribsRangeHolder.getInstance();
addRadioButtons(rangeHolder);
addOutputUi();
// Populate the UI immediately to direct attention of the user.
updateRangeUrl(rangeHolder);
}
function addContribsRangerPortlet() {
const linkText = "Contribs ranger";
const portletId = 'ca-andrybakContribsSelector';
const tooltip = "Select a range of contributions";
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'],
startContribsRanger
);
};
}
function main() {
if (mw?.config == undefined) {
setTimeout(main, 200);
return;
}
const namespaceNumber = mw.config.get('wgNamespaceNumber');
if (namespaceNumber !== -1) {
info('Not a special page. Aborting.');
return;
}
const canonicalSpecialPageName = mw.config.get('wgCanonicalSpecialPageName');
if (canonicalSpecialPageName !== 'Contributions') {
info('Not a contributions page. Aborting.');
return;
}
if (mw?.loader?.using == undefined) {
setTimeout(main, 200);
return;
}
mw.loader.using(
['mediawiki.util'],
addContribsRangerPortlet
);
}
main();
})();