Sort items by value, show total value, hide equipped items, and sort by circulation. Works on inventory AND market add-items.
// ==UserScript==
// @name Torn — Sort items by value
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Sort items by value, show total value, hide equipped items, and sort by circulation. Works on inventory AND market add-items.
// @author Charkel [3429133]
// @match https://www.torn.com/item.php*
// @match https://www.torn.com/page.php?sid=ItemMarket*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const BUTTON_CONTAINER_ID = 'tm-sort-panel';
const TOTAL_ID = 'tm-total-value';
const TOTAL_PLACEHOLDER = 'Total value: (click a sort to calculate)';
const SORTED_OVERLAY_ID = 'tm-sorted-overlay';
const FORCE_ALL_KEY = 'tm-force-all-tab';
const HIDE_EQUIPPED_KEY = 'tm-hide-equipped';
function isForceAllChecked() {
const cb = document.getElementById('tm-force-all');
if (cb) return cb.checked;
try {
const v = localStorage.getItem(FORCE_ALL_KEY);
return v === null ? true : v === '1';
} catch (e) { return true; }
}
function isHideEquippedChecked() {
const cb = document.getElementById('tm-hide-equipped');
if (cb) return cb.checked;
try { return localStorage.getItem(HIDE_EQUIPPED_KEY) === '1'; } catch (e) { return false; }
}
let itemsFullyLoaded = false;
let hideEquipped = false;
let hasSorted = false;
let loadingOverlay;
let suppressGreenObserver = false;
// Set your Torn API key here to enable "Circulation" sorting.
const API_KEY = 'API_KEY_HERE'; // ENTER YOUR API KEY HERE (Limited access)
let circulationMap = null;
let marketCollectedItems = [];
let marketCollectedScopeKey = '';
const marketCacheByScope = new Map();
const groupedPriceCache = new Map();
function isMarketPage() {
return !!(
document.querySelector('[class*="virtualList"]') ||
location.pathname.includes('bazaar') ||
location.pathname.includes('imarket') ||
location.href.includes('ItemMarket')
);
}
// --- Styles ---
const style = document.createElement('style');
style.textContent = `
#${BUTTON_CONTAINER_ID} {
position: fixed;
top: 80px;
right: 20px;
z-index: 9998;
background: rgba(0,0,0,0.85);
border: 1px solid #333;
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
color: #acea00;
max-width: 220px;
user-select: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
#${BUTTON_CONTAINER_ID}.tm-dragging { opacity: 0.85; }
#${BUTTON_CONTAINER_ID} .tm-drag-handle {
cursor: move;
padding: 3px 6px;
margin: -6px -8px 4px -8px;
background: rgba(172, 234, 0, 0.18);
border-bottom: 1px solid #333;
border-radius: 4px 4px 0 0;
font-weight: 700;
color: #acea00;
display: flex;
align-items: center;
gap: 6px;
}
#${BUTTON_CONTAINER_ID} .tm-drag-handle::before {
content: "⠿";
opacity: 0.8;
}
#${BUTTON_CONTAINER_ID} .tm-sort-btn {
margin: 2px 3px;
padding: 3px 7px;
background: #acea00;
border: 1px solid #222;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
font-weight: 700;
color: #000;
}
#${BUTTON_CONTAINER_ID} .tm-sort-btn:hover { background: #c4ff33; }
#${BUTTON_CONTAINER_ID} .tm-total {
display: block;
margin-top: 4px;
font-size: 12px;
font-weight: 700;
color: #acea00;
}
#${BUTTON_CONTAINER_ID} .tm-status {
display: block;
margin-top: 2px;
font-size: 11px;
color: #888;
}
#${BUTTON_CONTAINER_ID} .tm-checkbox {
display: flex;
align-items: center;
gap: 5px;
margin-top: 5px;
font-size: 11px;
color: #bbb;
cursor: pointer;
user-select: none;
}
#${BUTTON_CONTAINER_ID} .tm-checkbox input {
cursor: pointer;
margin: 0;
}
.tm-loading-overlay {
position: fixed;
top: 0; left: 0;
width: 100vw; height: 100vh;
background: rgba(0,0,0,0.70);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
font-size: 36px;
font-weight: bold;
color: #fff;
text-shadow: 2px 2px 4px rgba(0,0,0,0.9);
pointer-events: none;
flex-direction: column;
}
.tm-loading-overlay .tm-loading-sub {
font-size: 16px;
margin-top: 10px;
color: #ccc;
}
.tm-hidden-green { display: none !important; }
/* Sorted overlay — compact centered panel */
#${SORTED_OVERLAY_ID} {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 560px;
max-width: 95vw;
max-height: 85vh;
background: #191919;
border: 1px solid #333;
border-radius: 5px;
z-index: 9997;
overflow-y: auto;
padding: 0;
box-sizing: border-box;
font-family: Arial, Helvetica, sans-serif;
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
}
#${SORTED_OVERLAY_ID}-backdrop {
position: fixed;
top: 0; left: 0;
width: 100vw; height: 100vh;
background: rgba(0,0,0,0.6);
z-index: 9996;
}
#${SORTED_OVERLAY_ID} .tm-overlay-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
position: sticky;
top: 0;
background: #242424;
border-bottom: 1px solid #333;
z-index: 2;
}
#${SORTED_OVERLAY_ID} .tm-overlay-title {
font-size: 13px;
font-weight: 700;
color: #ddd;
}
#${SORTED_OVERLAY_ID} .tm-overlay-close {
padding: 4px 12px;
background: #333;
color: #ccc;
border: 1px solid #555;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
#${SORTED_OVERLAY_ID} .tm-overlay-close:hover { background: #444; color: #fff; }
/* Item rows — match Torn's native listing rows */
#${SORTED_OVERLAY_ID} .tm-item-row {
display: flex;
align-items: center;
padding: 4px 10px;
min-height: 38px;
border-bottom: 1px solid #252525;
cursor: pointer;
font-size: 12px;
color: #ccc;
background: #1c1c1c;
transition: background 0.1s;
}
#${SORTED_OVERLAY_ID} .tm-item-row:nth-child(even) {
background: #202020;
}
#${SORTED_OVERLAY_ID} .tm-item-row:hover {
background: #2a2a2a;
}
/* Item image */
#${SORTED_OVERLAY_ID} .tm-item-img {
width: 40px;
height: 30px;
object-fit: contain;
margin-right: 10px;
flex-shrink: 0;
}
#${SORTED_OVERLAY_ID} .tm-item-img-placeholder {
width: 40px;
height: 30px;
margin-right: 10px;
flex-shrink: 0;
}
/* Item name */
#${SORTED_OVERLAY_ID} .tm-item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #ddd;
font-size: 12px;
}
/* Price RRP */
#${SORTED_OVERLAY_ID} .tm-item-rrp {
width: 120px;
text-align: right;
color: #ccc;
font-size: 12px;
flex-shrink: 0;
margin-right: 10px;
}
#${SORTED_OVERLAY_ID} .tm-item-rrp .tm-rrp-label {
color: #777;
font-size: 11px;
margin-left: 3px;
}
/* Navigate button */
#${SORTED_OVERLAY_ID} .tm-item-goto {
margin-left: 8px;
padding: 3px 10px;
background: #333;
border: 1px solid #444;
border-radius: 3px;
color: #acea00;
font-size: 11px;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
}
#${SORTED_OVERLAY_ID} .tm-item-goto:hover {
background: #444;
color: #c4ff33;
}
.tm-flash-highlight {
animation: tm-flash 1.5s ease-out;
}
@keyframes tm-flash {
0% { outline: 3px solid #acea00; outline-offset: 2px; background: rgba(172,234,0,0.25); }
100% { outline: 3px solid transparent; outline-offset: 2px; background: transparent; }
}
`;
document.head.appendChild(style);
function createButton(label, sortType) {
const btn = document.createElement('button');
btn.className = 'tm-sort-btn';
btn.textContent = label + (sortType ? ' ↓' : '');
if (sortType) btn.dataset.sortType = sortType;
btn.dataset.order = 'desc';
return btn;
}
const PANEL_POS_KEY = 'tm-sort-panel-pos';
function makePanelDraggable(panel, handle) {
let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0;
function onPointerDown(e) {
// Only left mouse / touch
if (e.button !== undefined && e.button !== 0) return;
dragging = true;
const rect = panel.getBoundingClientRect();
// Switch to left/top positioning so we can drag freely
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
startLeft = rect.left;
startTop = rect.top;
startX = e.clientX;
startY = e.clientY;
panel.classList.add('tm-dragging');
handle.setPointerCapture?.(e.pointerId);
e.preventDefault();
}
function onPointerMove(e) {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const maxLeft = window.innerWidth - panel.offsetWidth;
const maxTop = window.innerHeight - panel.offsetHeight;
const newLeft = Math.max(0, Math.min(maxLeft, startLeft + dx));
const newTop = Math.max(0, Math.min(maxTop, startTop + dy));
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
}
function onPointerUp(e) {
if (!dragging) return;
dragging = false;
panel.classList.remove('tm-dragging');
try {
localStorage.setItem(PANEL_POS_KEY, JSON.stringify({
left: panel.style.left, top: panel.style.top
}));
} catch (err) {}
}
handle.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
}
function restorePanelPosition(panel) {
try {
const saved = JSON.parse(localStorage.getItem(PANEL_POS_KEY) || 'null');
if (saved && saved.left && saved.top) {
panel.style.left = saved.left;
panel.style.top = saved.top;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
}
} catch (e) {}
}
function ensurePanel() {
let panel = document.getElementById(BUTTON_CONTAINER_ID);
if (!panel) {
panel = document.createElement('div');
panel.id = BUTTON_CONTAINER_ID;
const handle = document.createElement('div');
handle.className = 'tm-drag-handle';
handle.textContent = 'Item sorting';
handle.title = 'Drag to move';
panel.appendChild(handle);
const buttonsRow = document.createElement('div');
buttonsRow.className = 'tm-buttons-row';
panel.appendChild(buttonsRow);
const total = document.createElement('span');
total.id = TOTAL_ID;
total.className = 'tm-total';
total.textContent = TOTAL_PLACEHOLDER;
panel.appendChild(total);
const status = document.createElement('span');
status.className = 'tm-status';
status.id = 'tm-status';
panel.appendChild(status);
const forceLabel = document.createElement('label');
forceLabel.className = 'tm-checkbox';
forceLabel.title = 'When checked, sorting first switches to the "All" items tab so every item is included. Uncheck to sort only the currently visible category.';
const forceCb = document.createElement('input');
forceCb.type = 'checkbox';
forceCb.id = 'tm-force-all';
// Default: checked. Persisted across sessions only if user unchecks it.
const savedForce = (() => { try { return localStorage.getItem(FORCE_ALL_KEY); } catch (e) { return null; } })();
forceCb.checked = savedForce !== '0';
forceCb.addEventListener('change', () => {
try { localStorage.setItem(FORCE_ALL_KEY, forceCb.checked ? '1' : '0'); } catch (e) {}
// Reset cached market items so a category switch is reflected on next sort
marketCollectedItems = [];
marketCollectedScopeKey = '';
hasSorted = false;
updateTotalDisplay();
});
forceLabel.appendChild(forceCb);
forceLabel.appendChild(document.createTextNode('Force "All items" tab'));
panel.appendChild(forceLabel);
// Hide Equipped checkbox (inventory only — but harmless on market)
const hideLabel = document.createElement('label');
hideLabel.className = 'tm-checkbox';
hideLabel.title = 'Hide equipped items (highlighted green) from the inventory list.';
const hideCb = document.createElement('input');
hideCb.type = 'checkbox';
hideCb.id = 'tm-hide-equipped';
try { hideCb.checked = localStorage.getItem(HIDE_EQUIPPED_KEY) === '1'; } catch (e) { hideCb.checked = false; }
hideEquipped = hideCb.checked;
hideCb.addEventListener('change', () => {
hideEquipped = hideCb.checked;
try { localStorage.setItem(HIDE_EQUIPPED_KEY, hideCb.checked ? '1' : '0'); } catch (e) {}
applyGreenVisibility(document);
});
hideLabel.appendChild(hideCb);
hideLabel.appendChild(document.createTextNode('Hide equipped items'));
panel.appendChild(hideLabel);
document.body.appendChild(panel);
restorePanelPosition(panel);
makePanelDraggable(panel, handle);
}
return panel;
}
function getButtonsRow() {
const panel = ensurePanel();
let row = panel.querySelector('.tm-buttons-row');
if (!row) {
row = document.createElement('div');
row.className = 'tm-buttons-row';
panel.insertBefore(row, panel.querySelector('#' + TOTAL_ID));
}
return row;
}
function getTotalNode() {
ensurePanel();
return document.getElementById(TOTAL_ID);
}
function setStatus(msg) {
ensurePanel();
const el = document.getElementById('tm-status');
if (el) el.textContent = msg;
}
function showLoadingOverlay(msg, sub) {
if (!loadingOverlay) {
loadingOverlay = document.createElement('div');
loadingOverlay.className = 'tm-loading-overlay';
document.body.appendChild(loadingOverlay);
}
loadingOverlay.innerHTML = `<div>${msg || 'Loading…'}</div>` +
(sub ? `<div class="tm-loading-sub">${sub}</div>` : '');
loadingOverlay.style.display = 'flex';
}
function hideLoadingOverlay() {
if (loadingOverlay) loadingOverlay.style.display = 'none';
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function nextFrame() { return new Promise(resolve => requestAnimationFrame(() => resolve())); }
// =============================================
// MARKET: Collect items by scrolling
// =============================================
function getVirtualListContainer() {
const allPanelList = getVisibleAllItemsPanel()?.querySelector('[class*="virtualList"]');
if (allPanelList) return allPanelList;
return Array.from(document.querySelectorAll('[class*="virtualList"]')).find(el => {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
}) || document.querySelector('[class*="virtualList"]');
}
function getActiveMarketScopeKey() {
if (isForceAllChecked()) return 'force:all-items';
const activeTab = document.querySelector('li.ui-tabs-active, li.ui-state-active, li[aria-selected="true"]');
const tabType = activeTab?.getAttribute('data-type') || activeTab?.id || activeTab?.textContent?.trim();
const panelId = activeTab?.querySelector('a[href^="#"]')?.getAttribute('href') || location.hash;
return `tab:${tabType || panelId || 'visible'}:${panelId || ''}`;
}
function parseMarketPriceNode(priceEl) {
if (!priceEl) return 0;
const ariaLabel = priceEl.getAttribute('aria-label') || '';
let match = ariaLabel.match(/\$([\d,]+)/);
if (!match) {
match = (priceEl.textContent || '').match(/\$([\d,]+)/);
}
return match ? parseInt(match[1].replace(/,/g, ''), 10) : 0;
}
function getMarketListingName(listingEl) {
return listingEl.querySelector('[class*="name___"]')?.textContent?.trim() || '';
}
function getGroupedPriceCacheKey(listingEl, toggleBtn) {
const imgEl = listingEl.querySelector('img');
const imgSrc = imgEl?.getAttribute('src') || imgEl?.src || '';
const imgMatch = imgSrc.match(/\/images\/items\/(\d+)\//i);
if (imgMatch) return `item:${imgMatch[1]}`;
const panelId = toggleBtn?.getAttribute('aria-controls') || '';
const panelMatch = panelId.match(/itemInfo-(\d+)-/i);
if (panelMatch) return `item:${panelMatch[1]}`;
const name = getMarketListingName(listingEl);
return name ? `name:${name}` : '';
}
function getGroupedItemDebugName(listingEl, toggleBtn) {
const name = getMarketListingName(listingEl);
if (name) return name;
const ariaLabel = toggleBtn?.getAttribute('aria-label') || '';
const match = ariaLabel.match(/Expand\s+\d+\s+(.+?)\s+items?/i);
return match?.[1]?.trim() || ariaLabel || 'unknown item';
}
function getGroupedInfoScopes(listingEl) {
return [
listingEl,
listingEl.nextElementSibling,
listingEl.previousElementSibling,
listingEl.parentElement,
listingEl.parentElement?.nextElementSibling,
listingEl.parentElement?.previousElementSibling,
listingEl.parentElement?.parentElement,
document.body,
].filter((scope, index, arr) => scope && arr.indexOf(scope) === index);
}
function getGroupedInfoButton(listingEl) {
return listingEl.querySelector('button[aria-controls][aria-expanded], [class*="viewInfoButton"][aria-controls]');
}
function getGroupedInfoPanel(listingEl, toggleBtn) {
const panelId = toggleBtn?.getAttribute('aria-controls');
if (panelId) {
const byId = document.getElementById(panelId);
if (byId) return byId;
if (window.CSS && typeof window.CSS.escape === 'function') {
for (const scope of getGroupedInfoScopes(listingEl)) {
const scoped = scope?.querySelector?.(`#${window.CSS.escape(panelId)}`);
if (scoped) return scoped;
}
}
}
for (const scope of getGroupedInfoScopes(listingEl)) {
if (!scope?.querySelector) continue;
const panel = scope.querySelector('[id*="itemInfo"], [class*="additionalInfo"]');
if (panel) return panel;
}
return null;
}
function getGroupedInfoPrice(listingEl, toggleBtn) {
const panel = getGroupedInfoPanel(listingEl, toggleBtn);
if (panel) {
const priceNodes = panel.querySelectorAll('[class*="price___"], [aria-label*="Recommended Retail Price"]');
for (const node of priceNodes) {
const price = parseMarketPriceNode(node);
if (price) return price;
}
}
for (const scope of getGroupedInfoScopes(listingEl)) {
if (!scope?.querySelector) continue;
const info = scope.querySelector('[class*="additionalInfo"], [id*="itemInfo"]');
if (info) {
const price = parseMarketPriceNode(info.querySelector('[class*="price___"], [aria-label*="Recommended Retail Price"]'));
if (price) return price;
}
}
return 0;
}
async function waitForGroupedInfoPrice(listingEl, toggleBtn, timeout = 1500) {
const immediatePrice = getGroupedInfoPrice(listingEl, toggleBtn);
if (immediatePrice) return immediatePrice;
// Also try finding the panel by aria-controls ID directly on document
const panelId = toggleBtn?.getAttribute('aria-controls');
const start = performance.now();
while (performance.now() - start < timeout) {
// Check via normal scopes
const price = getGroupedInfoPrice(listingEl, toggleBtn);
if (price) return price;
// Direct panel lookup by ID on document
if (panelId) {
const panel = document.getElementById(panelId);
if (panel) {
const priceNode = panel.querySelector('[class*="price___"], [aria-label*="Recommended Retail Price"]');
const p = parseMarketPriceNode(priceNode);
if (p) return p;
}
}
if (!toggleBtn?.isConnected) break;
// Use setTimeout polling (50ms intervals) instead of rAF to avoid CPU burn
await sleep(50);
}
console.log('[TM Sort Items] Timeout waiting for price on:', getGroupedItemDebugName(listingEl, toggleBtn));
return 0;
}
async function getGroupedMarketPrice(listingEl, qty) {
const toggleBtn = getGroupedInfoButton(listingEl);
if (!toggleBtn || qty <= 1) return 0;
const cacheKey = getGroupedPriceCacheKey(listingEl, toggleBtn);
if (cacheKey && groupedPriceCache.has(cacheKey)) {
return groupedPriceCache.get(cacheKey) || 0;
}
const existingPrice = getGroupedInfoPrice(listingEl, toggleBtn);
if (existingPrice) {
if (cacheKey) groupedPriceCache.set(cacheKey, existingPrice);
return existingPrice;
}
const wasExpanded = toggleBtn.getAttribute('aria-expanded') === 'true';
if (!wasExpanded) {
toggleBtn.click();
await sleep(300); // Give Torn time to render the expanded panel
}
const hiddenPrice = await waitForGroupedInfoPrice(listingEl, toggleBtn);
if (hiddenPrice && cacheKey) {
groupedPriceCache.set(cacheKey, hiddenPrice);
}
// Collapse it back
if (!wasExpanded && toggleBtn.isConnected && toggleBtn.getAttribute('aria-expanded') === 'true') {
toggleBtn.click();
await sleep(100);
}
return hiddenPrice;
}
async function parseMarketListing(listingEl) {
const name = getMarketListingName(listingEl);
let qty = 1;
const titleEl = listingEl.querySelector('[class*="title___"]');
if (titleEl) {
const titleText = titleEl.textContent || '';
const m = titleText.match(/x\s*(\d+)/i);
if (m) qty = parseInt(m[1], 10);
}
const directPrice = parseMarketPriceNode(listingEl.querySelector('[class*="price___"]'));
const hiddenPrice = (!directPrice && qty > 1) ? await getGroupedMarketPrice(listingEl, qty) : 0;
const singleRrp = directPrice || hiddenPrice || 0;
const totalRrp = singleRrp > 0 ? singleRrp * qty : 0;
const rrp = singleRrp;
const hasPrice = singleRrp > 0 || totalRrp > 0;
let imgSrc = '';
const imgEl = listingEl.querySelector('img');
if (imgEl) {
imgSrc = imgEl.src || imgEl.getAttribute('src') || '';
}
const dataIndex = parseInt(listingEl.dataset.index || '0', 10);
const transformMatch = (listingEl.style.transform || '').match(/translateY\(([\d.]+)px\)/);
const translateY = transformMatch ? parseFloat(transformMatch[1]) : 0;
return { name, qty, rrp, singleRrp, totalRrp, hasPrice, imgSrc, dataIndex, translateY };
}
function fireMouseEvent(el, type) {
if (!el) return;
try {
const evt = new MouseEvent(type, { bubbles: true, cancelable: true, view: window, button: 0 });
el.dispatchEvent(evt);
} catch (e) { /* ignore */ }
}
function getVisibleAllItemsPanel() {
const panel = document.querySelector('#all-items');
if (!panel) return null;
const style = window.getComputedStyle(panel);
return style.display !== 'none' && style.visibility !== 'hidden' ? panel : null;
}
function runJqueryAllItemsTab(allTab) {
try {
const $ = window.jQuery || window.$;
if (!$ || !$.fn || !$.fn.tabs) return false;
const tabsRoot = $(allTab).closest('.ui-tabs');
const tabIndex = $(allTab).index();
if (!tabsRoot.length || tabIndex < 0) return false;
tabsRoot.tabs('option', 'active', tabIndex);
if (typeof tabsRoot.tabs('load') === 'function') tabsRoot.tabs('load', tabIndex);
return true;
} catch (e) {
console.log('[TM Sort Items] jQuery tab activation failed:', e);
return false;
}
}
function activateAllItemsDomFallback(allTab) {
const anchor = allTab.querySelector('a[href="#all-items"]') || allTab.querySelector('a') || allTab;
const panel = document.querySelector('#all-items');
const tabsRoot = allTab.closest('.ui-tabs') || document;
Array.from(tabsRoot.querySelectorAll('li[role="tab"], li.ui-tabs-tab, li[id*="categoriesItem"], li[data-type]')).forEach(tab => {
const selected = tab === allTab;
tab.classList.toggle('ui-tabs-active', selected);
tab.classList.toggle('ui-state-active', selected);
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
tab.setAttribute('tabindex', selected ? '0' : '-1');
});
if (anchor?.setAttribute) anchor.setAttribute('aria-selected', 'true');
if (panel) {
Array.from(tabsRoot.querySelectorAll('[role="tabpanel"], .ui-tabs-panel')).forEach(p => {
const selected = p.id === 'all-items';
p.hidden = !selected;
p.setAttribute('aria-hidden', selected ? 'false' : 'true');
p.style.display = selected ? '' : 'none';
});
panel.hidden = false;
panel.setAttribute('aria-hidden', 'false');
panel.style.display = '';
}
}
async function clickAllItemsTab() {
// Ensure the "All" category tab is active so every item is included.
// Torn uses jQuery UI tabs which often need full mouse events, not just .click().
const allTab = document.querySelector('#categoriesItem[data-type="All"]')
|| document.querySelector('li[data-type="All"]')
|| document.querySelector('a[href="#all-items"]')?.closest('li');
if (!allTab) {
console.log('[TM Sort Items] Could not find All items tab');
setStatus('All tab not found');
return false;
}
const visibleAllList = getVisibleAllItemsPanel()?.querySelector('[class*="virtualList"]');
const isActive = allTab.classList.contains('ui-tabs-active')
|| allTab.getAttribute('aria-selected') === 'true';
const isLoaded = allTab.getAttribute('data-loaded') === '1' || !!visibleAllList;
console.log('[TM Sort Items] All tab state before click:', {
active: isActive,
loaded: isLoaded,
dataLoaded: allTab.getAttribute('data-loaded'),
className: allTab.className
});
if ((isActive && isLoaded) || visibleAllList) return false;
const anchor = allTab.querySelector('a') || allTab;
runJqueryAllItemsTab(allTab);
if (anchor.hash === '#all-items' && location.hash !== '#all-items') {
try { history.replaceState(null, '', location.pathname + location.search + '#all-items'); } catch (e) { location.hash = 'all-items'; }
try { window.dispatchEvent(new HashChangeEvent('hashchange')); } catch (e) {}
}
// Fire a full mouse event sequence on both anchor and li
['mouseover', 'mouseenter', 'mousemove', 'mousedown', 'mouseup', 'click'].forEach(t => {
fireMouseEvent(anchor, t);
fireMouseEvent(allTab, t);
});
try { anchor.click(); } catch (e) {}
try { allTab.click(); } catch (e) {}
// Wait only as long as needed for the visible list to appear.
for (let i = 0; i < 10; i++) {
if (getVisibleAllItemsPanel()?.querySelector('[class*="virtualList"]')) break;
await sleep(25);
const activeNow = allTab.classList.contains('ui-tabs-active') || allTab.getAttribute('aria-selected') === 'true';
const loadedNow = allTab.getAttribute('data-loaded') === '1' || !!getVisibleAllItemsPanel()?.querySelector('[class*="virtualList"]');
if (activeNow && loadedNow) break;
}
if (!getVisibleAllItemsPanel()) activateAllItemsDomFallback(allTab);
return true;
}
async function collectAllMarketItems() {
const scopeKey = getActiveMarketScopeKey();
if (marketCacheByScope.has(scopeKey)) {
marketCollectedScopeKey = scopeKey;
return marketCacheByScope.get(scopeKey) || [];
}
if (isForceAllChecked()) await clickAllItemsTab();
const activeScopeKey = getActiveMarketScopeKey();
if (marketCacheByScope.has(activeScopeKey)) {
marketCollectedScopeKey = activeScopeKey;
return marketCacheByScope.get(activeScopeKey) || [];
}
const virtualList = getVirtualListContainer();
if (!virtualList) return [];
showLoadingOverlay('Loading items, please wait…');
const collected = new Map();
const processedKeys = new Set();
const listHeight = parseInt(virtualList.style.height) || 6800;
const scrollStep = 300;
const maxSteps = Math.ceil(listHeight / scrollStep) + 20;
window.scrollTo(0, 0);
await sleep(100);
let stableCount = 0, lastCollectedCount = 0;
for (let step = 0; step < maxSteps; step++) {
const listings = Array.from(virtualList.querySelectorAll('[class*="virtualListing"]'));
for (const el of listings) {
const rawIndex = el.dataset.index || '';
const transformKey = el.style.transform || '';
const rowKey = rawIndex || transformKey || ((el.querySelector('[class*="name___"]')?.textContent || '').trim() + '|' + (el.querySelector('[class*="title___"]')?.textContent || '').trim());
if (!rowKey || processedKeys.has(rowKey)) continue;
processedKeys.add(rowKey);
const parsed = await parseMarketListing(el);
if (parsed.name && !collected.has(parsed.dataIndex)) {
collected.set(parsed.dataIndex, parsed);
}
}
showLoadingOverlay('Loading items, please wait…');
if (collected.size === lastCollectedCount) {
stableCount++;
if (stableCount > 10) break;
} else {
stableCount = 0;
lastCollectedCount = collected.size;
}
window.scrollBy(0, scrollStep);
await sleep(150);
}
window.scrollTo(0, 0);
hideLoadingOverlay();
const items = Array.from(collected.values()).sort((a, b) => a.dataIndex - b.dataIndex);
marketCollectedScopeKey = activeScopeKey;
marketCacheByScope.set(activeScopeKey, items);
console.log(`[TM Sort Items] Collected ${items.length} market items`);
setStatus(`Collected ${items.length} items`);
return items;
}
// =============================================
// MARKET: Show sorted overlay & navigate to real item on click
// =============================================
function scrollToMarketItem(item) {
closeSortedOverlay();
const virtualList = getVirtualListContainer();
if (!virtualList) return;
// Estimate row height from the virtual list (default ~32px per row)
const allRows = virtualList.querySelectorAll('[class*="virtualListing"]');
let rowHeight = 32;
if (allRows.length >= 2) {
const t1 = parseFloat((allRows[0].style.transform || '').match(/translateY\(([\d.]+)/)?.[1] || '0');
const t2 = parseFloat((allRows[1].style.transform || '').match(/translateY\(([\d.]+)/)?.[1] || '0');
if (t2 > t1) rowHeight = t2 - t1;
}
// Calculate approximate scroll position from data-index
const virtualListRect = virtualList.getBoundingClientRect();
const listTop = window.scrollY + virtualListRect.top;
const estimatedY = listTop + (item.dataIndex * rowHeight);
const scrollTarget = Math.max(0, estimatedY - window.innerHeight / 2);
window.scrollTo({ top: scrollTarget, behavior: 'smooth' });
// Wait for scroll + virtual list to re-render, then find and highlight
function findAndHighlight(attempts = 0) {
if (attempts > 15) return;
const realNode = virtualList.querySelector(`[data-index="${item.dataIndex}"]`);
if (realNode) {
realNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
realNode.classList.remove('tm-flash-highlight');
void realNode.offsetWidth;
realNode.classList.add('tm-flash-highlight');
return;
}
// Also try matching by name
const listings = virtualList.querySelectorAll('[class*="virtualListing"]');
for (const el of listings) {
const nameEl = el.querySelector('[class*="name___"]');
if (nameEl && nameEl.textContent.trim() === item.name) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.remove('tm-flash-highlight');
void el.offsetWidth;
el.classList.add('tm-flash-highlight');
return;
}
}
setTimeout(() => findAndHighlight(attempts + 1), 200);
}
setTimeout(() => findAndHighlight(), 500);
}
function fmtPrice(n) {
return '$' + n.toLocaleString('en-US');
}
function showSortedOverlay(sortedItems, sortLabel) {
closeSortedOverlay();
const overlay = document.createElement('div');
overlay.id = SORTED_OVERLAY_ID;
// Header
const header = document.createElement('div');
header.className = 'tm-overlay-header';
const title = document.createElement('span');
title.className = 'tm-overlay-title';
title.textContent = `Sorted by ${sortLabel} — click any row to jump to it`;
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.className = 'tm-overlay-close';
closeBtn.textContent = '✕ Close';
closeBtn.addEventListener('click', closeSortedOverlay);
header.appendChild(closeBtn);
overlay.appendChild(header);
// Item rows — matching Torn's native layout
sortedItems.forEach((item) => {
const row = document.createElement('div');
row.className = 'tm-item-row';
// Item image
if (item.imgSrc) {
const img = document.createElement('img');
img.className = 'tm-item-img';
img.src = item.imgSrc;
img.alt = item.name;
row.appendChild(img);
} else {
const ph = document.createElement('div');
ph.className = 'tm-item-img-placeholder';
row.appendChild(ph);
}
// Item name with quantity
const nameSpan = document.createElement('span');
nameSpan.className = 'tm-item-name';
nameSpan.textContent = item.qty > 1 ? `${item.name} x${item.qty}` : item.name;
row.appendChild(nameSpan);
// Sorted value display
const rrpSpan = document.createElement('span');
rrpSpan.className = 'tm-item-rrp';
if (item.val) {
rrpSpan.textContent = fmtPrice(item.val);
} else if (item.hasPrice) {
rrpSpan.textContent = fmtPrice(item.rrp);
} else {
rrpSpan.textContent = '';
}
row.appendChild(rrpSpan);
// Go to item button
const goBtn = document.createElement('span');
goBtn.className = 'tm-item-goto';
goBtn.textContent = 'Go →';
row.appendChild(goBtn);
row.addEventListener('click', () => scrollToMarketItem(item));
overlay.appendChild(row);
});
// Close on Escape
overlay.addEventListener('keydown', e => { if (e.key === 'Escape') closeSortedOverlay(); });
overlay.tabIndex = -1;
// Add backdrop
const backdrop = document.createElement('div');
backdrop.id = SORTED_OVERLAY_ID + '-backdrop';
backdrop.addEventListener('click', closeSortedOverlay);
document.body.appendChild(backdrop);
document.body.appendChild(overlay);
overlay.focus();
}
function closeSortedOverlay() {
const el = document.getElementById(SORTED_OVERLAY_ID);
if (el) el.remove();
const bd = document.getElementById(SORTED_OVERLAY_ID + '-backdrop');
if (bd) bd.remove();
}
// =============================================
// INVENTORY: Original logic
// =============================================
async function loadAllItemsInventory() {
if (itemsFullyLoaded) return;
showLoadingOverlay('Loading items, please wait…');
const start = Date.now(), MAX_MS = 25000;
return new Promise(resolve => {
let lastHeight = 0, sameHeightCount = 0, attempts = 0, maxAttempts = 120;
function finish() { itemsFullyLoaded = true; hideLoadingOverlay(); resolve(); }
function scrollStep() {
attempts++;
window.scrollTo(0, document.body.scrollHeight);
const newHeight = document.body.scrollHeight;
if (newHeight === lastHeight) {
sameHeightCount++;
if (sameHeightCount > 5 || attempts > maxAttempts || (Date.now() - start) > MAX_MS) return finish();
} else { sameHeightCount = 0; lastHeight = newHeight; }
setTimeout(scrollStep, 300);
}
scrollStep();
});
}
function parsePriceElem(priceElem) {
if (!priceElem) return { single: 0, total: 0, qty: 0 };
const rawText = priceElem.textContent.replace(/\u00A0/g, ' ').trim();
if (/N\/A/i.test(rawText)) return { single: 0, total: 0, qty: 0 };
const numTokens = (rawText.match(/\d[\d,]*/g) || []).map(s => parseInt(s.replace(/,/g, ''), 10));
let qty = 0;
const qtySpan = priceElem.querySelector('.tt-item-quantity');
const qtyMatch = (qtySpan && qtySpan.textContent.match(/(\d+)/)) || rawText.match(/(\d+)\s*x/i);
if (qtyMatch) qty = parseInt(qtyMatch[1], 10);
if (qty) {
const total = numTokens[numTokens.length - 1] || 0;
let single = numTokens.find(n => n !== qty && n !== total) || 0;
if (!single && total && qty) single = Math.round(total / qty);
return { single, total, qty };
}
if (!numTokens.length) return { single: 0, total: 0, qty: 0 };
if (numTokens.length === 1) return { single: numTokens[0], total: numTokens[0], qty: 0 };
const max = Math.max(...numTokens);
const min = Math.min(...numTokens);
if (max % min === 0 && (max / min) <= 1000) return { single: min, total: max, qty: Math.round(max / min) };
return { single: numTokens[0], total: max, qty: 0 };
}
function isVisible(el) { return !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length)); }
function getVisibleItemContainers() {
let containers = Array.from(document.querySelectorAll('.items-cont, .itemsList, ul.items-cont, ul.itemsList')).filter(isVisible);
if (!containers.length) {
containers = Array.from(document.querySelectorAll('ul')).filter(u => /-items$/.test(u.id) && isVisible(u));
}
return containers;
}
async function getCirculationMap() {
if (circulationMap) return circulationMap;
if (!API_KEY) {
setStatus('Circulation: no API key set in script');
console.warn('[TM Sort] Circulation requires API_KEY constant at top of script.');
return null;
}
try {
setStatus('Fetching circulation data…');
const res = await fetch(`https://api.torn.com/torn/?selections=items&key=${API_KEY}`);
const data = await res.json();
if (data.error) {
setStatus(`Circulation API error: ${data.error.error || data.error}`);
console.warn('[TM Sort] Torn API error:', data.error);
return null;
}
const map = {};
if (data.items) Object.values(data.items).forEach(item => { if (item?.name) map[item.name] = item.circulation || 0; });
circulationMap = map;
console.log('[TM Sort] Circulation map loaded:', Object.keys(map).length, 'items');
return map;
} catch (e) {
setStatus('Circulation fetch failed (network)');
console.warn('[TM Sort] Circulation fetch failed:', e);
return null;
}
}
async function sortInventoryLists(sortType, order) {
const containers = getVisibleItemContainers();
let cMap = null;
if (sortType === 'circulation') {
cMap = await getCirculationMap();
if (!cMap) return false;
}
containers.forEach(container => {
const items = Array.from(container.children).filter(el => el.tagName === 'LI');
if (!items.length) return;
const pairs = items.map((li, i) => {
let val = 0;
if (sortType === 'single' || sortType === 'total') {
const priceElem = li.querySelector('.tt-item-price');
const parsed = parsePriceElem(priceElem);
val = sortType === 'single' ? parsed.single : parsed.total;
} else if (sortType === 'circulation') {
const nameElem = li.querySelector('.tt-item-name') || li.querySelector('.name-wrap .name') || li.querySelector('.title-wrap .name') || li.querySelector('.name');
const itemName = nameElem ? nameElem.textContent.trim() : '';
val = (itemName && cMap && Object.prototype.hasOwnProperty.call(cMap, itemName)) ? cMap[itemName] : 0;
}
return { li, val, index: i };
});
pairs.sort((a, b) => {
if (a.val === b.val) return a.index - b.index;
return order === 'desc' ? b.val - a.val : a.val - b.val;
});
pairs.forEach(p => container.appendChild(p.li));
});
}
// === Sort for MARKET ===
async function sortMarket(sortType, order) {
const scopeKey = getActiveMarketScopeKey();
if (isForceAllChecked() && marketCacheByScope.has(scopeKey)) clickAllItemsTab();
if (marketCollectedScopeKey !== scopeKey) {
marketCollectedItems = marketCacheByScope.get(scopeKey) || [];
if (marketCollectedItems.length) marketCollectedScopeKey = scopeKey;
}
if (!marketCollectedItems.length) {
marketCollectedItems = await collectAllMarketItems();
}
if (!marketCollectedItems.length) { setStatus('No items found!'); return; }
let cMap = null;
if (sortType === 'circulation') {
cMap = await getCirculationMap();
if (!cMap) return;
}
const sortable = marketCollectedItems.map((item, i) => {
let val = 0;
if (sortType === 'rrp' || sortType === 'single') val = item.singleRrp || item.rrp;
else if (sortType === 'total') val = item.totalRrp || ((item.singleRrp || item.rrp) * item.qty);
else if (sortType === 'qty') val = item.qty;
else if (sortType === 'circulation') val = (item.name && cMap && cMap[item.name]) ? cMap[item.name] : 0;
return { ...item, val, origIndex: i };
});
sortable.sort((a, b) => {
if (a.val === b.val) return a.origIndex - b.origIndex;
return order === 'desc' ? b.val - a.val : a.val - b.val;
});
const labelMap = { total: 'Total Value', single: 'Single Value', circulation: 'Circulation', qty: 'Quantity' };
const sortLabel = labelMap[sortType] || sortType;
showSortedOverlay(sortable, sortLabel);
setStatus(`Sorted ${sortable.length} items by ${sortLabel} (${order})`);
}
async function sortVisibleLists(sortType, order) {
if (isMarketPage()) await sortMarket(sortType, order);
else await sortInventoryLists(sortType, order);
}
function formatNum(n) { return n.toLocaleString('en-US'); }
function isFactionItem(li) {
return !!li.querySelector('.option-return-to-faction, .return, [data-action="return"], [data-type="armoury"], [data-armoryid]');
}
function computeTotalValue() {
if (isMarketPage()) {
let sum = 0;
marketCollectedItems.forEach(item => { if (item.hasPrice) sum += item.totalRrp || ((item.singleRrp || item.rrp) * item.qty); });
return sum;
}
const containers = getVisibleItemContainers();
let sum = 0;
containers.forEach(container => {
Array.from(container.children).filter(el =>
el.tagName === 'LI' && isVisible(el) && !el.classList.contains('tm-hidden-green') && !isFactionItem(el)
).forEach(li => {
const { total } = parsePriceElem(li.querySelector('.tt-item-price'));
if (Number.isFinite(total)) sum += total || 0;
});
});
return sum;
}
function updateTotalDisplay() {
const node = getTotalNode();
if (!node) return;
if (!hasSorted) { node.textContent = TOTAL_PLACEHOLDER; return; }
node.textContent = `Total value: $${formatNum(computeTotalValue())}`;
}
function applyGreenVisibility(scope = document) {
suppressGreenObserver = true;
scope.querySelectorAll('li.bg-green').forEach(el => el.classList.toggle('tm-hidden-green', hideEquipped));
suppressGreenObserver = false;
updateTotalDisplay();
}
function addSortButtonsOnce() {
const panel = ensurePanel();
const row = getButtonsRow();
if (row.dataset.tmButtonsAdded === '1') return;
const onMarket = isMarketPage();
const btnSortA = createButton('Total Value', 'total');
const btnSortB = createButton('Single Value', 'single');
const btnCirc = createButton('Circulation', 'circulation');
const sortButtons = [btnSortA, btnSortB, btnCirc];
if (onMarket) sortButtons.push(createButton('Quantity', 'qty'));
async function onSortClick(clickedBtn) {
const sortType = clickedBtn.dataset.sortType;
const currentOrder = clickedBtn.dataset.order || 'desc';
sortButtons.forEach(btn => {
if (btn === clickedBtn) return;
btn.dataset.order = 'desc';
btn.textContent = btn.textContent.replace(/↓|↑/, '↓');
});
if (isForceAllChecked() && !onMarket) await clickAllItemsTab();
if (!onMarket) await loadAllItemsInventory();
await sortVisibleLists(sortType, currentOrder);
hasSorted = true;
updateTotalDisplay();
const newOrder = currentOrder === 'desc' ? 'asc' : 'desc';
clickedBtn.dataset.order = newOrder;
clickedBtn.textContent = clickedBtn.textContent.replace(/↓|↑/, newOrder === 'desc' ? '↓' : '↑');
if (!onMarket) window.scrollTo(0, 0);
}
sortButtons.forEach(btn => btn.addEventListener('click', () => onSortClick(btn)));
sortButtons.forEach(btn => row.appendChild(btn));
row.dataset.tmButtonsAdded = '1';
updateTotalDisplay();
}
// Observers
let totalRaf = 0;
const main = document.querySelector('#mainContainer') || document.documentElement;
new MutationObserver(mutations => {
if (mutations.every(m => {
const tgt = m.target instanceof Element ? m.target : null;
const panel = tgt && tgt.closest && tgt.closest('#' + BUTTON_CONTAINER_ID);
return panel || (tgt && tgt.id === TOTAL_ID);
})) return;
if (totalRaf) return;
totalRaf = requestAnimationFrame(() => { totalRaf = 0; updateTotalDisplay(); });
}).observe(main, { childList: true, subtree: true, characterData: true });
new MutationObserver(() => addSortButtonsOnce()).observe(main, { childList: true, subtree: true });
new MutationObserver(mutations => {
if (suppressGreenObserver || !hideEquipped) return;
for (const m of mutations) {
if (!m.addedNodes?.length) continue;
m.addedNodes.forEach(node => {
if (!(node instanceof Element)) return;
if (node.matches?.('li.bg-green')) applyGreenVisibility(node.parentElement || document);
else if (node.querySelectorAll?.('li.bg-green').length) applyGreenVisibility(node);
});
}
}).observe(main, { childList: true, subtree: true });
addSortButtonsOnce();
})();