User:Polygnotus/Scripts/ContribsRanger.js
// <nowiki>
// From: https://en.wikipedia.org/wiki/User:Andrybak/Scripts/Contribs_ranger.js
//bunch of bugs fixed
/*
* 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');
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 => {
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);
}
);
});
}
/*
* Special:Contributions accepts a 17-digit offset (14-digit timestamp + 3-digit sequence number).
*/
buildOffset(timestamp14) {
return timestamp14 + "001";
}
}
/*
* 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);
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 => {
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);
}
);
});
}
/*
* Special:Log accepts a 17-digit offset (14-digit timestamp + 3-digit sequence number).
*/
buildOffset(timestamp14) {
return timestamp14 + "001";
}
}
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
// Use revids for a direct lookup instead of titles + rvstartid/rvendid,
// which is slow on pages with many revisions.
const queryParams = {
action: 'query',
prop: 'revisions',
rvprop: 'ids|timestamp',
formatversion: 2,
revids: 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);
}
);
});
}
/*
* action=history only accepts a 14-digit timestamp as offset.
* The 17-digit format used by Special:Contributions and Special:Log is silently
* rejected, causing MediaWiki to fall back to showing the most recent N edits.
* We add 1 second to the boundary revision's timestamp to include it in the range.
*/
buildOffset(timestamp14) {
const d = new Date(
Date.UTC(
parseInt(timestamp14.slice(0, 4)),
parseInt(timestamp14.slice(4, 6)) - 1,
parseInt(timestamp14.slice(6, 8)),
parseInt(timestamp14.slice(8, 10)),
parseInt(timestamp14.slice(10, 12)),
parseInt(timestamp14.slice(12, 14)) + 1
)
);
return convertIsoTimestamp(d.toISOString());
}
}
function getUrl(limit, isoTimestamp, rangeHolder) {
const timestamp = convertIsoTimestamp(isoTimestamp);
/*
* buildOffset is implemented per holder class:
* - ContribsRangeHolder and LogRangeHolder append "001" to produce a 17-digit offset.
* - HistoryRangeHolder adds 1 second and returns a 14-digit offset, because
* action=history silently rejects 17-digit offsets.
*/
const offset = rangeHolder.buildOffset(timestamp);
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, rangeHolder);
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();
}
}
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) {
return;
}
if (mw?.loader?.using == undefined) {
setTimeout(main, 200);
return;
}
mw.loader.using(
['mediawiki.util'],
addContribsRangerPortlet
);
}
main();
})();
// </nowiki>
Content Disclaimer
Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.
- The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
- There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
- It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
- Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
- Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.