User:Js/tools/combinedContribs.js

var disabledEvents var rollbackRegex = /Help:Reverting">Reverted<\/a> edits by <a.* to last version by / //" var scriptPage = 'User:Js'

var msg = {legend:'Combined contributions' }

function mm(txt){ return msg[txt] || txt }

var images = {usercontribs: 'Δ' ,newpage:'N' ,deletedrevs: ' Δ ' // Λ ,review: '✓' ,review_unapprove: ' ✓ ' ,patrol:'c/c5/Blue_check_PD.svg' ,move: 'b/b9/Gtk-go-forward-ltr.svg'//' 9/94/Gnome-go-next.svg' ,upload: 'c/ca/Icon_-_upload_photo.svg' ,upload_overwrite:'c/cd/Image_upload-tango.svg' ,upload_revert:'8/83/Gnome-insert-image.svg' ,block: '5/50/NIK-interdit.svg' // 'f/f1/Stop_hand_nuvola.svg' // 'a/aa/Commons-emblem-hand-orange.svg' ,block_unblock: 'a/ab/Torchlight_reload.png' //'5/5f/Process-stop_Gion.svg' //'4/4f/Blocked_user.svg' ,'delete': 'c/c3/Chess_tile_xr.png' ,delete_restore: 'a/ad/Chess_tile_xg.png' // try File:Add.svg ,delete_revision:'8/89/Gnome-edit-cut.svg' //,'delete': '1/16/Deletion_icon.svg' //'d/de/User-trash-empty-4.svg' ,protect: '2/2c/Padlock-dash.svg' ,protect_unprotect:'3/3d/Padlock-silver-medium-open.png' ,protect_move_prot:'1/13/Padlock-olive-arrow.svg' ,protect_create:'7/70/Padlock-pink.svg' ,rights: '4/4a/Nuvola_apps_kgpg2.svg' ,renameuser: 'e/ed/Nuvola_apps_kuser.svg' ,newusers: '6/63/Bathrobecabalicon.svg' ,abuselog: 'e/ee/Emblem-unblock-granted.svg' //'a/a4/Text_document_with_red_question_mark.svg' ,abuselog_warn: 'e/ec/Emblem-unblock-request.svg' ,abuselog_disallow: '5/59/Emblem-unblock-denied.svg' ,abusefilter: 'af' ,rollback:'←' ,userlink:'1/12/User_icon_2.svg' } // 'block|protect|delete|upload|move|review|stable|rights|abusefilter|renameuser', // import, patrol, merge, suppress, gblblock, globalauth, gblrights, newusers function image(tt, sz){ tt = images[tt] || '' if( /\.(svg|png)$/.test(tt) ) tt = apl.icon(tt, sz || 15) return tt }

// !! could use API action=query&titles=File:Emblem-unblock-request.svg|File:Emblem-unblock-granted.svg&prop=imageinfo&iiprop=url&iiurlwidth=15

var inpUsers var date1, date2

var pendingRequests var events var isMultiple

var allTitles var userStats, userStatTypes var firstRequestDone

mw.util.addCSS('\ td.user, td.details {font-size:smaller}\ tr.review td.type {opacity:0.6}\ tr.review td.details {color:gray}\ tr.approve-a td.details {font-size:smaller; opacity:0.6}\ tr.deletedrevs td.type a {color:gray}\ tr.abuselog td.details {font-size:smaller}\ span.autoapprove {color:gray; font-size:smaller}\ tr.rollback td.details {font-size:smaller}\ tr.uc-top td.type {border-right: 1px dotted #bbb}\ span.uc-minor {font-weight:bold; font-size:smaller}\ tr.logevents td.title {font-size:smaller}\ tr.unapprove td.type a {color:orange}\ tr.hidden {opacity:0.4}\ ')
 * 1) output {font-size:88%}\
 * 2) msg_container {position:relative}\
 * 3) msg_toggle {position:absolute; right:2px; top:0; border:1px solid gray; cursor:pointer}\
 * 4) msg_content {height:150px; overflow:auto; border:1px inset gray; background:white}\

