// ==UserScript==
// @name Torn Profile Link Formatter
// @namespace GNSC4 [268863]
// @version 1.4.4
// @description Adds a copy button next to user names on profile, faction, and ranked war pages for easy sharing.
// @author GNSC4 [268863]
// @match https://www.torn.com/profiles.php?XID=*
// @match https://www.torn.com/factions.php*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// ==/UserScript==
(function() {
'use strict';
// --- Global cache for live hospital data ---
let hospTime = {};
// --- 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-list-btn { margin-left: 5px; cursor: pointer; font-size: 14px; display: inline-block; vertical-align: middle; width: 18px; text-align: center; }
.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; }
.buttons-wrap .gnsc-list-btn { padding: 4px; font-size: 16px; height: 34px; line-height: 26px; } /* Mini profile button style */
`);
}
// --- Page Initialization Logic ---
function initProfilePage() {
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) {
mainProfile(nameElement, infoTable);
return true;
}
return false;
}
function initFactionPage() {
const memberLists = document.querySelectorAll('.members-list, .enemy-list, .your-faction');
if (memberLists.length > 0) {
memberLists.forEach(list => injectButtonsIntoList(list));
return true;
}
return false;
}
function initMiniProfile() {
const miniProfile = document.querySelector('.profile-mini-_wrapper___Arw8R:not(.gnsc-injected), .mini-profile-wrapper:not(.gnsc-injected)');
if (miniProfile) {
miniProfile.classList.add('gnsc-injected');
let attempts = 0;
const maxAttempts = 25; // Try for 5 seconds
const interval = setInterval(() => {
const buttonContainer = miniProfile.querySelector('.buttons-wrap');
const nameLink = miniProfile.querySelector('a[href*="profiles.php?XID="]');
if (buttonContainer && nameLink && !buttonContainer.querySelector('.gnsc-list-btn')) {
clearInterval(interval);
const button = document.createElement('span');
button.className = 'gnsc-list-btn';
button.textContent = '📄';
button.title = 'Copy Formatted Link';
button.addEventListener('click', (e) => handleListCopyClick(e, button, miniProfile));
buttonContainer.insertAdjacentElement('afterbegin', button);
} else if (attempts >= maxAttempts) {
clearInterval(interval);
}
attempts++;
}, 200);
}
}
function injectButtonsIntoList(listElement) {
const members = listElement.querySelectorAll('li.member, li.table-row, li.enemy, li.your');
members.forEach(member => {
const nameLink = member.querySelector('a[href*="profiles.php"]');
if (nameLink && !member.querySelector('.gnsc-list-btn')) {
const button = document.createElement('span');
button.className = 'gnsc-list-btn';
button.textContent = '📄';
button.title = 'Copy Formatted Link';
button.addEventListener('click', (e) => handleListCopyClick(e, button, member));
nameLink.insertAdjacentElement('afterend', button);
}
});
}
// --- Profile Page Specific Functions ---
function mainProfile(nameElement, infoTable) {
const urlParams = new URLSearchParams(window.location.search);
const userId = urlParams.get('XID');
if (!userId) return;
const cleanedName = nameElement.textContent.replace("'s Profile", "").split(' [')[0].trim();
let factionLinkEl = null;
let companyLinkEl = null;
let isInHospital = false;
let activityStatus = 'Offline';
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')) {
isInHospital = true;
}
const statusIconEl = document.querySelector('li[id^="icon1-profile-"], li[id^="icon2-profile-"], li[id^="icon62-profile-"]');
if (statusIconEl) {
if (statusIconEl.className.includes('-Online')) activityStatus = 'Online';
else if (statusIconEl.className.includes('-Away')) activityStatus = 'Idle';
}
const userInfo = {
id: userId,
name: cleanedName,
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,
isInHospital: isInHospital,
activityStatus: activityStatus
};
createUI(nameElement, userInfo);
}
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);
}
function createSettingsPanel(userInfo) {
const panel = document.createElement('div');
panel.className = 'gnsc-settings-panel';
const settings = loadSettings();
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'));
const options = [
{ key: 'attack', label: 'Attack', available: true },
{ key: 'activity', label: 'Activity Status', available: true },
{ key: 'faction', label: 'Faction', available: !!userInfo.factionUrl },
{ key: 'company', label: 'Company', available: !!userInfo.companyUrl },
{ key: 'timeRemaining', label: 'Time Remaining', available: userInfo.isInHospital },
{ key: 'releaseTime', label: 'Release Time (TCT)', available: userInfo.isInHospital },
{ key: 'battlestats', label: 'Battle Stats', available: true }
];
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;
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();
return panel;
}
async function handleCopyClick(e, button, userInfo) {
e.preventDefault();
const settings = loadSettings();
let battleStatsStr = null;
let hospitalStr = null;
let statusEmoji = '';
if (settings.activity) {
if (userInfo.activityStatus === 'Online') statusEmoji = '🟢 ';
else if (userInfo.activityStatus === 'Idle') statusEmoji = '🟡 ';
else statusEmoji = '⚫ ';
}
if (settings.hospital && userInfo.isInHospital) {
const timeParts = [];
const releaseTimestamp = hospTime[userInfo.id] || null;
if (releaseTimestamp) {
if (settings.timeRemaining) {
const remainingSeconds = releaseTimestamp - (Date.now() / 1000);
timeParts.push(`In hospital for ${formatRemainingTime(remainingSeconds)}`);
}
if(settings.releaseTime) {
const releaseDate = new Date(releaseTimestamp * 1000);
const tctTimeString = releaseDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'UTC' });
timeParts.push(`Out at ${tctTimeString} TCT`);
}
}
if (timeParts.length > 0) hospitalStr = `(${timeParts.join(' | ')})`;
}
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') {
battleStatsStr = `(Str: ${formatNumber(spyResult.strength)} | Def: ${formatNumber(spyResult.defense)} | Spd: ${formatNumber(spyResult.speed)} | Dex: ${formatNumber(spyResult.dexterity)} | Total: ${formatNumber(spyResult.total)} | Spy: ${formatTimeDifference(spyResult.timestamp)})`;
} else {
battleStatsStr = "(Stats: N/A)";
}
} catch (error) {
console.error("Torn Profile Link Formatter: Failed to fetch TornStats data.", error);
battleStatsStr = "(Stats: API Error)";
}
}
const linkedName = `<a href="${userInfo.profileUrl}">${userInfo.name} [${userInfo.id}]</a>`;
const details = [];
if (settings.attack) details.push(`<a href="${userInfo.attackUrl}">Attack</a>`);
if (settings.faction && userInfo.factionUrl) details.push(`<a href="${userInfo.factionUrl}">Faction</a>`);
if (settings.company && userInfo.companyUrl) details.push(`<a href="${userInfo.companyUrl}">Company</a>`);
if (hospitalStr) details.push(hospitalStr);
if (battleStatsStr) details.push(battleStatsStr);
const formattedString = details.length > 0 ? `${statusEmoji}${linkedName} - ${details.join(' - ')}` : `${statusEmoji}${linkedName}`;
copyToClipboard(formattedString);
const originalText = '<span>Copy</span>';
button.innerHTML = '<span>Copied!</span>';
button.style.backgroundColor = '#2a633a';
setTimeout(() => { button.innerHTML = originalText; button.style.backgroundColor = ''; }, 2000);
}
async function handleListCopyClick(e, button, memberElement) {
e.preventDefault();
e.stopPropagation();
const nameLink = memberElement.querySelector('a[href*="profiles.php"]');
if (!nameLink) return;
const name = nameLink.textContent.trim();
const idMatch = nameLink.href.match(/XID=(\d+)/);
if (!idMatch) return;
const id = idMatch[1];
const settings = loadSettings();
let statusEmoji = '';
let healthStr = null;
let battleStatsStr = null;
if (settings.activity) {
const statusEl = memberElement.querySelector('.userStatusWrap___ljSJG svg, li[class*="user-status-16-"]');
statusEmoji = '⚫ '; // Default
if (statusEl) {
const fillAttr = statusEl.getAttribute('fill');
const className = statusEl.className.toString();
if(fillAttr && fillAttr.includes('online') || className.includes('-Online')) statusEmoji = '🟢 ';
else if (fillAttr && fillAttr.includes('idle') || className.includes('-Away') || className.includes('-Idle')) statusEmoji = '🟡 ';
}
}
const healthStatusEl = memberElement.querySelector('.status, .description .main-desc');
if (healthStatusEl && healthStatusEl.textContent.toLowerCase().includes('hospital')) {
const timeParts = [];
const releaseTimestamp = hospTime[id] || null;
if (releaseTimestamp) {
if (settings.timeRemaining) {
const remainingSeconds = releaseTimestamp - (Date.now() / 1000);
timeParts.push(`In hospital for ${formatRemainingTime(remainingSeconds)}`);
}
if(settings.releaseTime) {
const releaseDate = new Date(releaseTimestamp * 1000);
const tctTimeString = releaseDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'UTC' });
timeParts.push(`Out at ${tctTimeString} TCT`);
}
}
if (timeParts.length > 0) healthStr = `(${timeParts.join(' | ')})`;
else healthStr = `(${healthStatusEl.textContent.trim()})`; // Fallback
}
if (settings.battlestats && settings.apiKey) {
button.textContent = '...';
try {
const spyData = await fetchTornStatsSpy(settings.apiKey, id);
const spyResult = spyData?.spy;
if (spyResult?.status === true && typeof spyResult?.total !== 'undefined') {
battleStatsStr = `(Str: ${formatNumber(spyResult.strength)} | Def: ${formatNumber(spyResult.defense)} | Spd: ${formatNumber(spyResult.speed)} | Dex: ${formatNumber(spyResult.dexterity)} | Total: ${formatNumber(spyResult.total)} | Spy: ${formatTimeDifference(spyResult.timestamp)})`;
} else {
battleStatsStr = "(Stats: N/A)";
}
} catch (error) {
console.error("Torn Profile Link Formatter: Failed to fetch TornStats data.", error);
battleStatsStr = "(Stats: API Error)";
}
}
const linkedName = `<a href="https://www.torn.com/profiles.php?XID=${id}">${name} [${id}]</a>`;
const attackLink = `<a href="https://www.torn.com/loader2.php?sid=getInAttack&user2ID=${id}">Attack</a>`;
const details = [attackLink];
if (healthStr) details.push(healthStr);
if (battleStatsStr) details.push(battleStatsStr);
const formattedString = `${statusEmoji}${linkedName} - ${details.join(' - ')}`;
copyToClipboard(formattedString);
button.textContent = '✅';
setTimeout(() => { button.textContent = '📄'; }, 1500);
}
// --- Utility Functions ---
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 formatRemainingTime(totalSeconds) {
if (totalSeconds <= 0) return "0s";
let timeParts = [];
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
if (hours > 0) timeParts.push(`${hours}h`);
if (minutes > 0) timeParts.push(`${minutes}m`);
if (seconds > 0) timeParts.push(`${seconds}s`);
return timeParts.join(' ');
}
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";
}
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: (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: (error) => reject(error)
});
});
}
function loadSettings() {
return GNSC_getValue('tornProfileFormatterSettings', {
attack: true, faction: false, company: false,
timeRemaining: true, releaseTime: true,
battlestats: false, activity: true, apiKey: ''
});
}
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 = {
attack: document.getElementById('gnsc-check-attack').checked,
faction: document.getElementById('gnsc-check-faction')?.checked || false,
company: document.getElementById('gnsc-check-company')?.checked || false,
timeRemaining: document.getElementById('gnsc-check-timeRemaining')?.checked || false,
releaseTime: document.getElementById('gnsc-check-releaseTime')?.checked || false,
activity: document.getElementById('gnsc-check-activity').checked,
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);
}
// --- Script Entry Point ---
const observer = new MutationObserver(() => {
const onProfilePage = window.location.href.includes('profiles.php');
const onFactionPage = window.location.href.includes('factions.php');
if (onProfilePage) {
initProfilePage();
} else if (onFactionPage) {
initFactionPage();
}
initMiniProfile();
});
observer.observe(document.body, { childList: true, subtree: true });
// --- Live Data Interception ---
const originalFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async (...args) => {
const url = args[0] instanceof Request ? args[0].url : args[0];
const isWarData = url.includes("step=getwarusers") || url.includes("step=getProcessBarRefreshData");
if (!isWarData) {
return originalFetch(...args);
}
const response = await originalFetch(...args);
const clone = response.clone();
clone.json().then(json => {
let members = null;
if (json.warDesc) members = json.warDesc.members;
else if (json.userStatuses) members = json.userStatuses;
else return;
Object.keys(members).forEach((id) => {
const status = members[id].status || members[id];
const userId = members[id].userID || id;
if (status.text === "Hospital") {
hospTime[userId] = status.updateAt;
} else {
delete hospTime[userId];
}
});
}).catch(err => console.error("Torn Profile Link Formatter: Error parsing fetch JSON.", err));
return response;
};
})();