/*
* 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 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');
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();
})();