var blankPageCSS

if( wgPageName == scriptPage ) start

function start{

blankPageCSS = mw.util.addCSS('body > * {display:none}')

$(' \ X \ '+mm('legend')+' \ \ \ \ From:  \ Time:  \ \ \ \ \ \ \  \ \ \ \ \ \ ') .appendTo(document.body) .show

//prepare form urlToForm('#dialog form') $('#users').keyup(autoResizeTbox).each(autoResizeTbox) $('#go').click(formSubmit)

//prepare other elements $('#msg_toggle').click( function { $('#msg_content').slideToggle }) $('#js-close').click( function{ $('#output').remove; blankPageCSS.disabled = true })

// function autoResizeTbox{ var hh = Math.min( 200, Math.max(this.scrollHeight, this.clientHeight) ) if (hh > this.clientHeight) this.style.height = hh + 'px' }

} //start

function formSubmit{ //analyze form data & start request

dispMsg('clear'); dispMsg('show') inpUsers = $('#users').val.replace(/\r/g,'').replace(/ *(\n|,|;) */g,'|').split('|')

//dates date1 = input2Date( 'date_1' ) if( date1 == 'error' ) return date2 = input2Date( 'date_2', date1 ) if( date2 == 'error' ) return //check that date2 < date1 if( date2 && ( ( date1 || getCurrentTime ) - date2 < 10000 ) ) return dispMsg('Time period is too small or negative','red') dispMsg( 'Form: current parameters')

contribsRequest }

