// ==UserScript==
// @name Torn Item Safety
// @namespace http://tampermonkey.net/
// @version 1.1.1
// @license GNU GPLv3
// @description Displays effects of items in Torn's various item shops with hover tooltips and disables items with warnings when toggle is active.
// @author Vassilios [2276659]
// @match https://www.torn.com/item.php*
// @match https://www.torn.com/bigalgunshop.php*
// @match https://www.torn.com/shops.php?step=*
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @connect api.torn.com
// ==/UserScript==
(function() {
'use strict';
// Register Tampermonkey menu command to enter API key
GM_registerMenuCommand('Enter API key.', () => {
const newApiKey = prompt('Please enter your Torn API key:', '');
if (newApiKey && newApiKey.trim() !== '') {
localStorage.setItem(CONFIG.STORAGE_KEYS.API_KEY, newApiKey.trim());
// Clear cached data to force a fresh fetch with the new key
localStorage.removeItem(CONFIG.STORAGE_KEYS.ITEM_DATA);
localStorage.removeItem(CONFIG.STORAGE_KEYS.LAST_REQUEST_TIME);
// Trigger a refresh of item data
ItemEffectsApp.init();
}
});
// =====================================================================
// CONFIGURATION
// =====================================================================
const CONFIG = {
getApiKey: () => localStorage.getItem('tornItemEffects_apiKey') || "", // Dynamically retrieve API key
API_BASE_URL: 'https://api.torn.com/v2/torn/items',
ITEM_CATEGORIES: ['Tool', 'Enhancer'],
CACHE_DURATION: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
OBSERVER_CONFIG: { childList: true, subtree: true },
ELEMENT_IDS: {
WARNINGS_TOGGLE: 'warnings-toggle',
WARNINGS_STATUS: 'warnings-status'
},
SELECTORS: {
ALL_ITEMS: '#all-items',
SELL_ITEMS_WRAP: '.sell-items-wrap',
CONTENT_TITLE_LINKS: '.content-title-links',
ITEM_NAME: '.name',
WARNING_SIGN: '.warning-sign'
},
STORAGE_KEYS: {
WARNING_STATE: 'tornItemEffects_warningState',
ITEM_DATA: 'tornItemEffects_itemData',
LAST_REQUEST_TIME: 'tornItemEffects_lastRequestTime',
API_KEY: 'tornItemEffects_apiKey'
},
STYLES: {
WARNING_SIGN: {
color: '#ff9900',
fontWeight: 'bold',
marginLeft: '5px',
cursor: 'help'
},
TOOLTIP: {
position: 'absolute',
backgroundColor: '#191919',
color: '#F7FAFC',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '12px',
zIndex: '9999999',
display: 'none',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
maxWidth: '250px',
textAlign: 'left',
fontFamily: 'Segoe UI, Arial, sans-serif',
lineHeight: '1.3',
border: '1px solid #4A5568',
wordWrap: 'break-word',
overflowWrap: 'break-word'
},
STATUS_INDICATOR: {
ON: { text: 'ON', color: '#4CAF50' },
OFF: { text: 'OFF', color: '#F44336' }
}
}
};
// =====================================================================
// STATE MANAGEMENT
// =====================================================================
const State = {
itemData: [],
observers: { items: null, body: null, disabledInputs: null },
disabledInputs: new Map() // Store references to disabled inputs
};
// =====================================================================
// API INTERFACE
// =====================================================================
const ApiService = {
fetchItemCategory: function(category) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest === 'undefined') {
console.error('GM_xmlhttpRequest is not available');
reject(new Error('GM_xmlhttpRequest is not defined'));
return;
}
const apiKey = CONFIG.getApiKey();
const url = `${CONFIG.API_BASE_URL}?cat=${category}&sort=ASC`;
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: {
'accept': 'application/json',
'Authorization': `ApiKey ${apiKey}`
},
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data && data.items) {
resolve(data.items);
} else {
console.error('API response does not contain items:', response.responseText);
reject(new Error('Invalid data format from API'));
}
} catch (error) {
console.error('Error parsing API response:', response.responseText, error);
reject(error);
}
},
onerror: function(error) {
console.error('GM_xmlhttpRequest error:', error);
reject(error);
}
});
});
},
fetchAllItemData: function() {
// Check if API key is set
const apiKey = CONFIG.getApiKey();
if (!apiKey) {
const cachedData = localStorage.getItem(CONFIG.STORAGE_KEYS.ITEM_DATA);
if (cachedData) {
try {
State.itemData = JSON.parse(cachedData);
return Promise.resolve(State.itemData);
} catch (error) {
console.error('Error parsing cached data:', error);
return Promise.resolve([]);
}
}
return Promise.resolve([]);
}
// Check for cached data
const cachedData = localStorage.getItem(CONFIG.STORAGE_KEYS.ITEM_DATA);
const lastRequestTime = localStorage.getItem(CONFIG.STORAGE_KEYS.LAST_REQUEST_TIME);
const currentTime = Date.now();
if (cachedData && lastRequestTime && cachedData !== "[]") {
const timeSinceLastRequest = currentTime - parseInt(lastRequestTime, 10);
if (timeSinceLastRequest < CONFIG.CACHE_DURATION) {
try {
State.itemData = JSON.parse(cachedData);
return Promise.resolve(State.itemData);
} catch (error) {
console.error('Error parsing cached data:', error);
}
}
}
// Fetch new data
const fetchPromises = CONFIG.ITEM_CATEGORIES.map(category =>
this.fetchItemCategory(category)
.then(items => {
const simplifiedItems = items.map(item => ({
name: item.name,
effect: item.effect
}));
State.itemData = [...State.itemData, ...simplifiedItems];
return simplifiedItems;
})
.catch(error => {
console.error(`Error fetching ${category} items:`, error);
return [];
})
);
return Promise.all(fetchPromises).then(() => {
// Save to localStorage
try {
localStorage.setItem(CONFIG.STORAGE_KEYS.ITEM_DATA, JSON.stringify(State.itemData));
localStorage.setItem(CONFIG.STORAGE_KEYS.LAST_REQUEST_TIME, currentTime.toString());
} catch (error) {
console.error('Error saving to localStorage:', error);
}
return State.itemData;
});
}
};
// =====================================================================
// DOM UTILITIES
// =====================================================================
const DomUtils = {
find: {
itemContainers: function() {
const containers = [];
const allItemsList = document.querySelector(CONFIG.SELECTORS.ALL_ITEMS);
if (allItemsList) containers.push(allItemsList);
const sellItemsWrap = document.querySelector(CONFIG.SELECTORS.SELL_ITEMS_WRAP);
if (sellItemsWrap) containers.push(sellItemsWrap);
return containers;
},
listItems: function() {
const containers = this.itemContainers();
let items = [];
containers.forEach(container => {
const containerItems = Array.from(container.getElementsByTagName('li'));
items = [...items, ...containerItems];
});
return items;
},
navigationContainer: function() {
return document.querySelector(CONFIG.SELECTORS.CONTENT_TITLE_LINKS);
}
},
create: {
warningSign: function(effectText) {
const warningSign = document.createElement('span');
warningSign.className = 'warning-sign';
Object.assign(warningSign.style, CONFIG.STYLES.WARNING_SIGN);
warningSign.innerHTML = '⚠️';
const tooltip = this.tooltip(effectText);
warningSign.appendChild(tooltip);
warningSign.addEventListener('mouseenter', function() {
tooltip.style.display = 'block';
const rect = warningSign.getBoundingClientRect();
const isLongText = (tooltip.textContent || '').length > 50;
tooltip.style.left = '0px';
tooltip.style.top = isLongText ? '-45px' : '-30px';
setTimeout(() => {
const tooltipRect = tooltip.getBoundingClientRect();
if (tooltipRect.left < 0) tooltip.style.left = '5px';
if (tooltipRect.top < 0) tooltip.style.top = '20px';
}, 0);
});
warningSign.addEventListener('mouseleave', function() {
tooltip.style.display = 'none';
});
return warningSign;
},
tooltip: function(content) {
const tooltip = document.createElement('div');
tooltip.className = 'item-effect-tooltip';
tooltip.setAttribute('role', 'tooltip');
Object.assign(tooltip.style, CONFIG.STYLES.TOOLTIP);
tooltip.textContent = content;
return tooltip;
},
toggleButton: function() {
const toggleButton = document.createElement('a');
toggleButton.id = CONFIG.ELEMENT_IDS.WARNINGS_TOGGLE;
toggleButton.className = 'warnings-toggle t-clear h c-pointer m-icon line-h24 right';
toggleButton.setAttribute('aria-labelledby', 'warnings-toggle-label');
const savedState = localStorage.getItem(CONFIG.STORAGE_KEYS.WARNING_STATE);
const isActive = savedState !== null ? savedState === 'true' : true;
if (isActive) {
toggleButton.classList.add('top-page-link-button--active');
toggleButton.classList.add('active');
}
toggleButton.innerHTML = `
<span class="icon-wrap svg-icon-wrap">
<span class="link-icon-svg warnings-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 16">
<defs>
<style>.cls-1{opacity:0.35;}.cls-2{fill:#fff;}.cls-3{fill:#777;}</style>
</defs>
<g>
<g>
<g class="cls-1">
<path class="cls-2" d="M7.5,1 L15,15 L0,15 Z"></path>
</g>
<path class="cls-3" d="M7.5,0 L15,14 L0,14 Z"></path>
<path class="cls-3" d="M7,6 L8,6 L8,10 L7,10 Z" style="fill:#fff"></path>
<circle class="cls-3" cx="7.5" cy="12" r="1" style="fill:#fff"></circle>
</g>
</g>
</svg>
</span>
</span>
<span id="warnings-toggle-label">Item Safety:</span>
`;
const statusIndicator = document.createElement('span');
statusIndicator.id = CONFIG.ELEMENT_IDS.WARNINGS_STATUS;
statusIndicator.style.marginLeft = '5px';
statusIndicator.style.fontWeight = 'bold';
statusIndicator.textContent = isActive ? CONFIG.STYLES.STATUS_INDICATOR.ON.text : CONFIG.STYLES.STATUS_INDICATOR.OFF.text;
statusIndicator.style.color = isActive ? CONFIG.STYLES.STATUS_INDICATOR.ON.color : CONFIG.STYLES.STATUS_INDICATOR.OFF.color;
toggleButton.appendChild(statusIndicator);
return toggleButton;
}
}
};
// =====================================================================
// INPUT PROTECTION FUNCTIONALITY
// =====================================================================
const InputProtection = {
setupDisabledInputsObserver: function() {
// Create a MutationObserver to watch for changes to disabled inputs
const observerConfig = {
attributes: true,
attributeFilter: ['disabled', 'value'],
subtree: true
};
State.observers.disabledInputs = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const element = mutation.target;
// Check if warnings are enabled before applying protection
const warningsEnabled = localStorage.getItem(CONFIG.STORAGE_KEYS.WARNING_STATE) !== 'false';
if (!warningsEnabled) return;
// Handle disabled attribute changes
if (mutation.attributeName === 'disabled') {
if (!element.disabled && element.dataset.disabledByWarning === 'true') {
// Element was disabled by our script but something tried to enable it
// Re-disable it
element.disabled = true;
}
}
// Handle value changes on disabled inputs
if (mutation.attributeName === 'value' && element.disabled && element.dataset.disabledByWarning === 'true') {
// Reset value to 0 if it was changed while disabled
if (element.value !== '0') {
element.value = '0';
}
}
});
});
document.addEventListener('input', function(e) {
// Check if warnings are enabled before applying protection
const warningsEnabled = localStorage.getItem(CONFIG.STORAGE_KEYS.WARNING_STATE) !== 'false';
if (!warningsEnabled) return;
// For any input events on disabled inputs
if (e.target.disabled && e.target.dataset.disabledByWarning === 'true') {
e.preventDefault();
e.stopPropagation();
e.target.value = '0';
}
}, true);
// Start observing the entire document
State.observers.disabledInputs.observe(document.documentElement, observerConfig);
},
// Proxy for input properties to intercept changes to disabled inputs
setupInputPropertyProxy: function() {
// Store original property descriptors
const originalValueDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
const originalDisabledDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'disabled');
// Override the value property
Object.defineProperty(HTMLInputElement.prototype, 'value', {
set: function(newValue) {
// Check if warnings are enabled before applying protection
const warningsEnabled = localStorage.getItem(CONFIG.STORAGE_KEYS.WARNING_STATE) !== 'false';
if (warningsEnabled && this.disabled && this.dataset.disabledByWarning === 'true') {
originalValueDescriptor.set.call(this, '0');
return '0';
} else {
return originalValueDescriptor.set.call(this, newValue);
}
},
get: function() {
return originalValueDescriptor.get.call(this);
},
configurable: true
});
// Override the disabled property
Object.defineProperty(HTMLInputElement.prototype, 'disabled', {
set: function(value) {
// Check if warnings are enabled before applying protection
const warningsEnabled = localStorage.getItem(CONFIG.STORAGE_KEYS.WARNING_STATE) !== 'false';
if (warningsEnabled && !value && this.dataset.disabledByWarning === 'true') {
return originalDisabledDescriptor.set.call(this, true);
} else {
return originalDisabledDescriptor.set.call(this, value);
}
},
get: function() {
return originalDisabledDescriptor.get.call(this);
},
configurable: true
});
},
// Method to track new disabled inputs
trackDisabledInput: function(input) {
if (input.type === 'text' || input.type === 'number') {
// Store original value
input.dataset.originalValue = input.value || '';
// Set value to 0 for numerical inputs
if (input.type === 'number' || !isNaN(parseFloat(input.value))) {
input.value = '0';
}
}
// Add to our tracking map
State.disabledInputs.set(input, {
originalDisabled: input.disabled,
originalValue: input.dataset.originalValue
});
},
// Method to untrack and restore inputs when warnings are disabled
restoreInput: function(input) {
// Restore original value if it exists
if (input.dataset.originalValue !== undefined) {
input.value = input.dataset.originalValue;
delete input.dataset.originalValue;
}
// Restore original state
input.disabled = false;
input.style.opacity = '';
input.style.cursor = '';
delete input.dataset.disabledByWarning;
if (input.type === 'text' && input.dataset.originalBg !== undefined) {
input.style.backgroundColor = input.dataset.originalBg;
delete input.dataset.originalBg;
}
// Remove from tracking
State.disabledInputs.delete(input);
},
// Initialize input protection
init: function() {
this.setupDisabledInputsObserver();
this.setupInputPropertyProxy();
}
};
// =====================================================================
// CORE FUNCTIONALITY
// =====================================================================
const ItemEffectsApp = {
init: function() {
this.initializeToggleButton();
// Initialize input protection first
InputProtection.init();
ApiService.fetchAllItemData()
.then(() => {
this.processItems();
this.setupObservers();
this.applyWarningState(); // Changed from applyInitialWarningState to be more general
})
.catch(error => {
console.error('Failed to fetch item data:', error);
this.processItems();
this.setupObservers();
this.applyWarningState(); // Changed from applyInitialWarningState to be more general
});
},
applyWarningState: function() {
const savedState = localStorage.getItem(CONFIG.STORAGE_KEYS.WARNING_STATE);
const isActive = savedState !== null ? savedState === 'true' : true;
const warningElements = document.querySelectorAll(CONFIG.SELECTORS.WARNING_SIGN);
warningElements.forEach(warning => {
const listItem = warning.closest('li[data-item]');
if (!listItem) return;
const isItemPage = window.location.href.includes('item.php');
if (isItemPage) {
const deleteButtons = listItem.querySelectorAll('.option-delete');
deleteButtons.forEach(button => {
if (isActive) {
button.disabled = true;
button.style.opacity = '0.5';
button.style.cursor = 'not-allowed';
button.dataset.disabledByWarning = 'true';
} else {
button.disabled = false;
button.style.opacity = '';
button.style.cursor = '';
delete button.dataset.disabledByWarning;
}
});
} else {
const inputElements = listItem.querySelectorAll('input, button, select, textarea, a.buy');
inputElements.forEach(input => {
if (isActive) {
if (input.tagName.toLowerCase() === 'a') {
input.dataset.originalHref = input.href;
input.href = 'javascript:void(0)';
input.style.opacity = '0.5';
input.style.pointerEvents = 'none';
} else {
// Store original value before disabling
if (input.type === 'text' || input.type === 'number') {
input.dataset.originalValue = input.value || '';
}
input.disabled = true;
input.style.opacity = '0.5';
input.style.cursor = 'not-allowed';
input.dataset.disabledByWarning = 'true';
// Track and protect this disabled input
InputProtection.trackDisabledInput(input);
if (input.type === 'text') {
input.dataset.originalBg = input.style.backgroundColor;
input.style.backgroundColor = '#e0e0e0';
}
}
} else {
if (input.tagName.toLowerCase() === 'a') {
if (input.dataset.originalHref) {
input.href = input.dataset.originalHref;
}
input.style.opacity = '';
input.style.pointerEvents = '';
} else {
// Use the dedicated restore method
InputProtection.restoreInput(input);
}
}
});
}
});
},
processItems: function() {
const listItems = DomUtils.find.listItems();
if (listItems.length === 0) return;
listItems.forEach(item => this.processItemElement(item));
// After processing items, apply the warning state based on toggle setting
setTimeout(() => this.applyWarningState(), 0);
},
processItemElement: function(itemElement) {
const nameElement = itemElement.querySelector(CONFIG.SELECTORS.ITEM_NAME);
if (!nameElement || !nameElement.textContent) return;
const itemName = nameElement.textContent.trim();
const matchingItem = State.itemData.find(item => item.name === itemName);
if (matchingItem && matchingItem.effect && !nameElement.querySelector(CONFIG.SELECTORS.WARNING_SIGN)) {
const warningSign = DomUtils.create.warningSign(matchingItem.effect);
nameElement.appendChild(warningSign);
}
},
initializeToggleButton: function() {
if (document.getElementById(CONFIG.ELEMENT_IDS.WARNINGS_TOGGLE)) {
return;
}
const navContainer = DomUtils.find.navigationContainer();
if (!navContainer) {
if (document.readyState !== 'complete' && document.readyState !== 'interactive') {
document.addEventListener('DOMContentLoaded', () => this.initializeToggleButton());
}
return;
}
const toggleButton = DomUtils.create.toggleButton();
toggleButton.addEventListener('click', this.toggleWarnings);
navContainer.appendChild(toggleButton);
},
toggleWarnings: function() {
this.classList.toggle('top-page-link-button--active');
this.classList.toggle('active');
const warningsEnabled = this.classList.contains('active');
localStorage.setItem(CONFIG.STORAGE_KEYS.WARNING_STATE, warningsEnabled);
const statusIndicator = document.getElementById(CONFIG.ELEMENT_IDS.WARNINGS_STATUS);
statusIndicator.textContent = warningsEnabled ? CONFIG.STYLES.STATUS_INDICATOR.ON.text : CONFIG.STYLES.STATUS_INDICATOR.OFF.text;
statusIndicator.style.color = warningsEnabled ? CONFIG.STYLES.STATUS_INDICATOR.ON.color : CONFIG.STYLES.STATUS_INDICATOR.OFF.color;
// Use the general applyWarningState method instead of duplicating logic here
ItemEffectsApp.applyWarningState();
},
setupObservers: function() {
const itemContainers = DomUtils.find.itemContainers();
State.observers.items = new MutationObserver(mutations => {
let newItemsAdded = false;
mutations.forEach(mutation => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
newItemsAdded = true;
}
});
if (newItemsAdded) {
this.processItems();
// Apply warning state after new items are processed
this.applyWarningState();
}
});
if (itemContainers.length > 0) {
itemContainers.forEach(container => {
State.observers.items.observe(container, CONFIG.OBSERVER_CONFIG);
});
} else {
this.setupBodyObserver();
}
},
setupBodyObserver: function() {
State.observers.body = new MutationObserver(mutations => {
const itemContainers = DomUtils.find.itemContainers();
if (itemContainers.length > 0) {
itemContainers.forEach(container => {
State.observers.items.observe(container, CONFIG.OBSERVER_CONFIG);
});
this.processItems();
this.initializeToggleButton();
// Apply warning state after container is found
this.applyWarningState();
State.observers.body.disconnect();
}
});
State.observers.body.observe(document.body, CONFIG.OBSERVER_CONFIG);
}
};
// =====================================================================
// INITIALIZATION
// =====================================================================
if (document.readyState === 'complete' || document.readyState === 'interactive') {
ItemEffectsApp.init();
} else {
document.addEventListener('DOMContentLoaded', () => ItemEffectsApp.init());
}
window.TornItemEffects = {
processItems: () => ItemEffectsApp.processItems(),
toggleWarnings: () => {
const warningToggleButton = document.getElementById(CONFIG.ELEMENT_IDS.WARNINGS_TOGGLE);
if (warningToggleButton) warningToggleButton.click();
}
};
})();