2019-11-02 18:51:02 +08:00
// ==UserScript==
// @name Wayfarer Exporter
2024-05-23 14:38:11 +08:00
// @version 0.11
2019-11-02 18:51:02 +08:00
// @description Export nominations data from Wayfarer to IITC in Wayfarer Planner
2023-01-07 12:04:55 +08:00
// @namespace https://gitlab.com/NvlblNm/wayfarer/
// @downloadURL https://gitlab.com/NvlblNm/wayfarer/raw/master/wayfarer-exporter.user.js
2024-06-15 22:53:35 +08:00
// @updateURL https://gitlab.com/NvlblNm/wayfarer/raw/master/wayfarer-exporter.user.js
2023-01-07 12:04:55 +08:00
// @homepageURL https://gitlab.com/NvlblNm/wayfarer/
2019-11-02 18:51:02 +08:00
// @match https://wayfarer.nianticlabs.com/*
// ==/UserScript==
/* eslint-env es6 */
/* eslint no-var: "error" */
function init ( ) {
2023-01-25 03:57:18 +08:00
// const w = typeof unsafeWindow === 'undefined' ? window : unsafeWindow;
let tryNumber = 15
// let nominationController;
// queue of updates to send
const pendingUpdates = [ ]
// keep track of how many request are being sent at the moment
let sendingUpdates = 0
// limit to avoid errors with Google
const maxSendingUpdates = 8
// counters for the log
let totalUpdates = 0
let sentUpdates = 0
// logger containers
let updateLog
let logger
let msgLog
/ * *
* Overwrite the open method of the XMLHttpRequest . prototype to intercept the server calls
* /
; ( function ( open ) {
XMLHttpRequest . prototype . open = function ( method , url ) {
if ( url === '/api/v1/vault/manage' ) {
if ( method === 'GET' ) {
this . addEventListener ( 'load' , parseNominations , false )
}
}
open . apply ( this , arguments )
}
} ) ( XMLHttpRequest . prototype . open )
addConfigurationButton ( )
let sentNominations
function parseNominations ( e ) {
try {
const response = this . response
const json = JSON . parse ( response )
2024-05-23 14:38:11 +08:00
const nominations = json && json . result && json . result . submissions
2024-04-30 02:33:23 +08:00
if ( ! nominations ) {
2023-01-25 03:57:18 +08:00
logMessage ( 'Failed to parse nominations from Wayfarer' )
return
}
2024-04-30 02:33:23 +08:00
sentNominations = nominations . filter (
( nomination ) => nomination . type === 'NOMINATION'
)
analyzeCandidates ( sentNominations )
2023-01-25 03:57:18 +08:00
} catch ( e ) {
console . log ( e ) // eslint-disable-line no-console
}
}
let currentCandidates
function analyzeCandidates ( result ) {
if ( ! sentNominations ) {
setTimeout ( analyzeCandidates , 200 )
return
}
getAllCandidates ( ) . then ( function ( candidates ) {
if ( ! candidates ) {
return
}
currentCandidates = candidates
logMessage ( ` Analyzing ${ sentNominations . length } nominations. ` )
let modifiedCandidates = false
sentNominations . forEach ( ( nomination ) => {
if ( checkNomination ( nomination ) ) {
modifiedCandidates = true
}
} )
if ( modifiedCandidates ) {
localStorage [ 'wayfarerexporter-candidates' ] =
JSON . stringify ( currentCandidates )
} else {
logMessage ( 'No modifications detected on the nominations.' )
logMessage ( 'Closing in 5 secs.' )
setTimeout ( removeLogger , 5 * 1000 )
}
} )
}
/ *
returns true if it has modified the currentCandidates object and we must save it to localStorage after the loop ends
* /
function checkNomination ( nomination ) {
// console.log(nomination);
const id = nomination . id
// if we're already tracking it...
const existingCandidate = currentCandidates [ id ]
if ( existingCandidate ) {
if ( nomination . status === 'ACCEPTED' ) {
// Ok, we don't have to track it any longer.
logMessage ( ` Approved candidate ${ nomination . title } ` )
deleteCandidate ( nomination )
delete currentCandidates [ id ]
return true
}
if ( nomination . status === 'REJECTED' ) {
rejectCandidate ( nomination , existingCandidate )
// can be appealed, so keeping
updateLocalCandidate ( id , nomination )
return true
}
if ( nomination . status === 'DUPLICATE' ) {
rejectCandidate ( nomination , existingCandidate )
delete currentCandidates [ id ]
return true
}
if ( nomination . status === 'WITHDRAWN' ) {
rejectCandidate ( nomination , existingCandidate )
delete currentCandidates [ id ]
return true
}
if ( nomination . status === 'APPEALED' ) {
updateLocalCandidate ( id , nomination )
appealCandidate ( nomination , existingCandidate )
return true
}
// catches following changes: held -> nominated, nominated -> held, held -> nominated -> voting
if (
statusConvertor ( nomination . status ) !== existingCandidate . status
) {
updateLocalCandidate ( id , nomination )
updateCandidate ( nomination , 'status' )
return true
}
// check for title and description updates only
if (
nomination . title !== existingCandidate . title ||
nomination . description !== existingCandidate . description
) {
currentCandidates [ id ] . title = nomination . title
currentCandidates [ id ] . description = nomination . description
updateCandidate ( nomination , 'title or description' )
return true
}
return false
}
if (
nomination . status === 'NOMINATED' ||
nomination . status === 'VOTING' ||
nomination . status === 'HELD' ||
nomination . status === 'APPEALED' ||
nomination . status === 'NIANTIC_REVIEW'
) {
/ *
Try to find nominations added manually in IITC :
same name in the same level 17 cell
* /
const cell17 = S2 . S2Cell . FromLatLng ( nomination , 17 )
const cell17id = cell17 . toString ( )
Object . keys ( currentCandidates ) . forEach ( ( idx ) => {
const candidate = currentCandidates [ idx ]
2024-04-30 02:33:23 +08:00
// if it finds a potential candidate in the same level 17 cell and less than 3 meters away, handle it as the nomination for this
// Relax the distance requirement a bit if the title of the potential candidate matches the nomination title exactly
2023-01-25 03:57:18 +08:00
if (
2024-04-30 02:33:23 +08:00
candidate . status === 'potential' &&
2023-01-25 03:57:18 +08:00
candidate . cell17id === cell17id &&
2024-04-30 02:33:23 +08:00
( ( candidate . title === nomination . title &&
getDistance ( candidate , nomination ) < 10 ) ||
getDistance ( candidate , nomination ) < 3 )
2023-01-25 03:57:18 +08:00
) {
// if we find such candidate, remove it because we're gonna add now the new one with a new id
logMessage ( ` Found manual candidate for ${ candidate . title } ` )
deleteCandidate ( { id : idx } )
}
} )
addCandidate ( nomination )
currentCandidates [ nomination . id ] = {
cell17id : S2 . S2Cell . FromLatLng ( nomination , 17 ) . toString ( ) ,
title : nomination . title ,
description : nomination . description ,
lat : nomination . lat ,
lng : nomination . lng ,
status : statusConvertor ( nomination . status )
}
return true
}
return false
}
// https://stackoverflow.com/a/1502821/250294
function getDistance ( p1 , p2 ) {
const rad = function ( x ) {
return ( x * Math . PI ) / 180
}
const R = 6378137 // Earth’ s mean radius in meter
const dLat = rad ( p2 . lat - p1 . lat )
const dLong = rad ( p2 . lng - p1 . lng )
const a =
Math . sin ( dLat / 2 ) * Math . sin ( dLat / 2 ) +
Math . cos ( rad ( p1 . lat ) ) *
Math . cos ( rad ( p2 . lat ) ) *
Math . sin ( dLong / 2 ) *
Math . sin ( dLong / 2 )
const c = 2 * Math . atan2 ( Math . sqrt ( a ) , Math . sqrt ( 1 - a ) )
return R * c // returns the distance in meter
}
function statusConvertor ( status ) {
if ( status === 'HELD' ) {
return 'held'
}
if ( status === 'NOMINATED' ) {
return 'submitted'
}
if ( status === 'VOTING' ) {
return 'voting'
}
if (
status === 'REJECTED' ||
status === 'DUPLICATE' ||
status === 'WITHDRAWN'
) {
return 'rejected'
}
if ( status === 'APPEALED' ) {
return 'appealed'
}
return status
}
function updateLocalCandidate ( id , nomination ) {
currentCandidates [ id ] . status = statusConvertor ( nomination . status )
currentCandidates [ id ] . title = nomination . title
currentCandidates [ id ] . description = nomination . description
}
function addCandidate ( nomination ) {
logMessage ( ` New candidate ${ nomination . title } ` )
console . log ( 'Tracking new nomination' , nomination )
updateStatus ( nomination , statusConvertor ( nomination . status ) )
}
function updateCandidate ( nomination , change ) {
logMessage ( ` Updated candidate ${ nomination . title } - changed ${ change } ` )
console . log ( 'Updated existing nomination' , nomination )
updateStatus ( nomination , statusConvertor ( nomination . status ) )
}
function deleteCandidate ( nomination ) {
console . log ( 'Deleting nomination' , nomination )
updateStatus ( nomination , 'delete' )
}
function rejectCandidate ( nomination , existingCandidate ) {
if ( existingCandidate . status === 'rejected' ) {
return
}
logMessage ( ` Rejected nomination ${ nomination . title } ` )
console . log ( 'Rejected nomination' , nomination )
updateStatus ( nomination , 'rejected' )
}
function appealCandidate ( nomination , existingCandidate ) {
if ( existingCandidate . status === 'appealed' ) {
return
}
logMessage ( ` Appealed nomination ${ nomination . title } ` )
console . log ( 'Appealed nomination' , nomination )
updateStatus ( nomination , statusConvertor ( nomination . status ) )
}
function updateStatus ( nomination , newStatus ) {
const formData = new FormData ( )
// if there's an error, let's retry 3 times. This is a custom property for us.
formData . retries = 3
formData . append ( 'status' , newStatus )
formData . append ( 'id' , nomination . id )
formData . append ( 'lat' , nomination . lat )
formData . append ( 'lng' , nomination . lng )
formData . append ( 'title' , nomination . title )
formData . append ( 'description' , nomination . description )
formData . append ( 'submitteddate' , nomination . day )
formData . append ( 'candidateimageurl' , nomination . imageUrl )
getName ( )
. then ( ( name ) => {
formData . append ( 'nickname' , name )
} )
. catch ( ( error ) => {
console . log ( 'Catched load name error' , error )
formData . append ( 'nickname' , 'wayfarer' )
} )
. finally ( ( ) => {
pendingUpdates . push ( formData )
totalUpdates ++
sendUpdate ( )
} )
}
let name
let nameLoadingTriggered = false
function getName ( ) {
return new Promise ( function ( resolve , reject ) {
if ( ! nameLoadingTriggered ) {
nameLoadingTriggered = true
const url =
'https://wayfarer.nianticlabs.com/api/v1/vault/properties'
fetch ( url )
. then ( ( response ) => {
response . json ( ) . then ( ( json ) => {
name = json . result . socialProfile . name
logMessage ( ` Loaded name ${ name } ` )
resolve ( name )
} )
} )
. catch ( ( error ) => {
console . log ( 'Catched fetch error' , error )
logMessage ( 'Loading name failed. Using wayfarer' )
name = 'wayfarer'
resolve ( name )
} )
} else {
const loop = ( ) =>
name !== undefined ? resolve ( name ) : setTimeout ( loop , 2000 )
loop ( )
}
} )
}
// Send updates one by one to avoid errors from Google
function sendUpdate ( ) {
updateProgressLog ( )
if ( sendingUpdates >= maxSendingUpdates ) {
return
}
if ( pendingUpdates . length === 0 ) {
return
}
sentUpdates ++
sendingUpdates ++
updateProgressLog ( )
const formData = pendingUpdates . shift ( )
const options = {
method : 'POST' ,
body : formData
}
fetch ( getUrl ( ) , options )
. then ( ( data ) => { } )
. catch ( ( error ) => {
console . log ( 'Catched fetch error' , error ) // eslint-disable-line no-console
logMessage ( error )
// one retry less
formData . retries --
if ( formData . retries > 0 ) {
// if we should still retry, put it at the end of the queue
pendingUpdates . push ( formData )
}
} )
. finally ( ( ) => {
sendingUpdates --
sendUpdate ( )
} )
}
function updateProgressLog ( ) {
const count = pendingUpdates . length
if ( count === 0 ) {
updateLog . textContent = 'All updates sent.'
} else {
updateLog . textContent = ` Sending ${ sentUpdates } / ${ totalUpdates } updates to the spreadsheet. `
}
}
function getUrl ( ) {
return localStorage [ 'wayfarerexporter-url' ]
}
function addConfigurationButton ( ) {
const ref = document . querySelector ( '.sidebar-link[href$="nominations"]' )
if ( ! ref ) {
if ( tryNumber === 0 ) {
document
. querySelector ( 'body' )
. insertAdjacentHTML (
'afterBegin' ,
'<div class="alert alert-danger"><strong><span class="glyphicon glyphicon-remove"></span> Wayfarer Exporter initialization failed, refresh page</strong></div>'
)
return
}
setTimeout ( addConfigurationButton , 1000 )
tryNumber --
return
}
addCss ( )
const link = document . createElement ( 'a' )
link . className =
'mat-tooltip-trigger sidebar-link sidebar-wayfarerexporter'
link . title = 'Configure Exporter'
link . innerHTML =
'<svg viewBox="0 0 24 24" class="sidebar-link__icon"><path d="M12,1L8,5H11V14H13V5H16M18,23H6C4.89,23 4,22.1 4,21V9A2,2 0 0,1 6,7H9V9H6V21H18V9H15V7H18A2,2 0 0,1 20,9V21A2,2 0 0,1 18,23Z" /></svg><span> Exporter</span>'
// const ref = document.querySelector('.sidebar__item--nominations');
ref . parentNode . insertBefore ( link , ref . nextSibling )
link . addEventListener ( 'click' , function ( e ) {
e . preventDefault ( )
const currentUrl = getUrl ( )
const url = window . prompt (
'Script Url for Wayfarer Planner' ,
currentUrl
)
if ( ! url ) {
return
}
loadPlannerData ( url ) . then ( analyzeCandidates )
} )
}
function addCss ( ) {
const css = `
. sidebar - wayfarerexporter svg {
width : 24 px ;
height : 24 px ;
filter : none ;
fill : currentColor ;
}
. wayfarer - exporter _log {
background : # fff ;
box - shadow : 0 2 px 5 px 0 rgba ( 0 , 0 , 0 , . 16 ) , 0 2 px 10 px 0 rgba ( 0 , 0 , 0 , . 12 ) ;
display : flex ;
flex - direction : column ;
max - height : 100 % ;
padding : 5 px ;
position : absolute ;
top : 0 ;
z - index : 2 ;
}
. wayfarer - exporter _log h3 {
margin - right : 1 em ;
margin - top : 0 ;
}
. wayfarer - exporter _closelog {
cursor : pointer ;
position : absolute ;
right : 0 ;
}
. wayfarer - exporter _log - wrapper {
overflow : auto ;
}
`
const style = document . createElement ( 'style' )
style . type = 'text/css'
style . innerHTML = css
document . querySelector ( 'head' ) . appendChild ( style )
}
function getAllCandidates ( ) {
const promesa = new Promise ( function ( resolve , reject ) {
const storedData = localStorage [ 'wayfarerexporter-candidates' ]
const lastUpdate = localStorage [ 'wayfarerexporter-lastupdate' ] || 0
const now = new Date ( ) . getTime ( )
// cache it for 12 hours
if ( ! storedData || now - lastUpdate > 12 * 60 * 60 * 1000 ) {
resolve ( loadPlannerData ( ) )
return
}
resolve ( JSON . parse ( storedData ) )
} )
return promesa
}
function loadPlannerData ( newUrl ) {
let url = newUrl || getUrl ( )
if ( ! url ) {
url = window . prompt ( 'Script Url for Wayfarer Planner' )
if ( ! url ) {
return null
}
}
if ( ! url . startsWith ( 'https://script.google.com/macros/' ) ) {
alert (
'The url of the script seems to be wrong, please paste the URL provided after "creating the webapp"'
)
return null
}
if ( url . includes ( 'echo' ) || ! url . endsWith ( 'exec' ) ) {
alert (
'You must use the short URL provided by "creating the webapp", not the long one after executing the script.'
)
return null
}
if ( url . includes ( ' ' ) ) {
alert (
"Warning, the URL contains at least one space. Check that you've copied it properly."
)
return null
}
const fetchOptions = {
method : 'GET'
}
return fetch ( url , fetchOptions )
. then ( function ( response ) {
return response . text ( )
} )
. then ( function ( data ) {
return JSON . parse ( data )
} )
. then ( function ( allData ) {
const submitted = allData . filter (
( c ) =>
c . status === 'submitted' ||
2023-01-30 13:47:46 +08:00
c . status === 'voting' ||
c . status === 'NIANTIC_REVIEW' ||
2023-01-25 03:57:18 +08:00
c . status === 'potential' ||
c . status === 'held' ||
c . status === 'rejected' ||
c . status === 'appealed'
)
const candidates = { }
submitted . forEach ( ( c ) => {
candidates [ c . id ] = {
cell17id : S2 . S2Cell . FromLatLng ( c , 17 ) . toString ( ) ,
title : c . title ,
description : c . description ,
lat : c . lat ,
lng : c . lng ,
status : c . status
}
} )
localStorage [ 'wayfarerexporter-url' ] = url
localStorage [ 'wayfarerexporter-lastupdate' ] =
new Date ( ) . getTime ( )
localStorage [ 'wayfarerexporter-candidates' ] =
JSON . stringify ( candidates )
const tracked = Object . keys ( candidates ) . length
logMessage (
` Loaded a total of ${ allData . length } candidates from the spreadsheet. `
)
logMessage ( ` Currently tracking: ${ tracked } . ` )
return candidates
} )
. catch ( function ( e ) {
console . log ( e ) // eslint-disable-line no-console
alert (
"Wayfarer Planner. Failed to retrieve data from the scriptURL.\r\nVerify that you're using the right URL and that you don't use any extension that blocks access to google."
)
return null
} )
}
function removeLogger ( ) {
logger . parentNode . removeChild ( logger )
logger = null
}
function logMessage ( txt ) {
if ( ! logger ) {
logger = document . createElement ( 'div' )
logger . className = 'wayfarer-exporter_log'
document . body . appendChild ( logger )
const img = document . createElement ( 'img' )
img . src = '/img/sidebar/clear-24px.svg'
img . className = 'wayfarer-exporter_closelog'
img . height = 24
img . width = 24
img . addEventListener ( 'click' , removeLogger )
logger . appendChild ( img )
const title = document . createElement ( 'h3' )
title . textContent = 'Wayfarer exporter'
logger . appendChild ( title )
updateLog = document . createElement ( 'div' )
updateLog . className = 'wayfarer-exporter_log-counter'
logger . appendChild ( updateLog )
msgLog = document . createElement ( 'div' )
msgLog . className = 'wayfarer-exporter_log-wrapper'
logger . appendChild ( msgLog )
}
const div = document . createElement ( 'div' )
div . textContent = txt
msgLog . appendChild ( div )
}
/ * *
S2 extracted from Regions Plugin
https : static . iitc . me / build / release / plugins / regions . user . js
* /
const S2 = { }
const d2r = Math . PI / 180.0
function LatLngToXYZ ( latLng ) {
const phi = latLng . lat * d2r
const theta = latLng . lng * d2r
const cosphi = Math . cos ( phi )
return [
Math . cos ( theta ) * cosphi ,
Math . sin ( theta ) * cosphi ,
Math . sin ( phi )
]
}
function largestAbsComponent ( xyz ) {
const temp = [ Math . abs ( xyz [ 0 ] ) , Math . abs ( xyz [ 1 ] ) , Math . abs ( xyz [ 2 ] ) ]
if ( temp [ 0 ] > temp [ 1 ] ) {
if ( temp [ 0 ] > temp [ 2 ] ) {
return 0
}
return 2
}
if ( temp [ 1 ] > temp [ 2 ] ) {
return 1
}
return 2
}
function faceXYZToUV ( face , xyz ) {
let u , v
switch ( face ) {
case 0 :
u = xyz [ 1 ] / xyz [ 0 ]
v = xyz [ 2 ] / xyz [ 0 ]
break
case 1 :
u = - xyz [ 0 ] / xyz [ 1 ]
v = xyz [ 2 ] / xyz [ 1 ]
break
case 2 :
u = - xyz [ 0 ] / xyz [ 2 ]
v = - xyz [ 1 ] / xyz [ 2 ]
break
case 3 :
u = xyz [ 2 ] / xyz [ 0 ]
v = xyz [ 1 ] / xyz [ 0 ]
break
case 4 :
u = xyz [ 2 ] / xyz [ 1 ]
v = - xyz [ 0 ] / xyz [ 1 ]
break
case 5 :
u = - xyz [ 1 ] / xyz [ 2 ]
v = - xyz [ 0 ] / xyz [ 2 ]
break
default :
throw { error : 'Invalid face' }
}
return [ u , v ]
}
function XYZToFaceUV ( xyz ) {
let face = largestAbsComponent ( xyz )
if ( xyz [ face ] < 0 ) {
face += 3
}
const uv = faceXYZToUV ( face , xyz )
return [ face , uv ]
}
function UVToST ( uv ) {
const singleUVtoST = function ( uv ) {
if ( uv >= 0 ) {
return 0.5 * Math . sqrt ( 1 + 3 * uv )
}
return 1 - 0.5 * Math . sqrt ( 1 - 3 * uv )
}
return [ singleUVtoST ( uv [ 0 ] ) , singleUVtoST ( uv [ 1 ] ) ]
}
function STToIJ ( st , order ) {
const maxSize = 1 << order
const singleSTtoIJ = function ( st ) {
const ij = Math . floor ( st * maxSize )
return Math . max ( 0 , Math . min ( maxSize - 1 , ij ) )
}
return [ singleSTtoIJ ( st [ 0 ] ) , singleSTtoIJ ( st [ 1 ] ) ]
}
// S2Cell class
S2 . S2Cell = function ( ) { }
// static method to construct
S2 . S2Cell . FromLatLng = function ( latLng , level ) {
const xyz = LatLngToXYZ ( latLng )
const faceuv = XYZToFaceUV ( xyz )
const st = UVToST ( faceuv [ 1 ] )
const ij = STToIJ ( st , level )
return S2 . S2Cell . FromFaceIJ ( faceuv [ 0 ] , ij , level )
}
S2 . S2Cell . FromFaceIJ = function ( face , ij , level ) {
const cell = new S2 . S2Cell ( )
cell . face = face
cell . ij = ij
cell . level = level
return cell
}
S2 . S2Cell . prototype . toString = function ( ) {
return (
'F' +
this . face +
'ij[' +
this . ij [ 0 ] +
',' +
this . ij [ 1 ] +
']@' +
this . level
)
}
2019-11-02 18:51:02 +08:00
}
2023-01-25 03:57:18 +08:00
init ( )