function input2Date( inp, fromDate ){ //text input ->  date or 'error' var dd = fromDate ? new Date(fromDate) : getCurrentTime // "new Date" is to avoid modifying existing object var str = $('#'+inp).val.replace(/\s/g,'') if( str == $('#'+inp).attr('placeholder') ) str = '' if( !str ){ dd = null //date is omitted }else if( /^\d{8,14}$/.test(str) ){ while( str.length < 14 ) str += '0' //fill 0's to the full timestamp dd = apl.parseTimestamp(str) }else if( /^\d+d?$/.test(str) ){ //adjust days dd.setDate( dd.getDate - parseInt(str)) }else if( /^\d+h$/.test(str) ){ //adjust hours dd.setHours( dd.getHours - parseInt(str) ) }else{ dd = 'error' dispMsg('cannot parse date «' + str + '»', 'red') }  return dd }

function contribsRequest{

//initialize before API requests var ipRX = /^((\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.){3}(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$/ events = [] userStats = {} pendingRequests = 0 userStatTypes = {} $('#results').empty var user, curReq var range, octet, lst //for CIDRs isMultiple = ( inpUsers.length > 1 ) //loop through all users for( var u=0; u<inpUsers.length; u++ ){ user = $.trim(inpUsers[u]) if( user == '' ) continue //probably an empty line curReq = { requestid: user, list: '' } //basic request; 'prop:flags' is here to get 'top edit' flag addRequest('usercontribs', 'uc', {prop:'timestamp|title|parsedcomment|patrolled|ids|flags'} )

//check what kind of user if( range = /\/(\d\d)$/.exec( user ) ){ //IP range range = parseInt(range[1]) octet = user.replace (/\/\d\d$/, '') if( ! ipRX.test(octet) ) return dispMsg('Cannot parse range: ' + user, 'red') octet = /^(\d+\.)(\d+\.)(\d+\.)(\d+)$/.exec(octet) isMultiple = true delete curReq.ucuser //using ucuserprefix instead

switch( range ){

case 13: case 14: case 15: //make several /16 requests lst = makeOctetsList(octet[2], 16 - range) for( var i=0; i<lst.length; i++ ){ curReq.ucuserprefix = octet[1] + lst[i] + '.' queryAPI( curReq, contribsReceive ) }        curReq = null break case 16: case 17: case 18: case 19: //request /16 and filter results later curReq.ucuserprefix = octet[1] + octet[2] break

case 20: case 21: case 22: case 23: //make several (16, 8,4,2)  /24 requests lst = makeOctetsList( octet[3], 24 - range) for( var i=0; i<lst.length; i++ ){ curReq.ucuserprefix = octet[1] + octet[2] + lst[i] + '.' queryAPI( curReq, contribsReceive ) }        curReq = null break case 24: case 25: case 26: //request /24 and filter results later curReq.ucuserprefix = octet[1] + octet[2] + octet[3] break case 27: case 28: case 29: case 30: case 31: //request contribs for a list of IPs, up to 32 with /27 lst = makeOctetsList(octet[4], 32 - range) for( var i=0; i<lst.length; i++ ) lst[i] = octet[1] + octet[2] + octet[3] + lst[i] curReq.ucuser = lst.join('|') break

default: return dispMsg(user + ': invalid range, supported are /13 - /31', 'red') }

}else if( /\*$/.test(user) ){ // e.g. 1.2.3.* isMultiple = true curReq.ucuserprefix = user.replace(/\*$/,'') // and 'ucuser' (added later) will be ignored }else{ //just one user addRequest('abuselog', 'afl', {prop:'timestamp|title|ids|filter|result'} ) //deletedrevs for sysops if( /sysop-/.exec( wgUserGroups.join('-') + '-' ) ) addRequest('deletedrevs', 'dr', {prop:'parsedcomment|minor'} ) //logevents for non-IP if( ! ipRX.test(user) ) addRequest('logevents', 'le', {prop: 'timestamp|title|parsedcomment|type|details'} ) }  //call api if( curReq ) queryAPI( curReq, contribsReceive ) //start spinner apl.spinner('#dialog')

}

// function addRequest(name, prefix, params){ $.extend(params, {limit:500, user:user }) if( date1 ) params.start = apl.makeAPITimestamp(date1) if( date2 ) params.end  = apl.makeAPITimestamp(date2) for (var k in params) curReq[ prefix + k ] = params[k] curReq.list += (curReq.list ? '|' : '') + name userStatTypes[name] = true }

//function to convert "range" into smaller subranges // ('0', 2) -> [0,1,2,4],  ('128', 2) ->[128, 129, 130, 131],  ('0',3) -> [0,1,2,4,5,6,7], //from http://en.wikipedia.org/wiki/MediaWiki:Gadget-contribsrange.js function makeOctetsList(octetStart, binaryDigits){ var lst = [] var count = Math.pow(2, binaryDigits) octetStart = parseInt( octetStart.replace(/\./,'') ) octetStart = octetStart - octetStart % count for( var i=0; i 60 ) dispMsg('Your time is different from server time: ' + timeDiff + ' sec', 'orange') }  firstRequestDone = true

//prepare userStats objects, set "there was more" flags var curUser = resp.requestid userStats[curUser] = {} for (var tp in resp['query-continue'] ) userStats[curUser][tp+'-more'] = true

//check for API arrors, e.g. "User X not found" if( resp.error  )  userStats[curUser].error = resp.error if( resp.warning ) jsMsg(resp.warning.code + ':' + resp.warning.info)

//process data if( resp.query ) contribsProcess( resp.query, curUser )

if( --pendingRequests <= 0 ) contribsShow //all data received }

function contribsProcess(data, curUser){ //called after each API response

//usercontribs - remove results that are outside of target IP range if ( /\/(\d\d)$/.test(curUser) && data.usercontribs ){ var checkRange = parseRange( curUser ) var UC = [] for ( var i=0; i  append as separate events if( data.deletedrevs ){ var DR = [] for( var i=0; i<data.deletedrevs.length; i++ ) for( var r=0; r < data.deletedrevs[i].revisions.length; r++ ) // .revisions[r] provides: parsedcomment, timestamp, minor DR.push( $.extend( { title: data.deletedrevs[i].title }, data.deletedrevs[i].revisions[r] ) ) //replace data delete data.deletedrevs data.deletedrevs = DR } //append all events into events[] for( var curSource in data ) for ( var i=0; i<data[curSource].length; i++ ){ var ev = data[curSource][i] if( disabledEvents && disabledEvents.test(ev.type) ) continue ev.source = curSource if( !ev.type ) ev.type = curSource //fill .type with 'usercontribs' / 'deletedrevs', like in logevents if( !ev.user ) ev.user = curUser events.push( ev ) //keep per-user stats if( !userStats[curUser][curSource] ) userStats[curUser][curSource] = 0 userStats[curUser][curSource]++ } }

function contribsShow{ //called when all responses are received

dispMsg('All data received.'); dispMsg('hide'); apl.spinner

//first show per-user stats var htm = ' ' )

if( events.length == 0 ) return var i, ev, prev // 'prev' is used by findPrev below allTitles = {}

//sort by timestamp events.sort(function(a, b){  if(      a.timestamp < b.timestamp ) return 1    else if( a.timestamp > b.timestamp ) return -1   else if( a.type == 'review'        ) return -1 //review first   else if( b.type == 'review'        ) return  1   else if( /usercontribs|deletedrevs/.test(a.type) ) return 1 // logevents first   else return 0 })

//create output values for every event for (i=0; i<events.length; i++){

ev = events[i] //initial values ev.classes = ev.source if( ev.source == 'logevents' ) ev.classes += ' ' + ev.type + ' ' + (ev.action != ev.type ? ev.action : '') ev.iconTD = image(ev.type+'_'+ev.action) || image(ev.type) || ' '+ev.type.substr(0,3)+' ' ev.titleTD = apl.output( ev.title, 'title' ) ev.detailsTD = ev.parsedcomment || ''

switch( ev.type ){

case 'usercontribs':

if( /\[(edit|move)=/.test(ev.parsedcomment) && findPrev('protect') ){ ev.iconTD = prev.iconTD ev.classes += ' ' + prev.classes prev.hidden = true }      if( rollbackRegex.test(ev.parsedcomment) ){ ev.iconTD = image('rollback') ev.classes += ' rollback' }else if( typeof ev.minor == 'string'){ ev.detailsTD = ' m ' + ev.detailsTD ev.classes += ' uc-minor' }        if( typeof ev['new'] == 'string'){ ev.iconTD = image('newpage') ev.classes += ' uc-newpage' }

ev.iconTD = outputIntLink('diff='+ev.revid, ev.iconTD)

if( typeof ev.top == 'string'){ //ev.titleTD += ' (top) ' //ev.iconTD += ' ^ ' ev.classes += ' uc-top' }

break

case 'deletedrevs': ev.iconTD = outputIntLink('title=Special:undelete&diff=prev&target='       + encodeURIComponent(ev.title) + '&timestamp=' + ev.timestamp, ev.iconTD) break

case 'abuselog': ev.iconTD = image('abuselog_' + ev.result) || image ('abuselog') ev.iconTD = outputIntLink('title=Special:AbuseLog&details='+ev.id, ev.iconTD) ev.result = ev.result || 'done' ev.classes += ' ' + ev.result ev.detailsTD = ev.result + ' : ' + apl.outputPage('Special:AbuseFilter/'+ev.filter_id, ev.filter) break

case 'move': ev.titleTD += ' ' + apl.output(ev.move.new_title, 'title') if( typeof ev.move.suppressedredirect == 'string' ) ev.detailsTD = ' [noredir] ' + ev.detailsTD break case 'review': ev.iconTD = outputIntLink('diff='+ev[0]+'&oldid='+ev[1], ev.iconTD) ev.detailsTD += ' ' //time between edit and review + apl.output( apl.parseTimestamp(ev.timestamp) - apl.parseTimestamp(ev[2]), 'hours' ) break

case 'block': var u = ev.title.split(':')[1] ev.titleTD = ' ' + apl.outputPage('Special:Contributions/'+u, u)       if (ev.block){ ev.detailsTD = ''+ev.block.duration + (':' + ev.block.flags.replace(/^anononly,nocreate$/,)).replace(/^:$/,) + ': ' + ev.detailsTD }       break

case 'protect': if( /\[create=/.test(ev[0]) ) ev.iconTD = image('protect_create') ev.detailsTD = ev.action + ': ' + (ev[0]||) + ' ' + (ev[1]|) + ' ' + ev.detailsTD

case 'delete': if( ev.action == 'revision' ){ var revs = ev[1].split(',') ev.detailsTD = '' for (var j=0; j<revs.length; j++) ev.detailsTD += ' ' + outputIntLink('diff='+revs[j], revs[j]) ev.detailsTD += ' ' + (ev.parsedcomment || '') + ' ['+ev[2]+':'+ev[3]+']' }      break

case 'abusefilter': ev.iconTD = apl.outputPage( 'Special:AbuseFilter/history/'        + ev[1] + '/diff/prev/' + ev[0], ev.iconTD ) ev.detailsTD = ev.action + ': ' + ev.detailsTD break }//switch

if( /deletedrevs|abuselog|review|protect|delete/.test(ev.type) ) allTitles[ ev.title ] = true //join event with auto-review if( ev.type != 'review' && findPrev('review') && /approve-i?a/.test(prev.action)){ var tip = prev.parsedcomment if( prev.title != ev.title ) tip = prev.title + ' ' + tip ev.titleTD += ' <span class=autoapprove title="' + tip.replace(/"/g,'&quot;') + '">✓ '     prev.hidden = true   }   if( ev.type != 'patrol' && findPrev('patrol')        && prev.patrol.auto == "1" && ev.title==prev.title){ //for enwiki      ev.titleTD += ' <span class=patrolled title="' + ('patrolled: ' + prev.parsedcomment).replace(/"/g,'&quot;') + '">✓ ' prev.hidden = true } }//for

//create table htm = ' ' ) ts_makeSortable( $('#apiresults')[0] )

//request pages info to mark some links as red var arr = [] for (var ttl in allTitles) arr.push(ttl) while (arr.length > 0) queryAPI({prop:'info', titles: arr.splice(0,50).join('|')}, pagesReceive)

return function addTH(tip, ico){ return '<th title="' + tip + '">' + (ico ? apl.icon(ico, 15) : '') + ' ' } function findPrev(wh){ //find previous (upper) event with specified type, on success  put it into in var 'prev' and return true var j = i, ms  while( --j >= 0 ){ prev = events[j] ms = apl.parseTimestamp(prev.timestamp) - apl.parseTimestamp(ev.timestamp) if( ms > 2000 ) break //less than 2 seconds earlier if( prev.type == wh ) return true }  return null }

}

function pagesReceive(pgs){ if( ! pgs.query ) return pgs = pgs.query.pages //get all redlinked titles var missingPage = {} for (var id in pgs) if( typeof pgs[id].missing == 'string' ) missingPage[ pgs[id].title ] = true //loop through all title cells var a, ttl $('#apiresults').find('td.title').each(function{  a = $(this).find('a')   ttl = a.attr('title') || a.text // !!! need to process quot; in tooltip   if ( missingPage[ttl] ) a.addClass('new') }) }

//AUX Functions-

function dispMsg(txt, color){ var cnt = $('#msg_content') switch(txt){ case 'clear': case null: cnt.empty; break case 'hide': cnt.hide; break case 'show': cnt.show; break default: cnt.append( '<span style="color:'+color+'">' + txt + ' ') }  }

function getCurrentTime{ d = new Date //return new Date( d.getTime + d.getTimezoneOffset * 60000 ) return d }

function outputIntLink(link, name){ return '<a href="' + wgScript + '?' + link + '">'+name+'</a>' }

function queryAPI(req, func){ dispMsg('Req: <a href="' + wgScriptPath+'/api.php?action=query&rawcontinue=&' + $.param(req) + '">'         + (req.list || req.prop || 'request') + ' '          + (req.ucuser || req.ucuserprefix || '')         + '</a>')

pendingRequests++ $.getJSON( wgScriptPath+'/api.php?action=query&rawcontinue=&format=json', req, func ) }

//IP RANGES, thx to bart: http://stackoverflow.com/questions/503052/javascript-is-ip-in-one-of-these-subnets function parseRange(s){ s = /^(\d+\.\d+\.\d+\.\d+)\/(\d\d)$/.exec(s) if (s) return { base: ipNum(s[1]), mask: -1<<(32-s[2]) }; else return null } function ipNum(ip) { ip = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(ip) if (ip) return (+ip[1]<<24) + (+ip[2]<<16) + (+ip[3]<<8) + (+ip[4]) else return null } function inRange(ip, base, mask){ return (ipNum(ip) & mask) == base }

function urlToForm(frm){ //autofill form from URL frm = $(frm) var val frm.find('input, textarea').add(frm.find('select')) .each( function(t,el){  val = mw.util.getParamValue( el.name || el.id )   if( val == null ) return   switch( el.type ){    case 'radio':      frm.find('input [type=radio][value="'+val+'"]').attr('selected', true)      break    case 'checkbox':      switch( val ){        case 'on':  case el.value:  if( !el.checked ) el.click; break        case 'off': case '':        if(  el.checked ) el.click; break        default:                    el.value = val //custom value checkbox?      }      break    default: //text, select     el.value = val.replace(/\+/g,' ')   } }) }

function formToUrl(frm){ //save form values into URL $(frm).find('input, textarea').each( function(i, el){ if( !el.name ) el.name = el.id }) //serialize needs 'em return  document.URL.replace(/\?.*/, '') + '?' + $(frm).serialize + ( mw.util.getParamValue('withjs') ? '&withjs=' + mw.util.getParamValue('withjs') : '') + ( mw.util.getParamValue('runjs') ? '&runjs='  + mw.util.getParamValue('runjs')  : '') //frm.find('input:checkbox:not(:checked)').each( function(t,el){ url += '&' + el.name + '='  }) // remember unchecked checkboxes ?? }

/* small library for MW API requests */

var wgServerTime

var apl = new function{

//initialization mw.util.addCSS('span.sort {display:none}')

//---API

var queryType = {rc:'list=recentchanges' ,uc:'list=usercontribs' ,ap:'list=allpages' ,rv:'prop=revisions' ,le:'list=logevents' }

// Usage: var myQ = new simpleQuery('rc', {limit:20}, myFunc) // then myQ.call; ... if (myQ.isMore) myQ.call(50) this.simpleQuery = function(tp, params, cbfunc){ var shortName = tp // 'rc' var longName = queryType[shortName].split('=')[1] // 'recentcontribs' var url = wgScriptPath+'/api.php?action=query&rawcontinue=&format=json&' + queryType[shortName] var request = {}, callback = cbfunc, next

for (var k in params) addParam(k, params[k]) this.isMore = false

function addParam(key, val){ if (!/titles|pageids|revids|redirects|indexpageids|requestid/.test(key)) key = shortName + key request[key] = val } this.call = function(params){ if (typeof params == 'number') request[shortName+'limit'] = params else if (typeof params == 'object') for (var k in params) addParam(k, params[k]) $.getJSON(url, $.extend({}, request, next), recv) } this.isMore = function { return next }

var recv = function(data, textStatus, jqXHR){ updateServerTime(jqXHR) //save continue values if (next = data['query-continue']) next = next[longName] if (!data.query) return dispMsg('Server returned empty data') callback(data.query[longName], data) }

}

this.updateServerTime = updateServerTime = function(xhr){ if (xhr) wgServerTime = new Date( xhr.getResponseHeader('Date') ) }

this.parseTimestamp = parseTS

this.getChild = function(obj, path){ //example: getSomeChild( data, 'query.pages..somekey' ) path = path.split('.') for (var i=0; i<path.length; i++){ var key = path[i] if (key == '') for (key in obj) break //get any child obj = obj[key] } return obj }

// HTML OUTPUT

this.toggleCSS = (function { //example: toggleCSS( 'p{display:none}', true ) var sheet = {} return function(css, isOn){   if (!sheet[css]) sheet[css] = mw.util.addCSS(css)   sheet[css].disabled = ! isOn } })

this.spinner = function (el){ if (el) $(el).append('<img class=spinner style="margin-left:1em" src="'  + stylepath +'/common/images/spinner.gif" alt="..." title="..." />') else $('img.spinner').remove }

this.checkbox = function (name, id, val, txt){ return '<input type=checkbox name='+name+' id='+id + ' value="'+val+'">' + '<label for='+id+'>' + txt + ' ' }

this.icon = function(src, size, attr){ //returns <img ...> from Commons if( size ) src = 'thumb/'+src+'/'+size+'px-'+src.split('/')[2] //+'.png' //for svg: not needed? return '<img src="http://upload.wikimedia.org/wikipedia/commons/' + src + '" ' + (attr||'')+'>' }

this.output = function (val, type){ switch ( type ){ case 'title': return this.outputPage(val, simplifyTitle(val) ) case 'user': return this.outputPage('Special:Contributions/'+val, val) case 'touched': case 'timestamp': return inHours( wgServerTime - parseTS(val) ) case 'hours': return inHours(val) //case 'size': case 'oldlen': case 'newlen': default: return val } }

this.outputPage = function (page, name){ name = name || page if (name.length > 40) name = name.substr(0, 37) + '…' var tip = '' if (page != name) tip = ' title="'+page.replace(/"/g,'&quot;') + '"' page = encodeURI(page.replace(/ /g,'_')).replace(/\?/g,'%3F') // could use encodeURIComponent(page).replace(/%2F/g,'/') ? return '<a href="' + wgArticlePath.replace('$1', '') + page + '"'  + tip + '>'+name+'</a>' }

this.outputCell = function (val, key){ var attr = , a1 = , a2 = '', clss = key if( typeof val == 'object' ){ if( key == 'title' && typeof val.redirect == 'string' ) clss += ' redirect' val = val[key] } if ( key == 'timestamp'){ //handle timestamp attr = ' title="' + val + '"' a1 = ' ' + val + ' ' a2 = ' ' } return '<td class="' + clss + '"' + attr + '>' + a1 + this.output( val, key ) + a2 }

this.makeAPITimestamp = function(d){ //date -> 2008-01-26T06:34:19Z return d.getUTCFullYear + '-' + pad0(d.getUTCMonth+1) + '-' + pad0(d.getUTCDate) + 'T' + pad0(d.getUTCHours) + ':' + pad0(d.getUTCMinutes) + ':' + pad0(d.getUTCSeconds) + 'Z' }

//--- AUX Functions

function pad0(v, len){ // 6 -> '06' len = len || 2 v = v.toString while (v.length < len) v = '0'+v return v }

function diffSize(n){ return '<span class=' + 'mw-plusminus-'+ (n>0 ? 'pos' : (n<0?'neg':'null')) + (n < - 500 ? ' style="font-weight:bold"' : '') + '>' + n.toString + ' ' }

function inHours(ms){ //milliseconds -> "2:30" or 5,06 or 21 var mm = Math.floor(ms/60000) if( !mm ) return ' '+Math.floor(ms/1000)+'s ' var hh = Math.floor(mm/60); mm = mm % 60 var dd = Math.floor(hh/24); hh = hh % 24 if (dd) return dd + (dd<10?' ,'+pad0(hh)+' ':'') else return ' '+hh + ':' + pad0(mm)+' ' }

//20081226220605 or  2008-01-26T06:34:19Z   -> date function parseTS(ts){ var m = ts.replace(/\D/g,'').match(/(\d\d\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/) return new Date ( Date.UTC(m[1], m[2]-1, m[3], m[4], m[5], m[6]) ) }

function simplifyTitle(ttl){ for (var tt in simplerTitles) if ( ttl.substring (0, tt.length) == tt ) ttl = simplerTitles[ tt ] + ttl.substring(tt.length) return ttl }

//-- Per Project

// enwiki var simplerTitles = {'Wikipedia:' :'WP:' ,'Wikipedia talk:':'WT:' }

}//library