Torn Profile Link Formatter

Adds a dark-mode 'Copy' button with a customizable settings menu next to a user's name.

目前為 2025-06-16 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Torn Profile Link Formatter
// @namespace    GNSC4 [268863]
// @version      1.3.0
// @description  Adds a dark-mode 'Copy' button with a customizable settings menu next to a user's name.
// @author       GNSC4 [268863]
// @match        https://www.torn.com/profiles.php?XID=*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function() {
    'use strict';

    // --- GM Polyfills for environments without native support ---
    const GNSC_setValue = typeof GM_setValue !== 'undefined' ? GM_setValue : (key, value) => localStorage.setItem(key, JSON.stringify(value));
    const GNSC_getValue = typeof GM_getValue !== 'undefined' ? GM_getValue : (key, def) => JSON.parse(localStorage.getItem(key)) || def;

    // --- Add Styles for the UI ---
    if (typeof GM_addStyle !== 'undefined') {
        GM_addStyle(`
            .gnsc-copy-container { display: inline-flex; align-items: center; vertical-align: middle; gap: 5px; margin-left: 10px; }
            .gnsc-btn { background-color: #333; color: #DDD; border: 1px solid #555; border-radius: 5px; padding: 3px 8px; text-decoration: none; font-size: 12px; line-height: 1.5; font-weight: bold; cursor: pointer; white-space: nowrap; }
            .gnsc-btn:hover { background-color: #444; }
            .gnsc-settings-panel { display: none; position: absolute; background-color: #2c2c2c; border: 1px solid #555; border-radius: 5px; padding: 10px; z-index: 1000; top: 100%; left: 0; min-width: 200px; }
            .gnsc-settings-panel div { margin-bottom: 5px; display: flex; align-items: center; }
            .gnsc-settings-panel label { color: #DDD; flex-grow: 1; }
            .gnsc-settings-panel input[type="checkbox"] { margin-left: 5px; }
            .gnsc-settings-panel label.disabled { color: #888; }
            .gnsc-settings-container { position: relative; }
            .gnsc-api-key-wrapper { display: flex; flex-direction: column; align-items: flex-start !important; }
            .gnsc-api-key-wrapper label { margin-bottom: 4px; }
            .gnsc-api-key-input-wrapper { display: flex; width: 100%; }
            .gnsc-api-key-input { width: 100%; background-color: #1e1e1e; border: 1px solid #555; color: #ddd; border-radius: 3px; padding: 2px 4px;}
            #gnsc-show-api-key-btn { font-size: 10px; margin-left: 4px; padding: 2px 4px; }
        `);
    }

    /**
     * Main script logic, called after elements are confirmed to exist.
     */
    function main(nameElement, infoTable) {
        const urlParams = new URLSearchParams(window.location.search);
        const userId = urlParams.get('XID');
        if (!userId) return;

        const userName = nameElement.textContent.split(' [')[0].trim();
        let factionLinkEl = null;
        let companyLinkEl = null;
        let hospitalInfo = { inHospital: false, releaseTimestamp: 0 };

        const infoListItems = infoTable.querySelectorAll('li');
        infoListItems.forEach(item => {
            const titleEl = item.querySelector('.user-information-section .bold');
            if (!titleEl) return;
            const title = titleEl.textContent.trim();
            if (title === 'Faction') factionLinkEl = item.querySelector('.user-info-value a');
            if (title === 'Job') companyLinkEl = item.querySelector('.user-info-value a');
        });

        const statusDescEl = document.querySelector('.profile-container .description .main-desc');
        if (statusDescEl && statusDescEl.textContent.includes('In hospital')) {
            hospitalInfo.inHospital = true;
            const hospitalTimeStr = statusDescEl.textContent.trim();
            const hourMatch = hospitalTimeStr.match(/(\d+)\s+hour(s?)/);
            const minMatch = hospitalTimeStr.match(/(\d+)\s+minute(s?)/);
            const secMatch = hospitalTimeStr.match(/(\d+)\s+second(s?)/);
            let totalSeconds = 0;
            if (hourMatch) totalSeconds += parseInt(hourMatch[1], 10) * 3600;
            if (minMatch) totalSeconds += parseInt(minMatch[1], 10) * 60;
            if (secMatch) totalSeconds += parseInt(secMatch[1], 10);
            hospitalInfo.releaseTimestamp = Date.now() + totalSeconds * 1000;
        }

        const userInfo = {
            id: userId,
            name: userName,
            profileUrl: `https://www.torn.com/profiles.php?XID=${userId}`,
            attackUrl: `https://www.torn.com/loader2.php?sid=getInAttack&user2ID=${userId}`,
            factionUrl: factionLinkEl ? factionLinkEl.href : null,
            companyUrl: companyLinkEl ? companyLinkEl.href : null,
            hospitalInfo: hospitalInfo
        };

        createUI(nameElement, userInfo);
    }

    /**
     * Creates and injects all UI elements (buttons, panel).
     */
    function createUI(targetElement, userInfo) {
        const container = document.createElement('div');
        container.className = 'gnsc-copy-container';

        const copyButton = document.createElement('a');
        copyButton.href = "#";
        copyButton.className = 'gnsc-btn';
        copyButton.innerHTML = '<span>Copy</span>';
        copyButton.addEventListener('click', (e) => handleCopyClick(e, copyButton, userInfo));

        const settingsContainer = document.createElement('div');
        settingsContainer.className = 'gnsc-settings-container';

        const settingsButton = document.createElement('a');
        settingsButton.href = "#";
        settingsButton.className = 'gnsc-btn';
        settingsButton.innerHTML = '⚙️';

        const settingsPanel = createSettingsPanel(userInfo);
        settingsButton.addEventListener('click', (e) => {
            e.preventDefault();
            settingsPanel.style.display = settingsPanel.style.display === 'block' ? 'none' : 'block';
        });

        document.addEventListener('click', (e) => {
            if (!settingsContainer.contains(e.target)) {
                settingsPanel.style.display = 'none';
            }
        });

        settingsContainer.appendChild(settingsButton);
        settingsContainer.appendChild(settingsPanel);
        container.appendChild(copyButton);
        container.appendChild(settingsContainer);
        targetElement.insertAdjacentElement('afterend', container);
    }

    /**
     * Creates the settings panel element.
     */
    function createSettingsPanel(userInfo) {
        const panel = document.createElement('div');
        panel.className = 'gnsc-settings-panel';
        const settings = loadSettings();

        // API Key Section
        const apiKeyWrapper = document.createElement('div');
        apiKeyWrapper.className = 'gnsc-api-key-wrapper';
        apiKeyWrapper.innerHTML = `<label for="gnsc-api-key">TornStats API Key</label>`;
        const inputWrapper = document.createElement('div');
        inputWrapper.className = 'gnsc-api-key-input-wrapper';

        const apiKeyInput = document.createElement('input');
        apiKeyInput.type = 'password';
        apiKeyInput.id = 'gnsc-api-key';
        apiKeyInput.className = 'gnsc-api-key-input';
        apiKeyInput.value = settings.apiKey || '';
        apiKeyInput.addEventListener('input', () => {
             updateBattleStatsAvailability();
             saveSettings();
        });

        const showApiKeyBtn = document.createElement('button');
        showApiKeyBtn.id = 'gnsc-show-api-key-btn';
        showApiKeyBtn.className = 'gnsc-btn';
        showApiKeyBtn.textContent = 'Show';
        showApiKeyBtn.addEventListener('click', (e) => {
            e.preventDefault();
            const isPassword = apiKeyInput.type === 'password';
            apiKeyInput.type = isPassword ? 'text' : 'password';
            showApiKeyBtn.textContent = isPassword ? 'Hide' : 'Show';
        });

        inputWrapper.appendChild(apiKeyInput);
        inputWrapper.appendChild(showApiKeyBtn);
        apiKeyWrapper.appendChild(inputWrapper);
        panel.appendChild(apiKeyWrapper);
        panel.appendChild(document.createElement('hr'));


        // Checkbox Options
        const options = [
            { key: 'profile', label: 'Profile', available: true },
            { key: 'attack', label: 'Attack', available: true },
            { key: 'faction', label: 'Faction', available: !!userInfo.factionUrl },
            { key: 'company', label: 'Company', available: !!userInfo.companyUrl },
            { key: 'hospital', label: 'Hospital Time', available: userInfo.hospitalInfo.inHospital },
            { key: 'battlestats', label: 'Battle Stats', available: true } // Always create the element
        ];

        options.forEach(option => {
            const wrapper = document.createElement('div');
            const checkbox = document.createElement('input');
            const label = document.createElement('label');

            checkbox.type = 'checkbox';
            checkbox.id = `gnsc-check-${option.key}`;
            checkbox.checked = option.available && settings[option.key];
            checkbox.disabled = !option.available; // Will be updated dynamically for battlestats
            checkbox.addEventListener('change', () => saveSettings());

            label.htmlFor = `gnsc-check-${option.key}`;
            label.textContent = option.label;
            if (!option.available) label.classList.add('disabled');

            wrapper.appendChild(label);
            wrapper.appendChild(checkbox);
            panel.appendChild(wrapper);
        });

        updateBattleStatsAvailability(); // Set initial state of the Battle Stats checkbox
        return panel;
    }

    function formatNumber(num) {
        if (num < 1e3) return num;
        if (num >= 1e3 && num < 1e6) return +(num / 1e3).toFixed(2) + "K";
        if (num >= 1e6 && num < 1e9) return +(num / 1e6).toFixed(2) + "M";
        if (num >= 1e9 && num < 1e12) return +(num / 1e9).toFixed(2) + "B";
        if (num >= 1e12 && num < 1e15) return +(num / 1e12).toFixed(2) + "T";
        if (num >= 1e15) return +(num / 1e15).toFixed(2) + "Q";
    };

    function formatTimeDifference(timestamp) {
        const now = Math.floor(Date.now() / 1000);
        const seconds = now - timestamp;

        let interval = seconds / 31536000;
        if (interval > 1) return Math.floor(interval) + " years ago";
        interval = seconds / 2592000;
        if (interval > 1) return Math.floor(interval) + " months ago";
        interval = seconds / 86400;
        if (interval > 1) return Math.floor(interval) + " days ago";
        interval = seconds / 3600;
        if (interval > 1) return Math.floor(interval) + " hours ago";
        interval = seconds / 60;
        if (interval > 1) return Math.floor(interval) + " minutes ago";
        return Math.floor(seconds) + " seconds ago";
    }

    async function handleCopyClick(e, button, userInfo) {
        e.preventDefault();
        const settings = loadSettings();
        let battleStatsStr = null;
        let hospitalStr = null;

        if (settings.hospital && userInfo.hospitalInfo.inHospital) {
            const currentStatusDescEl = document.querySelector('.profile-container .description .main-desc');
            const relativeTimeStr = currentStatusDescEl ? currentStatusDescEl.textContent.trim() : '';

            const releaseDate = new Date(userInfo.hospitalInfo.releaseTimestamp);
            const tctTimeString = releaseDate.toLocaleTimeString([], {
                hour: '2-digit',
                minute: '2-digit',
                second: '2-digit',
                hour12: false,
                timeZone: 'UTC'
            });

            hospitalStr = `(${relativeTimeStr} | Out at ${tctTimeString} TCT)`;
        }


        if (settings.battlestats && settings.apiKey) {
            button.innerHTML = '<span>Fetching...</span>';
            try {
                const spyData = await fetchTornStatsSpy(settings.apiKey, userInfo.id);
                const spyResult = spyData?.spy;

                if (spyResult?.status === true && typeof spyResult?.total !== 'undefined') {
                    const str = formatNumber(spyResult.strength);
                    const def = formatNumber(spyResult.defense);
                    const spd = formatNumber(spyResult.speed);
                    const dex = formatNumber(spyResult.dexterity);
                    const total = formatNumber(spyResult.total);
                    const timeAgo = formatTimeDifference(spyResult.timestamp);

                    battleStatsStr = `(Str: ${str} | Def: ${def} | Spd: ${spd} | Dex: ${dex} | Total: ${total} | Spy: ${timeAgo})`;
                } else {
                    battleStatsStr = "(Stats: N/A)";
                }
            } catch (error) {
                console.error("Torn Profile Link Formatter: Failed to fetch TornStats data.", error);
                battleStatsStr = "(Stats: API Error)";
            }
        }

        const links = [];
        if (settings.profile) links.push(`<a href="${userInfo.profileUrl}">Profile</a>`);
        if (settings.attack) links.push(`<a href="${userInfo.attackUrl}">Attack</a>`);
        if (settings.faction && userInfo.factionUrl) links.push(`<a href="${userInfo.factionUrl}">Faction</a>`);
        if (settings.company && userInfo.companyUrl) links.push(`<a href="${userInfo.companyUrl}">Company</a>`);
        if (hospitalStr) links.push(hospitalStr);
        if (battleStatsStr) links.push(battleStatsStr);

        const formattedString = links.length > 0 ? `${userInfo.name} - ${links.join(' - ')}` : userInfo.name;
        copyToClipboard(formattedString);

        const originalText = '<span>Copy</span>';
        button.innerHTML = '<span>Copied!</span>';
        button.style.backgroundColor = '#2a633a';
        setTimeout(() => {
            button.innerHTML = originalText;
            button.style.backgroundColor = '';
        }, 2000);
    }

    function fetchTornStatsSpy(apiKey, userId) {
        const requestUrl = `https://www.tornstats.com/api/v2/${apiKey}/spy/user/${userId}`;
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: requestUrl,
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                           resolve(JSON.parse(response.responseText));
                        } catch(e) {
                           reject(new Error("Failed to parse JSON from TornStats."));
                        }
                    } else {
                        reject(new Error(`API responded with status ${response.status}`));
                    }
                },
                onerror: function(error) {
                    reject(error);
                }
            });
        });
    }

    function loadSettings() {
        return GNSC_getValue('tornProfileFormatterSettings', {
            profile: true,
            attack: true,
            faction: false,
            company: false,
            hospital: true,
            battlestats: false,
            apiKey: ''
        });
    }
    
    /**
     * Updates the availability of the Battle Stats option based on the API key input.
     */
    function updateBattleStatsAvailability() {
        const battleStatsCheckbox = document.getElementById('gnsc-check-battlestats');
        const battleStatsLabel = document.querySelector('label[for="gnsc-check-battlestats"]');
        const apiKeyInput = document.getElementById('gnsc-api-key');
        if (!battleStatsCheckbox || !battleStatsLabel || !apiKeyInput) return;

        const hasApiKey = !!apiKeyInput.value.trim();
        battleStatsCheckbox.disabled = !hasApiKey;
        battleStatsLabel.classList.toggle('disabled', !hasApiKey);

        if (!hasApiKey) {
            battleStatsCheckbox.checked = false;
        }
    }


    function saveSettings() {
        const battleStatsCheckbox = document.getElementById('gnsc-check-battlestats');
        const apiKeyInput = document.getElementById('gnsc-api-key');
        const hasApiKey = !!(apiKeyInput && apiKeyInput.value.trim());

        const settings = {
            profile: document.getElementById('gnsc-check-profile').checked,
            attack: document.getElementById('gnsc-check-attack').checked,
            faction: document.getElementById('gnsc-check-faction')?.checked || false,
            company: document.getElementById('gnsc-check-company')?.checked || false,
            hospital: document.getElementById('gnsc-check-hospital')?.checked || false,
            battlestats: hasApiKey && battleStatsCheckbox?.checked || false,
            apiKey: apiKeyInput?.value || ''
        };
        GNSC_setValue('tornProfileFormatterSettings', settings);
    }

    function copyToClipboard(text) {
        const tempTextarea = document.createElement('textarea');
        tempTextarea.style.position = 'fixed';
        tempTextarea.style.left = '-9999px';
        tempTextarea.value = text;
        document.body.appendChild(tempTextarea);
        tempTextarea.select();
        document.execCommand('copy');
        document.body.removeChild(tempTextarea);
    }

    const observer = new MutationObserver((mutations, obs) => {
        const nameElement = document.querySelector('#skip-to-content');
        const infoTable = document.querySelector('.basic-information .info-table');
        const alreadyInjected = document.querySelector('.gnsc-copy-container');
        if (nameElement && infoTable && infoTable.children.length > 5 && !alreadyInjected) {
            main(nameElement, infoTable);
            obs.disconnect();
        }
    });
    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();