Torn Display Case - Inline Diff Robust

Fetch display via your Torn API key and show inline diff from top item. Read-only.

// ==UserScript==
// @name         Torn Display Case - Inline Diff Robust
// @namespace    nova.displaycase.inline.robust
// @version      1.46
// @description  Fetch display via your Torn API key and show inline diff from top item. Read-only.
// @match        https://www.torn.com/displaycase.php*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEY = 'tdc_api_key_v1';
  GM_registerMenuCommand('Torn Display: Set API key', setKey);
  GM_registerMenuCommand('Torn Display: Clear API key', clearKey);

  function setKey() {
    const cur = GM_getValue(STORAGE_KEY, '') || '';
    const k = prompt('Paste your Torn API key (Display permission only):', cur);
    if (k && k.trim()) {
      GM_setValue(STORAGE_KEY, k.trim());
      alert('API key saved locally.');
      fetchAndApply();
    }
  }
  function clearKey() {
    GM_setValue(STORAGE_KEY, '');
    alert('API key cleared. Reload page to remove labels.');
    removeAllLabels();
  }

  function getKey() {
    return GM_getValue(STORAGE_KEY, '') || null;
  }

  function showOverlay(msg, timeout = 3000) {
    let o = document.getElementById('tdc-overlay');
    if (!o) {
      o = document.createElement('div');
      o.id = 'tdc-overlay';
      o.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:99999;background:#222;color:#fff;padding:8px 10px;border-radius:6px;font-size:12px;';
      document.body.appendChild(o);
    }
    o.textContent = msg;
    if (timeout) setTimeout(() => { const e = document.getElementById('tdc-overlay'); if (e) e.remove(); }, timeout);
  }

  function fetchDisplay(key) {
    return new Promise((resolve, reject) => {
      if (!key) return reject('No API key set. Use menu to set it.');

      const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload(res) {
          try {
            if (res.status !== 200) return reject(`HTTP ${res.status}`);
            const json = JSON.parse(res.responseText);
            if (!json || !json.display) return reject('No display data. Check API key permissions.');
            resolve(json.display);
          } catch (e) {
            reject('Failed parsing API response: ' + (e.message || e));
          }
        },
        onerror(err) { reject('Network error'); }
      });
    });
  }

  // Build name -> totalQuantity map from API display data
  function buildQuantityMap(displayObj) {
    const map = new Map();
    Object.values(displayObj).forEach(it => {
      if (!it) return;
      const name = (it.name || (it.info && it.info.name) || String(it.id || '')).trim();
      const qty = parseInt(it.quantity || 0, 10) || 0;
      map.set(name, (map.get(name) || 0) + qty);
    });
    return map;
  }

  // Remove previous labels
  function removeAllLabels() {
    document.querySelectorAll('.tdc-inline-diff').forEach(n => n.remove());
    const s = document.getElementById('tdc-summary'); if (s) s.remove();
  }

  // Heuristic: find candidate item card nodes
  function collectItemNodes() {
    const selectors = [
      '.display-case-item', '.case-item', '.item', '.item-wrap', '.display-item', '.case-items li', '.case-grid > div'
    ];
    let nodes = [];
    selectors.forEach(sel => document.querySelectorAll(sel)).forEach(list => {
      list.forEach(n => nodes.push(n));
    });

    // fallback: any element with an <img> child and text containing "x" and digits nearby
    if (nodes.length === 0) {
      const divs = Array.from(document.querySelectorAll('div, li'));
      divs.forEach(d => {
        if (d.querySelector('img')) nodes.push(d);
      });
    }

    // unique
    nodes = Array.from(new Set(nodes));
    return nodes;
  }

  // Try to extract item name from a node
  function extractNameFromNode(node) {
    // common property locations
    const titleSelectors = ['.title', '.item-name', '.name', '.display-name', '.item-title', 'h4', 'a'];
    for (const s of titleSelectors) {
      const el = node.querySelector(s);
      if (el && el.textContent.trim()) return el.textContent.trim();
    }
    // try img alt
    const img = node.querySelector('img[alt]');
    if (img) {
      const alt = img.getAttribute('alt').trim();
      if (alt) return alt;
    }
    // fallback: try text content like "Camel Plushie x814"
    const txt = (node.textContent || '').trim();
    const m = txt.match(/([A-Za-z0-9\-\s'’,:().]+?)\s+x\s?(\d+)/);
    if (m) return m[1].trim();
    // last-resort: short text snippet
    const words = txt.split('\n').map(s=>s.trim()).filter(Boolean);
    if (words.length) return words[0].slice(0, 60).trim();
    return null;
  }

  // Finds best matching name in map (exact then case-insensitive then substring)
  function findBestMatch(name, map) {
    if (!name) return null;
    if (map.has(name)) return name;
    const lower = name.toLowerCase();
    for (const key of map.keys()) {
      if (key.toLowerCase() === lower) return key;
    }
    for (const key of map.keys()) {
      if (lower.includes(key.toLowerCase()) || key.toLowerCase().includes(lower)) return key;
    }
    return null;
  }

  // Insert inline diff into a node
  function insertInline(node, diff) {
    // avoid duplication
    if (node.querySelector('.tdc-inline-diff')) return;
    const wrap = document.createElement('div');
    wrap.className = 'tdc-inline-diff';
    wrap.textContent = (diff > 0 ? '+' : '') + diff;
    wrap.style.cssText = `
      font-size:11px;
      color:${diff === 0 ? '#aaa' : diff < 0 ? '#ff6b6b' : '#8bc34a'};
      text-align:center;
      margin-top:4px;
      pointer-events:none;
    `;
    // try to append to a small info area if exists
    const infoSelectors = ['.item-description', '.description', '.item-info', '.info', '.case-item-info', '.meta'];
    for (const s of infoSelectors) {
      const el = node.querySelector(s);
      if (el) { el.appendChild(wrap); return; }
    }
    // otherwise append at end of node
    node.appendChild(wrap);
  }

  // Insert top summary near the title (optional)
  function insertSummary(topNames, qty) {
    if (document.getElementById('tdc-summary')) return;
    const s = document.createElement('div');
    s.id = 'tdc-summary';
    s.textContent = `Highest: ${topNames.join(', ')} (${qty})`;
    s.style.cssText = 'color:#4caf50;font-size:13px;font-weight:600;margin:8px 0;text-align:center;';
    const title = document.querySelector('.title-black, .content-title, h4, .title');
    if (title && title.parentNode) title.after(s);
    else document.body.prepend(s);
  }

  // Main application: match nodes to API map and inject labels
  function applyDisplayMap(map) {
    removeAllLabels();
    if (!map || map.size === 0) { showOverlay('No display items from API', 3000); return; }

    // compute max qty
    const maxQty = Math.max(...Array.from(map.values()));
    const topNames = [...map.entries()].filter(([_, q]) => q === maxQty).map(([n]) => n);

    insertSummary(topNames, maxQty);

    const nodes = collectItemNodes();
    if (!nodes || nodes.length === 0) {
      showOverlay('No item nodes found in DOM', 3000);
      return;
    }

    nodes.forEach(node => {
      try {
        const name = extractNameFromNode(node);
        const best = findBestMatch(name, map);
        if (!best) return;
        const qty = map.get(best) || 0;
        const diff = qty - maxQty;
        insertInline(node, diff);
      } catch (e) {
        // ignore per-node errors
      }
    });
  }

  // Fetch + apply flow
  async function fetchAndApply() {
    const key = getKey();
    if (!key) { showOverlay('No API key. Use menu to set.', 4000); return; }
    showOverlay('Fetching display data...');
    try {
      const display = await fetchDisplay(key);
      const qmap = buildQuantityMap(display);
      applyDisplayMap(qmap);
      showOverlay('Display diffs applied', 1500);
    } catch (err) {
      console.error('TDC error', err);
      showOverlay('Error: ' + String(err), 5000);
    }
  }

  // Observe page changes and re-run when display case appears or changes
  const pageObserver = new MutationObserver((mut) => {
    if (window._tdc_debounce) clearTimeout(window._tdc_debounce);
    window._tdc_debounce = setTimeout(() => {
      // only trigger on displaycase page or if display-like container exists
      if (location.pathname.includes('displaycase.php') || document.querySelector('.display-items, #displayCaseItems, .display-case')) {
        fetchAndApply();
      }
    }, 500);
  });
  pageObserver.observe(document.documentElement, { childList: true, subtree: true });

  // initial attempt
  setTimeout(fetchAndApply, 1000);

})();