One-click JSON exporter for GeoGuessr game results
// ==UserScript==
// @name GeoGuessr JSON Export
// @version 1.2
// @namespace https://github.com/asmodeo
// @icon https://parmageo.vercel.app/gg.ico
// @description One-click JSON exporter for GeoGuessr game results
// @author Parma
// @match *://*.geoguessr.com/*
// @license MIT
// @connect nominatim.openstreetmap.org
// @connect flagcdn.com
// ==/UserScript==
(function () {
'use strict';
// ──────────────────────────────────────────────────────────────────────
// PERSISTENT SETTINGS
// ──────────────────────────────────────────────────────────────────────
let tagSettings = {
includeCountryCode: true,
includeMapName: true
};
let roundsExpandedState = true;
function loadSettings() {
try {
const tags = JSON.parse(localStorage.getItem('ggjsonexport_tag_settings'));
if (tags && typeof tags === 'object') {
tagSettings.includeCountryCode = !!tags.includeCountryCode;
tagSettings.includeMapName = !!tags.includeMapName;
}
} catch (e) { /* ignore */ }
try {
const saved = localStorage.getItem('ggjsonexport_rounds_expanded');
if (saved !== null) {
roundsExpandedState = (saved === 'true');
}
} catch (e) { /* ignore */ }
}
function saveTagSettings() {
try {
localStorage.setItem('ggjsonexport_tag_settings', JSON.stringify(tagSettings));
} catch (e) { /* ignore */ }
}
function saveRoundsExpanded(value) {
try {
localStorage.setItem('ggjsonexport_rounds_expanded', String(value));
} catch (e) { /* ignore */ }
}
loadSettings();
// ──────────────────────────────────────────────────────────────────────
// COUNTRY RESOLUTION CACHE
// ──────────────────────────────────────────────────────────────────────
const countryCache = new Map();
let saveCacheTimeout = null;
function loadCountryCache() {
try {
const cached = localStorage.getItem('ggjsonexport_country_cache');
if (cached) {
const parsed = JSON.parse(cached);
for (const [key, value] of Object.entries(parsed)) {
// Value may be string or null
countryCache.set(key, value === null ? null : value);
}
}
} catch (e) {
console.warn('Failed to load country cache:', e);
}
}
function scheduleSaveCountryCache() {
clearTimeout(saveCacheTimeout);
saveCacheTimeout = setTimeout(() => {
try {
const cacheObj = {};
for (const [key, value] of countryCache.entries()) {
cacheObj[key] = value;
}
localStorage.setItem('ggjsonexport_country_cache', JSON.stringify(cacheObj));
} catch (e) { /* ignore */ }
}, 1000);
}
/**
* Fetches the ISO country code for given coordinates using Nominatim.
* Uses persistent cache via localStorage.
*/
async function getCountryCode(lat, lng) {
const key = `${lat},${lng}`;
if (countryCache.has(key)) {
return countryCache.get(key);
}
try {
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=en-US`;
const res = await fetch(url, {
headers: {
'User-Agent': 'GeoGuessrJSONExport/1.0 (Userscript; https://github.com/asmodeo)'
}
});
if (!res.ok) {
console.warn('Nominatim request failed:', res.status, res.statusText);
countryCache.set(key, null);
scheduleSaveCountryCache();
return null;
}
const data = await res.json();
const code = data?.address?.country_code || null;
countryCache.set(key, code);
scheduleSaveCountryCache();
return code;
} catch (e) {
console.warn('Error resolving country from Nominatim:', e);
countryCache.set(key, null);
scheduleSaveCountryCache();
return null;
}
}
// Load cache on init
loadCountryCache();
// ──────────────────────────────────────────────────────────────────────
// STATE VARIABLES
// ──────────────────────────────────────────────────────────────────────
let widget = null;
let observer = null;
let isActive = false;
let currentGameData = null;
let currentGameId = null;
let roundCheckboxes = [];
// ──────────────────────────────────────────────────────────────────────
// UTILS
// ──────────────────────────────────────────────────────────────────────
const US_STATE_NAMES = {
al:'Alabama', ak:'Alaska', az:'Arizona', ar:'Arkansas', ca:'California', co:'Colorado', ct:'Connecticut', de:'Delaware', fl:'Florida', ga:'Georgia',
hi:'Hawaii', id:'Idaho', il:'Illinois', in:'Indiana', ia:'Iowa', ks:'Kansas', ky:'Kentucky', la:'Louisiana', me:'Maine', md:'Maryland',
ma:'Massachusetts', mi:'Michigan', mn:'Minnesota', ms:'Mississippi', mo:'Missouri', mt:'Montana', ne:'Nebraska', nv:'Nevada', nh:'New Hampshire', nj:'New Jersey',
nm:'New Mexico', ny:'New York', nc:'North Carolina', nd:'North Dakota', oh:'Ohio', ok:'Oklahoma', or:'Oregon', pa:'Pennsylvania', ri:'Rhode Island', sc:'South Carolina',
sd:'South Dakota', tn:'Tennessee', tx:'Texas', ut:'Utah', vt:'Vermont', va:'Virginia', wa:'Washington', wv:'West Virginia', wi:'Wisconsin', wy:'Wyoming',
dc:'District of Columbia'
};
const USDC_FLAG = 'https://upload.wikimedia.org/wikipedia/commons/0/03/Flag_of_Washington%2C_D.C.svg';
function getCountryName(code) {
if (!code || code.length !== 2) return null;
try {
return new Intl.DisplayNames(['en'], { type: 'region' }).of(code.toUpperCase());
} catch (e) {
return code.toUpperCase();
}
}
function applySpinStyles() {
if (document.getElementById('ggjsonexport-spin-styles')) return;
const style = document.createElement('style');
style.id = 'ggjsonexport-spin-styles';
style.textContent = `
@keyframes ggjsonexport-rotate {
to { transform: rotate(360deg); }
}
.ggjsonexport-loading-svg {
animation: ggjsonexport-rotate 1.2s linear infinite;
}
`;
document.head.appendChild(style);
}
function createFlagElement(flagId, isResolving = false, isCompound = false) {
const flag = document.createElement('span');
flag.style.cssText = `
display: inline-block;
width: 24px;
height: 16px;
margin-left: 4px;
cursor: pointer;
vertical-align: middle;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
`;
if (flagId) {
const img = document.createElement('img');
let src = '';
if (isCompound && flagId.toLowerCase() === 'us-dc') {
src = USDC_FLAG;
} else {
src = `https://flagcdn.com/${flagId.toLowerCase()}.svg`;
}
img.src = src;
img.alt = flagId;
img.style.cssText = `
width: 100%;
height: 100%;
display: block;
object-fit: contain;
object-position: center;
image-rendering: -webkit-optimize-contrast;
`;
img.loading = 'lazy';
flag.appendChild(img);
let tooltipText;
// Compound IDs for US States
if (isCompound) {
const code = flagId.slice(3);
tooltipText = US_STATE_NAMES[code] || flagId.toUpperCase();
} else {
tooltipText = getCountryName(flagId) || flagId.toUpperCase();
}
flag.title = tooltipText;
return flag;
}
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '16');
svg.setAttribute('height', '16');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', '#aaa');
svg.setAttribute('stroke-width', '1');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
if (isResolving) {
applySpinStyles();
svg.classList.add('ggjsonexport-loading-svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z');
svg.appendChild(path);
flag.title = 'Resolving country...';
} else {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z');
svg.appendChild(path);
flag.title = 'Country unknown';
}
flag.appendChild(svg);
return flag;
}
// ──────────────────────────────────────────────────────────────────────
// UTILITY FUNCTIONS
// ──────────────────────────────────────────────────────────────────────
function hex2a(hex) {
if (typeof hex !== 'string' || hex.length % 2 !== 0) return null;
return hex.match(/.{2}/g)
.map(byte => String.fromCharCode(parseInt(byte, 16)))
.join('');
}
function googleMapsLink(pano) {
if (!pano || typeof pano.lat !== 'number' || typeof pano.lng !== 'number') return null;
const fov = 180 / Math.pow(2, pano.zoom ?? 0);
let url = `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${pano.lat},${pano.lng}&heading=${pano.heading ?? 0}&pitch=${pano.pitch ?? 0}&fov=${fov}`;
if (pano.panoId) {
const decoded = hex2a(pano.panoId);
if (decoded) url += `&pano=${decoded}`;
}
return url;
}
// ──────────────────────────────────────────────────────────────────────
// DATA PARSING
// ──────────────────────────────────────────────────────────────────────
function parseRoundData(round, index = 0, isUsStateStreak = false) {
if (!round) return null;
const pano = (round.panorama?.lat != null && round.panorama?.lng != null)
? round.panorama
: round;
if (typeof pano.lat !== 'number' || typeof pano.lng !== 'number') {
return null;
}
let countryCode = null;
let stateCode = null;
if (isUsStateStreak) {
countryCode = 'US';
stateCode = round.streakLocationCode || null;
} else {
countryCode =
round.panorama?.countryCode ||
round.answer?.countryCode ||
round.streakLocationCode ||
null;
}
const roundNum = round.roundNumber ?? (index + 1);
return { pano, roundNum, countryCode, stateCode };
}
// ──────────────────────────────────────────────────────────────────────
// JSON EXPORT
// ──────────────────────────────────────────────────────────────────────
function buildJsonForExport(selectedRounds, mapName, gameType, gameId) {
const name = `${gameType}_${gameId}`;
const coords = selectedRounds.map(r => {
const p = r.pano;
const tags = [];
if (tagSettings.includeCountryCode && r.countryCode) {
tags.push(r.countryCode.toUpperCase());
}
if (tagSettings.includeMapName && mapName) {
tags.push(`map: ${mapName}`);
}
const location = {
lat: p.lat,
lng: p.lng,
heading: p.heading ?? 0,
pitch: p.pitch ?? 0,
zoom: p.zoom ?? 0,
panoId: p.panoId ? hex2a(p.panoId) : null,
};
if (tags.length > 0) {
location.extra = { tags };
}
return location;
});
return JSON.stringify({ name, customCoordinates: coords }, null, 2);
}
// ──────────────────────────────────────────────────────────────────────
// UI COMPONENTS
// ──────────────────────────────────────────────────────────────────────
function createWidget() {
if (widget) return widget;
widget = document.createElement('div');
widget.id = 'ggjsonexport-export-widget';
widget.style.cssText = `
position: fixed; bottom: 20px; right: 80px; width: 240px;
background: #252525;
color: #e6e6e6;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 1;
font-family: var(--default-font);
overflow: hidden;
`;
const header = document.createElement('div');
header.style.cssText = `
display: flex; align-items: center; padding: 10px 12px;
background: #2d2d2d;
border-bottom: 1px solid #444;
gap: 8px;
`;
const jsonCheckbox = document.createElement('input');
jsonCheckbox.type = 'checkbox';
jsonCheckbox.id = 'ggjsonexport-select-all';
jsonCheckbox.title = 'Select/deselect all played rounds';
jsonCheckbox.checked = true;
jsonCheckbox.style.marginTop = '2px';
jsonCheckbox.addEventListener('change', toggleSelectAll);
const copyBtn = createIconButton('M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z', 'Copy JSON', (e) => copyJsonHandler(e));
const downloadBtn = createIconButton('M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z', 'Download JSON', (e) => downloadJsonHandler(e));
const tagsBtn = createIconButton(
'M17.63,5.84C17.27,5.33 16.67,5 16,5L6,5.01C4.9,5.01 4,5.9 4,7v10c0,1.1 0.9,1.99 2,1.99L16,19c0.67,0 1.27,-0.33 1.63,-0.84L22,12l-4.37,-6.16z',
'Tag settings',
toggleTagsDropdown
);
tagsBtn.id = 'ggjsonexport-tags-btn';
const jsonLabel = document.createElement('span');
jsonLabel.textContent = 'JSON';
jsonLabel.style.cssText = 'font-size: 13px; font-weight: 600; flex: 1;';
const toggleBtn = createIconButton('M7 10l5 5 5-5z', 'Toggle rounds', toggleContent);
toggleBtn.id = 'ggjsonexport-toggle-btn';
header.append(jsonCheckbox, copyBtn, downloadBtn, tagsBtn, jsonLabel, toggleBtn);
widget.appendChild(header);
// Tags dropdown
const tagsDropdown = document.createElement('div');
tagsDropdown.id = 'ggjsonexport-tags-dropdown';
tagsDropdown.style.cssText = `
display: none;
padding: 8px;
background: #1f1f1f;
border-top: 1px solid #444;
font-size: 12px;
gap: 6px;
flex-direction: column;
`;
const tagsTitle = document.createElement('span');
tagsTitle.textContent = 'Tags to include:';
tagsTitle.style.cssText = 'font-weight: bold; margin-bottom: 6px;';
tagsDropdown.appendChild(tagsTitle);
const countryCheckContainer = document.createElement('label');
countryCheckContainer.style.cssText = 'display: flex; align-items: center; gap: 6px; cursor: pointer;';
const countryCheck = document.createElement('input');
countryCheck.type = 'checkbox';
countryCheck.checked = tagSettings.includeCountryCode;
countryCheck.addEventListener('change', (e) => {
tagSettings.includeCountryCode = e.target.checked;
saveTagSettings();
});
countryCheckContainer.append(countryCheck, document.createTextNode('Country Code'));
const mapCheckContainer = document.createElement('label');
mapCheckContainer.style.cssText = 'display: flex; align-items: center; gap: 6px; cursor: pointer;';
const mapCheck = document.createElement('input');
mapCheck.type = 'checkbox';
mapCheck.checked = tagSettings.includeMapName;
mapCheck.addEventListener('change', (e) => {
tagSettings.includeMapName = e.target.checked;
saveTagSettings();
});
mapCheckContainer.append(mapCheck, document.createTextNode('Map Name'));
tagsDropdown.append(countryCheckContainer, mapCheckContainer);
widget.appendChild(tagsDropdown);
const content = document.createElement('div');
content.id = 'ggjsonexport-rounds-content';
content.style.cssText = `
max-height: 190px; overflow-y: auto; padding: 6px;
background: #1f1f1f; display: ${roundsExpandedState ? 'block' : 'none'};
`;
widget.appendChild(content);
// Set toggle button icon
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '16');
svg.setAttribute('height', '16');
svg.setAttribute('fill', 'currentColor');
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('d', roundsExpandedState ? 'M7 10l5 5 5-5z' : 'M7 15l5-5 5 5z');
svg.appendChild(pathEl);
toggleBtn.innerHTML = '';
toggleBtn.appendChild(svg);
document.body.appendChild(widget);
return widget;
}
function toggleTagsDropdown(e) {
e.stopPropagation();
const dropdown = document.getElementById('ggjsonexport-tags-dropdown');
if (dropdown.style.display === 'block') {
dropdown.style.display = 'none';
} else {
dropdown.style.display = 'block';
}
}
async function createRoundItem(round, index) {
const item = document.createElement('div');
item.style.cssText = `
display: flex; align-items: center; padding: 5px 8px; gap: 6px;
border-radius: 4px; font-size: 12px;
background: #1f1f1f;
`;
item.addEventListener('mouseenter', () => item.style.backgroundColor = '#333333');
item.addEventListener('mouseleave', () => item.style.backgroundColor = '#1f1f1f');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
checkbox.style.marginTop = '2px';
checkbox.addEventListener('change', updateSelectAllState);
const copyLinkBtn = createIconButton(
'M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z',
'Copy Maps Link',
(e) => {
e.stopPropagation();
const link = googleMapsLink(round.pano);
if (link) {
copyToClipboard(link);
showTempTooltip('Link copied!', e.currentTarget);
}
}
);
const mapsBtn = createMapsIconButton(googleMapsLink(round.pano));
const roundNum = round.roundNum;
const roundLabel = document.createElement('span');
roundLabel.textContent = `Round ${roundNum}`;
roundLabel.style.flex = '1';
const { lat, lng } = round.pano;
let flagEl;
// Handle US State Streaks with compound flag ids
const isUsStateStreak = currentGameData?.isUsStateStreak;
let resolvedCode = round.countryCode;
let flagId = null;
let isCompoundFlag = false;
if (isUsStateStreak && round.stateCode) {
flagId = `us-${round.stateCode}`;
isCompoundFlag = true;
resolvedCode = 'US'; // Ensure JSON has 'US' as countryCode
} else {
if (!resolvedCode && lat != null && lng != null) {
const cacheKey = `${lat},${lng}`;
if (countryCache.has(cacheKey)) {
const cachedCode = countryCache.get(cacheKey);
if (cachedCode) {
resolvedCode = cachedCode;
}
}
}
if (resolvedCode) {
flagId = resolvedCode;
}
}
if (flagId) {
flagEl = createFlagElement(flagId, false, isCompoundFlag);
round.countryCode = resolvedCode;
} else {
flagEl = createFlagElement(null, true); // spinning globe
resolveCountryForRound(round, flagEl, index);
}
item.append(checkbox, copyLinkBtn, mapsBtn, roundLabel, flagEl);
roundCheckboxes.push({ el: checkbox, roundData: round });
return item;
}
async function resolveCountryForRound(round, flagEl, index) {
await new Promise(r => setTimeout(r, 1000 * index));
if (!round.pano?.lat || !round.pano?.lng) return;
const code = await getCountryCode(round.pano.lat, round.pano.lng);
if (code) {
round.countryCode = code;
const newFlag = createFlagElement(code);
flagEl.replaceWith(newFlag);
} else {
const unknownFlag = createFlagElement(null, false);
flagEl.replaceWith(unknownFlag);
}
}
function createIconButton(path, title, handler) {
const btn = document.createElement('button');
btn.type = 'button';
btn.title = title;
btn.style.cssText = `
width: 20px; height: 20px; border: none; background: transparent; padding: 0;
cursor: pointer; display: flex; align-items: center; justify-content: center;
color: #aaa; flex-shrink: 0; transition: color 0.2s;
`;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '16');
svg.setAttribute('height', '16');
svg.setAttribute('fill', 'currentColor');
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('d', path);
svg.appendChild(pathEl);
btn.appendChild(svg);
btn.addEventListener('click', handler);
btn.addEventListener('mouseenter', () => btn.style.color = '#2196F3');
btn.addEventListener('mouseleave', () => btn.style.color = '#aaa');
return btn;
}
function createMapsIconButton(link) {
const a = document.createElement('a');
a.href = link || '#';
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.title = 'Open in Google Maps';
a.style.cssText = `
display: inline-block; width: 20px; height: 20px;
background: url('https://www.google.com/s2/favicons?sz=64&domain=google.com') center/contain no-repeat;
cursor: pointer; text-decoration: none; transition: transform 0.2s;
`;
a.addEventListener('click', (e) => {
e.preventDefault();
if (link) window.open(link, '_blank', 'noopener,noreferrer');
});
a.addEventListener('mouseenter', () => a.style.transform = 'scale(1.1)');
a.addEventListener('mouseleave', () => a.style.transform = '');
return a;
}
// ──────────────────────────────────────────────────────────────────────
// SELECTION & EXPORT HANDLERS
// ──────────────────────────────────────────────────────────────────────
function toggleSelectAll(e) {
const checked = e.target.checked;
roundCheckboxes.forEach(({ el }) => el.checked = checked);
updateSelectAllState();
}
function updateSelectAllState() {
const all = roundCheckboxes.length > 0;
const allChecked = all && roundCheckboxes.every(({ el }) => el.checked);
const someChecked = roundCheckboxes.some(({ el }) => el.checked);
const selectAll = document.getElementById('ggjsonexport-select-all');
if (selectAll) {
selectAll.checked = allChecked;
selectAll.indeterminate = all && !allChecked && someChecked;
selectAll.disabled = !all;
}
}
function getSelectedRounds() {
return roundCheckboxes
.filter(({ el }) => el.checked)
.map(({ roundData }) => roundData);
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).catch(() => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
});
}
function showTempTooltip(msg, targetElement = null) {
const existing = document.querySelector('#ggjsonexport-tooltip');
if (existing) existing.remove();
if (!targetElement) {
console.warn('Tooltip target missing');
return;
}
const tooltip = document.createElement('div');
tooltip.id = 'ggjsonexport-tooltip';
tooltip.textContent = msg;
tooltip.style.cssText = `
position: fixed;
background: #333;
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
white-space: nowrap;
`;
const rect = targetElement.getBoundingClientRect();
document.body.appendChild(tooltip);
const tooltipWidth = tooltip.offsetWidth;
tooltip.style.left = (rect.left + rect.width / 2 - tooltipWidth / 2) + 'px';
tooltip.style.top = (rect.top - 32) + 'px';
setTimeout(() => tooltip.style.opacity = '1', 10);
setTimeout(() => {
tooltip.style.opacity = '0';
setTimeout(() => tooltip.remove(), 200);
}, 1500);
}
function copyJsonHandler(e) {
const json = buildJsonForExport(getSelectedRounds(), currentGameData.mapName, currentGameData.gameType, currentGameId);
copyToClipboard(json);
showTempTooltip('JSON copied!', e.currentTarget);
}
function downloadJsonHandler(e) {
const json = buildJsonForExport(getSelectedRounds(), currentGameData.mapName, currentGameData.gameType, currentGameId);
const filename = `${currentGameData.gameType}_${currentGameId}.json`;
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showTempTooltip('JSON downloaded!', e.currentTarget);
}
function toggleContent() {
const content = document.getElementById('ggjsonexport-rounds-content');
const isHidden = content.style.display === 'none';
const isVisible = !isHidden;
content.style.display = isVisible ? 'none' : 'block';
const toggleBtn = document.getElementById('ggjsonexport-toggle-btn');
toggleBtn.innerHTML = '';
const path = isVisible ? 'M7 15l5-5 5 5z' : 'M7 10l5 5 5-5z';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '16');
svg.setAttribute('height', '16');
svg.setAttribute('fill', 'currentColor');
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('d', path);
svg.appendChild(pathEl);
toggleBtn.appendChild(svg);
saveRoundsExpanded(!isVisible);
}
// ──────────────────────────────────────────────────────────────────────
// REACT DATA EXTRACTION
// ──────────────────────────────────────────────────────────────────────
function extractGameDataFromReact(element) {
let current = element;
while (current && current !== document.body) {
const fiberKey = Object.keys(current).find(k => k.startsWith('__reactFiber$'));
if (fiberKey) {
const result = deepSearchReactNode(current[fiberKey]);
if (result) return result;
}
current = current.parentElement;
}
return null;
}
function deepSearchReactNode(node, visited = new WeakSet()) {
if (!node || typeof node !== 'object' || visited.has(node)) return null;
visited.add(node);
let props = node.memoizedProps || node.pendingProps;
if (props) {
const found = extractFromProps(props);
if (found) return found;
}
const children = props?.children;
if (children) {
const childArray = Array.isArray(children) ? children : [children];
for (const child of childArray) {
if (child && typeof child === 'object') {
if (child.$$typeof && child.props) {
const found = extractFromProps(child.props);
if (found) return found;
}
if (child.memoizedProps || child.pendingProps || child.child || child.sibling) {
const nestedResult = deepSearchReactNode(child, visited);
if (nestedResult) return nestedResult;
}
}
}
}
return deepSearchReactNode(node.child, visited) || deepSearchReactNode(node.sibling, visited);
}
function extractFromProps(props) {
if (!props || typeof props !== 'object') return null;
// Standard Game Results
if (props.preselectedGame) {
return {
rounds: props.preselectedGame.rounds,
mapName: props.preselectedGame.mapName,
gameType: props.preselectedGame.type.toLowerCase() // 'standard', 'challenge'
}
}
// Country Streaks
if (props.selectedGame) {
const isUsStateStreak = props.selectedGame.streakType === 'UsStateStreak'; // Special case
return {
rounds: props.selectedGame.rounds,
mapName: props.selectedGame.mapName,
gameType: props.selectedGame.streakType.toLowerCase(), // 'CountryStreak', 'UsStateStreak'
isUsStateStreak
};
}
// Duels / Team Duels
if (props.game) {
// Game generates sets of 5 rounds for duels, we filter only played rounds
return {
rounds: props.game.rounds?.filter(round => round.hasProcessedRoundTimeout) || [],
mapName: props.game.options?.map?.name,
gameType: props.game.gameType.toLowerCase() // 'Duels', 'TeamDuels'
};
}
// Battle Royale
if (props.children) { // Search children because we're using a parent container as target
const children = Array.isArray(props.children) ? props.children : [props.children];
for (const child of children) {
if (child?.props?.summary && child?.props?.lobby) {
return {
rounds: child?.props?.summary?.rounds,
mapName: child?.props?.lobby?.mapName,
gameType: child?.props?.lobby?.gameType.toLowerCase() // 'BattleRoyaleCountries', 'BattleRoyaleDistance'
};
}
}
}
return null;
}
// ──────────────────────────────────────────────────────────────────────
// PAGE DETECTION & ACTIVATION
// ──────────────────────────────────────────────────────────────────────
function getGameIdFromUrl() {
const duelsGame = window.location.pathname.match(/\/(?:team-)?duels\/([^\/]+)\/summary/);
if (duelsGame) return duelsGame[1];
const brGame = window.location.pathname.match(/\/battle-royale\/([^\/]+)\/summary/);
if (brGame) return brGame[1];
const standardGame = window.location.pathname.match(/\/results\/([^\/]+)/);
if (standardGame) return standardGame[1];
return null;
}
function isDuelSummaryPage() {
return /\/(?:team-)?duels\/[^\/]+\/summary/.test(window.location.pathname);
}
function isBattleRoyaleSummaryPage() {
return window.location.pathname.includes('/battle-royale/') && window.location.pathname.includes('/summary');
}
function isGameResultsPage() {
return window.location.pathname.includes('/results/');
}
async function activate() {
if (isActive) return;
// Finding React containers
let container = null;
if (isDuelSummaryPage()) {
container = document.querySelector('[class*="game-summary_innerContainer_"]');
} else if (isBattleRoyaleSummaryPage()) {
container = document.querySelector('[class*="in-game_layout_"]');
} else if (isGameResultsPage()) {
container = document.querySelector('[class*="results_container_"]');
}
if (!container) return;
const game = extractGameDataFromReact(container);
const gameId = getGameIdFromUrl();
if (!game || !gameId) return;
roundCheckboxes = [];
currentGameData = game;
currentGameId = gameId;
createWidget();
await updateWidgetContent();
observer = new MutationObserver(() => { });
observer.observe(container, { childList: true, subtree: true });
isActive = true;
}
async function updateWidgetContent() {
const content = document.getElementById('ggjsonexport-rounds-content');
content.innerHTML = '';
let rounds = currentGameData.rounds || [];
const parsedRounds = rounds
.map((r, idx) => parseRoundData(r, idx, currentGameData.isUsStateStreak))
.filter(Boolean);
if (parsedRounds.length === 0) {
content.textContent = 'No rounds found.';
content.style.color = '#aaa';
content.style.padding = '12px';
content.style.background = 'transparent';
} else {
for (let i = 0; i < parsedRounds.length; i++) {
const item = await createRoundItem(parsedRounds[i], i);
content.appendChild(item);
}
}
updateSelectAllState();
}
function deactivate() {
if (observer) {
observer.disconnect();
observer = null;
}
if (widget) {
widget.remove();
widget = null;
}
document.removeEventListener('click', closeTagsDropdownOnClickOutside);
isActive = false;
currentGameData = null;
currentGameId = null;
roundCheckboxes = [];
}
function onPageChange() {
if (isDuelSummaryPage() || isGameResultsPage() || isBattleRoyaleSummaryPage()) {
setTimeout(activate, 300);
} else {
deactivate();
}
}
// ──────────────────────────────────────────────────────────────────────
// GLOBAL CLICK LISTENER (TO CLOSE TAGS DROPDOWN)
// ──────────────────────────────────────────────────────────────────────
function closeTagsDropdownOnClickOutside(e) {
const dropdown = document.getElementById('ggjsonexport-tags-dropdown');
const toggleBtn = document.getElementById('ggjsonexport-tags-btn');
if (!dropdown || dropdown.style.display !== 'block') return;
if (!dropdown.contains(e.target) && e.target !== toggleBtn) {
dropdown.style.display = 'none';
}
}
document.addEventListener('click', closeTagsDropdownOnClickOutside);
// ──────────────────────────────────────────────────────────────────────
// INITIALIZATION
// ──────────────────────────────────────────────────────────────────────
const originalPush = history.pushState;
const originalReplace = history.replaceState;
history.pushState = function (...args) { originalPush.apply(this, args); onPageChange(); };
history.replaceState = function (...args) { originalReplace.apply(this, args); onPageChange(); };
window.addEventListener('popstate', onPageChange);
if (isDuelSummaryPage() || isGameResultsPage() || isBattleRoyaleSummaryPage()) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(activate, 300));
} else {
setTimeout(activate, 300);
}
}
})();