GeoGuessr JSON Export

One-click JSON exporter for GeoGuessr game results

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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);
        }
    }
})();