wayfarer/wayfarer-exporter.user.js

762 lines
25 KiB
JavaScript
Raw Permalink Normal View History

2019-11-02 18:51:02 +08:00
// ==UserScript==
// @name Wayfarer Exporter
// @version 0.11
2019-11-02 18:51:02 +08:00
// @description Export nominations data from Wayfarer to IITC in Wayfarer Planner
// @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
// @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() {
// 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)
const nominations = json && json.result && json.result.submissions
if (!nominations) {
logMessage('Failed to parse nominations from Wayfarer')
return
}
sentNominations = nominations.filter(
(nomination) => nomination.type === 'NOMINATION'
)
analyzeCandidates(sentNominations)
} 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]
// 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
if (
candidate.status === 'potential' &&
candidate.cell17id === cell17id &&
((candidate.title === nomination.title &&
getDistance(candidate, nomination) < 10) ||
getDistance(candidate, nomination) < 3)
) {
// 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 // Earths 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: 24px;
height: 24px;
filter: none;
fill: currentColor;
}
.wayfarer-exporter_log {
background: #fff;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .16), 0 2px 10px 0 rgba(0, 0, 0, .12);
display: flex;
flex-direction: column;
max-height: 100%;
padding: 5px;
position: absolute;
top: 0;
z-index: 2;
}
.wayfarer-exporter_log h3 {
margin-right: 1em;
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' ||
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
}
init()