wayfarer/wayfarer-exporter.user.js
2024-06-15 14:53:35 +00:00

762 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name Wayfarer Exporter
// @version 0.11
// @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
// @updateURL https://gitlab.com/NvlblNm/wayfarer/raw/master/wayfarer-exporter.user.js
// @homepageURL https://gitlab.com/NvlblNm/wayfarer/
// @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' ||
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
)
}
}
init()