Jump to content
Wikipedia The Free Encyclopedia

User:Andrybak/Scripts/Contribs ranger.js

From Wikipedia, the free encyclopedia
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.
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
 /*
  * 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';

 constUSERSCRIPT_NAME='Contribs ranger';
 constVERSION=5;
 constLOG_PREFIX=`[${USERSCRIPT_NAME} v${VERSION}]:`;

 functionerror(...toLog){
 console.error(LOG_PREFIX,...toLog);
 }

 functionwarn(...toLog){
 console.warn(LOG_PREFIX,...toLog);
 }

 functioninfo(...toLog){
 console.info(LOG_PREFIX,...toLog);
 }

 functiondebug(...toLog){
 console.debug(LOG_PREFIX,...toLog);
 }

 functionnotify(notificationMessage){
 mw.notify(notificationMessage,{
 title:USERSCRIPT_NAME
 });
 }

 functionerrorAndNotify(errorMessage,rejection){
 error(errorMessage,rejection);
 notify(errorMessage);
 }

 /*
 	 * Removes separators and timezone from a timestamp formatted in ISO 8601.
 	 * Example:
 	 * "2008年07月17日T11:48:39Z" -> "20080717114839"
 	 */
 functionconvertIsoTimestamp(isoTimestamp){
 returnisoTimestamp.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.
 	 */
 constRADIO_BUTTON_GROUP_NAME_PREFIX='contribsRangerRadioGroup';
 constRADIO_BUTTON_GROUP_A_NAME=RADIO_BUTTON_GROUP_NAME_PREFIX+'0';
 constRADIO_BUTTON_GROUP_B_NAME=RADIO_BUTTON_GROUP_NAME_PREFIX+'1';
 letrangeHolderSingleton=null;
 constUI_OUTPUT_LINK_ID='contribsRangerOutputLink';
 constUI_OUTPUT_COUNTER_ID='contribsRangerOutputCounter';
 constUI_OUTPUT_WIKITEXT='contribsRangerOutputWikitext';

 classContribsRangeHolder{
 // 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;

 staticgetInstance(){
 if(rangeHolderSingleton===null){
 rangeHolderSingleton=newContribsRangeHolder();
 }
 returnrangeHolderSingleton;
 }

 updateEndpoints(radioButton){
 constindex=radioButton.value;
 constrevisionId=parseInt(radioButton.parentNode.dataset.mwRevid);
 constpermalink=radioButton.parentElement.querySelector('.mw-changeslist-date');
 if(!permalink){
 errorAndNotify("Cannot find permalink for the selected radio button");
 return;
 }
 constpermalinkUrlStr=permalink.href;
 if(!permalinkUrlStr){
 errorAndNotify("Cannot access the revision for the selected radio button");
 return;
 }
 constpermalinkUrl=newURL(permalinkUrlStr);
 consttitle=permalinkUrl.searchParams.get('title');
 // debug('ContribsRangeHolder.updateEndpoints', title);
 if(radioButton.name===RADIO_BUTTON_GROUP_A_NAME){
 this.setEndpointA(index,revisionId,title);
 }elseif(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(){
 returnMath.abs(this.#indexA-this.#indexB)+1;
 }

 getNewestRevisionId(){
 returnMath.max(this.#revisionIdA,this.#revisionIdB);
 }

 getNewestTitle(){
 if(this.#revisionIdA>this.#revisionIdB){
 returnthis.#titleA;
 }else{
 returnthis.#titleB;
 }
 }

 asyncgetNewestIsoTimestamp(){
 constrevisionId=this.getNewestRevisionId();
 consttitle=this.getNewestTitle();
 returnthis.getIsoTimestamp(revisionId,title);
 }

 #cachedIsoTimestamps={};

 asyncgetIsoTimestamp(revisionId,title){
 if(revisionIdinthis.#cachedIsoTimestamps){
 returnPromise.resolve(this.#cachedIsoTimestamps[revisionId]);
 }
 returnnewPromise((resolve,reject)=>{
 constapi=newmw.Api();
 // https://en.wikipedia.org/w/api.php?action=help&modules=query%2Brevisions
 constqueryParams={
 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);
 constisoTimestamp=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]].
 	 */
 functiongetLoggedActionTitle(url,pageLink){
 constmaybeParam=url.searchParams.get('title');
 if(maybeParam){
 returnmaybeParam;
 }
 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ドル');
 }
 returnurl.pathname.slice(6);// cut off `/wiki/`
 }

 letlogRangeHolderSingleton=null;

 classLogRangeHolder{
 // 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;

 staticgetInstance(){
 if(logRangeHolderSingleton===null){
 logRangeHolderSingleton=newLogRangeHolder();
 }
 returnlogRangeHolderSingleton;
 }

 updateEndpoints(radioButton){
 constindex=radioButton.value;
 constlogId=parseInt(radioButton.parentNode.dataset.mwLogid);
 letpageLink=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;
 }
 constpageUrlStr=pageLink.href;
 if(!pageUrlStr){
 errorAndNotify("Cannot access the logged action for the selected radio button");
 return;
 }
 constpageUrl=newURL(pageUrlStr);
 consttitle=getLoggedActionTitle(pageUrl,pageLink);
 // debug('LogRangeHolder.updateEndpoints:', radioButton, pageUrlStr, pageUrl, title, logId);
 if(radioButton.name===RADIO_BUTTON_GROUP_A_NAME){
 this.setEndpointA(index,logId,title);
 }elseif(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(){
 returnMath.abs(this.#indexA-this.#indexB)+1;
 }

 getNewestLogId(){
 returnMath.max(this.#logIdA,this.#logIdB);
 }

 getNewestTitle(){
 if(this.#logIdA>this.#logIdB){
 returnthis.#titleA;
 }else{
 returnthis.#titleB;
 }
 }

 asyncgetNewestIsoTimestamp(){
 constlogId=this.getNewestLogId();
 consttitle=this.getNewestTitle();
 returnthis.getIsoTimestamp(logId,title);
 }

 #cachedIsoTimestamps={};

 asyncgetIsoTimestamp(logId,title){
 if(titleinthis.#cachedIsoTimestamps){
 returnPromise.resolve(this.#cachedIsoTimestamps[title]);
 }
 returnnewPromise((resolve,reject)=>{
 constapi=newmw.Api();
 // https://en.wikipedia.org/w/api.php?action=help&modules=query%2Blogevents
 constqueryParams={
 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);
 constisoTimestamp=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);
 }
 );
 });
 }
 }

 lethistoryRangeHolderSingleton=null;

 classHistoryRangeHolder{
 // indexes of selected radio buttons, which are enumerated from zero
 #indexA;
 #indexB;
 // revisionIds for the edits at endpoints
 #revisionIdA;
 #revisionIdB;
 // the title
 #title;

 staticgetInstance(){
 if(historyRangeHolderSingleton===null){
 historyRangeHolderSingleton=newHistoryRangeHolder();
 }
 returnhistoryRangeHolderSingleton;
 }

 constructor(){
 constparams=newURLSearchParams(document.location.search);
 this.#title=params.get('title');
 }

 updateEndpoints(radioButton){
 constindex=radioButton.value;
 constrevisionId=parseInt(radioButton.parentNode.dataset.mwRevid);
 constpermalink=radioButton.parentElement.querySelector('.mw-changeslist-date');
 if(!permalink){
 errorAndNotify("Cannot find permalink for the selected radio button");
 return;
 }
 constpermalinkUrlStr=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);
 }elseif(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(){
 returnMath.abs(this.#indexA-this.#indexB)+1;
 }

 getNewestRevisionId(){
 returnMath.max(this.#revisionIdA,this.#revisionIdB);
 }

 asyncgetNewestIsoTimestamp(){
 constrevisionId=this.getNewestRevisionId();
 returnthis.getIsoTimestamp(revisionId);
 }

 #cachedIsoTimestamps={};

 asyncgetIsoTimestamp(revisionId){
 if(revisionIdinthis.#cachedIsoTimestamps){
 returnPromise.resolve(this.#cachedIsoTimestamps[revisionId]);
 }
 returnnewPromise((resolve,reject)=>{
 constapi=newmw.Api();
 // https://en.wikipedia.org/w/api.php?action=help&modules=query%2Brevisions
 constqueryParams={
 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=>{
 constisoTimestamp=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);
 }
 );
 });
 }
 }

 functiongetUrl(limit,isoTimestamp){
 consttimestamp=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.
 		 */
 constoffset=timestamp+"001";
 consturl=newURL(document.location);
 url.searchParams.set('limit',limit);
 url.searchParams.set('offset',offset);
 returnurl.toString();
 }

 functionupdateRangeUrl(rangeHolder){
 constoutputLink=document.getElementById(UI_OUTPUT_LINK_ID);
 outputLink.textContent="Loading";
 constoutputCounter=document.getElementById(UI_OUTPUT_COUNTER_ID);
 outputCounter.textContent="...";
 rangeHolder.getNewestIsoTimestamp().then(
 isoTimestamp=>{
 constsize=rangeHolder.getSize();
 consturl=getUrl(size,isoTimestamp);
 outputLink.href=url;
 outputLink.textContent=url;
 outputCounter.textContent=size;
 },
 rejection=>{
 errorAndNotify("Cannot load newest timestamp",rejection);
 }
 );
 }

 functiononRadioButtonChanged(rangeHolder,event){
 constradioButton=event.target;
 rangeHolder.updateEndpoints(radioButton);
 updateRangeUrl(rangeHolder);
 }

 functionaddRadioButtons(rangeHolder,listClass){
 constRADIO_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; }`);
 constlistItems=document.querySelectorAll(`.${listClass} li`);
 constlen=listItems.length;
 listItems.forEach((listItem,listItemIndex)=>{
 for(leti=0;i<2;i++){
 constradioButton=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);
 }
 }
 });
 }

 functioncreateOutputLink(){
 constoutputLink=document.createElement('a');
 outputLink.id=UI_OUTPUT_LINK_ID;
 outputLink.href='#';
 returnoutputLink;
 }

 functioncreateOutputCounter(){
 constoutputLimitCounter=document.createElement('span');
 outputLimitCounter.id=UI_OUTPUT_COUNTER_ID;
 returnoutputLimitCounter;
 }

 functioncreateOutputWikitextElement(actionNamePlural){
 constoutputWikitext=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}]`));
 returnoutputWikitext;
 }

 functionhandleCopyEvent(copyEvent){
 copyEvent.stopPropagation();
 copyEvent.preventDefault();
 constclipboardData=copyEvent.clipboardData||window.clipboardData;
 constwikitext=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);
 consturl=document.getElementById(UI_OUTPUT_LINK_ID).href;
 constcount=document.getElementById(UI_OUTPUT_COUNTER_ID).innerText;
 consthtmlResult=`<a href=${url}>${count} edits</a>`;
 clipboardData.setData('text/html',htmlResult);
 }

 functioncreateCopyButton(){
 constcopyButton=document.createElement('button');
 copyButton.append("Copy");
 copyButton.onclick=(event)=>{
 document.addEventListener('copy',handleCopyEvent);
 document.execCommand('copy');
 document.removeEventListener('copy',handleCopyEvent);
 notify("Copied!");
 };
 returncopyButton;
 }

 functionaddOutputUi(rangeNamePrefix,actionNamePlural){
 if(document.getElementById(UI_OUTPUT_LINK_ID)){
 info('Already added output UI. Skipping.');
 return;
 }
 constui=document.createElement('span');
 ui.appendChild(document.createTextNode(rangeNamePrefix));
 ui.appendChild(createOutputWikitextElement(actionNamePlural));
 ui.appendChild(document.createTextNode(' '));
 ui.appendChild(createCopyButton());
 mw.util.addSubtitle(ui);
 }

 functionstartRanger(rangeHolder,listClassName,rangeNamePrefix,actionNamePlural){
 addRadioButtons(rangeHolder,listClassName);
 addOutputUi(rangeNamePrefix,actionNamePlural);
 // Populate the UI immediately to direct attention of the user.
 updateRangeUrl(rangeHolder);
 }

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

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

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

 functiononRangerType(logMessage,contribsRanger,logRanger,historyRanger,other){
 constnamespaceNumber=mw.config.get('wgNamespaceNumber');
 if(namespaceNumber===-1){
 constcanonicalSpecialPageName=mw.config.get('wgCanonicalSpecialPageName');
 if(canonicalSpecialPageName==='Contributions'){
 returncontribsRanger();
 }
 if(canonicalSpecialPageName==='Log'){
 returnlogRanger();
 }
 info(`${logMessage}: special page "${canonicalSpecialPageName}" is not Contributions or Log.`);
 }else{
 constaction=mw.config.get('wgAction');
 if(action==='history'){
 returnhistoryRanger();
 }
 info(`${logMessage}: this is a wikipage, but action '${action}' is not 'history'.`);
 }
 returnother();
 }

 functionstartUserscript(){
 info('Starting up...');
 onRangerType(
 'startUserscript',
 startContribsRanger,
 startLogRanger,
 startHistoryRanger,
 ()=>error('startUserscript:','Cannot find which type to start')
 );
 }

 functiongetPortletTexts(){
 returnonRangerType(
 '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 ?"};}
 );
 }

 functionaddContribsRangerPortlet(){
 consttexts=getPortletTexts();
 constlinkText=texts.link;
 constportletId='ca-andrybakContribsSelector';
 consttooltip=texts.tooltip;
 constlink=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
 );
 };
 }

 functionmain(){
 if(mw?.config==undefined){
 setTimeout(main,200);
 return;
 }
 constgood=onRangerType(
 'Function main',
 ()=>true,
 ()=>{
 constuserValue=document.getElementById('mw-input-user')?.querySelector('input')?.value;
 constres=userValue!==null&&userValue!=="";
 if(!res){
 info('A log page, but user is not selected.');
 }
 returnres;
 },
 ()=>true,
 ()=>false
 );
 if(!good){
 info('Aborting.');
 return;
 }
 if(mw?.loader?.using==undefined){
 setTimeout(main,200);
 return;
 }
 mw.loader.using(
 ['mediawiki.util'],
 addContribsRangerPortlet
 );
 }

 main();
 })();

AltStyle によって変換されたページ (->オリジナル) /