User:Andrybak/Scripts/Contribs ranger.js
Appearance
From Wikipedia, the free encyclopedia
< 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 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'; 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(); })();