// ==UserScript== // @name Wayfarer Exporter // @version 0.9 // @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 // @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) sentNominations = json && json.result && json.result.nominations if (!sentNominations) { logMessage('Failed to parse nominations from Wayfarer') return } analyzeCandidates() } 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 candidate in the same level 17 cell and less than 20 meters away, handle it as the nomination for this if ( candidate.cell17id === cell17id && getDistance(candidate, nomination) < 20 ) { // 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', '