ArmA Reforger Workshop - Preset Maker

Create mod presets to use with Reforger server configs.

// ==UserScript==
// @name         ArmA Reforger Workshop - Preset Maker
// @namespace    reforger_preset_maker
// @version      1.2
// @description  Create mod presets to use with Reforger server configs.
// @author       JJayRex
// @match        https://reforger.armaplatform.com/workshop*
// @grant        none
// @run-at       document-idle
// @license      GPL-3.0-or-later
// ==/UserScript==
(function () {
  'use strict';

  const STORAGE_KEY = 'reforgerPreset';

  // Storage helpers
  function loadMods() {
    try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
    catch { return []; }
  }
  function saveMods(mods) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(mods));
    refreshPanel();
  }
  function upsertMod(mod) {
    const mods = loadMods();
    const idx = mods.findIndex(m => m.modId === mod.modId);
    if (idx >= 0) mods[idx] = mod; else mods.push(mod);
    saveMods(mods);
  }
  function removeMod(modId) {
    const mods = loadMods().filter(m => m.modId !== modId);
    saveMods(mods);
  }

  // UI
  function ensureUI() {
    if (document.getElementById('presetToggle')) return;

    const btn = document.createElement('button');
    btn.id = 'presetToggle';
    btn.textContent = 'Preset';
    Object.assign(btn.style, {
      position: 'fixed', bottom: '10px', right: '20px', zIndex: '999999',
      background: '#0d6efd', color: '#fff', border: 'none',
      padding: '6px 10px', borderRadius: '6px', cursor: 'pointer',
      fontSize: '14px', boxShadow: '0 2px 8px rgba(0,0,0,.3)'
    });
    document.body.appendChild(btn);

    const panel = document.createElement('div');
    panel.id = 'presetPanel';
    Object.assign(panel.style, {
      display: 'none', position: 'fixed', bottom: '60px', right: '20px',
      zIndex: '999999', background: 'rgba(20,20,20,.95)', color: '#eee',
      padding: '12px', border: '1px solid #444', borderRadius: '8px',
      maxHeight: '75vh', overflowY: 'auto', width: '460px',
      boxShadow: '0 8px 24px rgba(0,0,0,.4)', backdropFilter: 'blur(2px)'
    });

    const header = document.createElement('div');
    header.style.display = 'flex';
    header.style.gap = '8px';
    header.style.alignItems = 'center';
    header.style.marginBottom = '8px';

    const title = document.createElement('strong');
    title.textContent = 'Mod Preset';
    title.style.flex = '1';

    const clearBtn = document.createElement('button');
    clearBtn.textContent = 'Clear all';
    Object.assign(clearBtn.style, smallBtnStyle('#6c757d'));

    const closeBtn = document.createElement('button');
    closeBtn.textContent = 'Close';
    Object.assign(closeBtn.style, smallBtnStyle('#dc3545'));

    header.appendChild(title);
    header.appendChild(clearBtn);
    header.appendChild(closeBtn);

    const listWrap = document.createElement('div');
    listWrap.id = 'modListWrap';
    listWrap.style.marginBottom = '10px';
    listWrap.style.border = '1px solid #333';
    listWrap.style.borderRadius = '6px';
    listWrap.style.padding = '8px';
    listWrap.style.background = '#1d1f20';

    const jsonTitle = h3('JSON');
    const jsonArea = ta('modJsonView', 140);

    const strTitle = h3('STRING');
    const strArea = ta('modStringView', 60);
    strArea.setAttribute('wrap', 'off');
    strArea.style.overflowX = 'auto';

    panel.append(header, listWrap, jsonTitle, jsonArea, strTitle, strArea);
    document.body.appendChild(panel);

    btn.addEventListener('click', () => {
      panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
      if (panel.style.display === 'block') refreshPanel();
    });
    closeBtn.addEventListener('click', () => panel.style.display = 'none');
    clearBtn.addEventListener('click', () => {
      if (confirm('Clear entire mod preset?')) saveMods([]);
    });
  }

  function smallBtnStyle(bg) {
    return {
      background: bg, color: '#fff', border: 'none', padding: '4px 8px',
      borderRadius: '6px', cursor: 'pointer', fontSize: '12px'
    };
  }
  function h3(text) {
    const el = document.createElement('h3');
    el.textContent = text;
    el.style.margin = '8px 0 4px 0';
    el.style.fontSize = '14px';
    return el;
  }
  function ta(id, heightPx) {
    const el = document.createElement('textarea');
    el.id = id; el.readOnly = true;
    Object.assign(el.style, {
      width: '100%', height: `${heightPx}px`, fontFamily: 'monospace',
      fontSize: '12px', background: '#111', color: '#eee',
      border: '1px solid #333', borderRadius: '6px', padding: '6px'
    });
    return el;
  }

  function refreshPanel() {
    const panel = document.getElementById('presetPanel');
    if (!panel) return;

    const mods = loadMods();

    // List
    const listWrap = document.getElementById('modListWrap');
    listWrap.innerHTML = '';
    if (mods.length === 0) {
      const empty = document.createElement('div');
      empty.textContent = 'No mods added yet.';
      empty.style.opacity = '0.8';
      listWrap.appendChild(empty);
    } else {
      for (const m of mods) {
        const row = document.createElement('div');
        row.style.display = 'grid';
        row.style.gridTemplateColumns = '1fr auto';
        row.style.alignItems = 'center';
        row.style.gap = '6px';
        row.style.padding = '4px 0';
        const info = document.createElement('div');
        info.innerHTML = `<div><b>${escapeHtml(m.name || '')}</b></div>
                              <div style="opacity:.8">ID: ${escapeHtml(m.modId)}</div>`;
        const rm = document.createElement('button');
        rm.textContent = 'Remove';
        Object.assign(rm.style, smallBtnStyle('#dc3545'));
        rm.addEventListener('click', () => removeMod(m.modId));
        row.append(info, rm);
        listWrap.appendChild(row);
        const hr = document.createElement('hr');
        hr.style.border = 'none'; hr.style.borderTop = '1px solid #2b2b2b';
        listWrap.appendChild(hr);
      }
      if (listWrap.lastChild && listWrap.lastChild.tagName === 'HR') listWrap.removeChild(listWrap.lastChild);
    }

    // JSON view
    const jsonArea = document.getElementById('modJsonView');
    jsonArea.value = JSON.stringify(mods, null, 2);

    // STRING view
    const ids = mods.map(m => m.modId).filter(Boolean);
    document.getElementById('modStringView').value = `-addon ${ids.join(', ')}`;
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, m => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[m]));
  }

  // Add button
  function isModPage() {
    return /\/workshop\/[0-9A-Fa-f]{16}(?:-|$)/.test(location.pathname);
  }

  function getIdFromUrl() {
    const m = location.pathname.match(/\/workshop\/([0-9A-Fa-f]{16})/);
    return m ? m[1].toUpperCase() : '';
  }

  async function ensureAddButton() {
    if (!isModPage()) return;

    // Avoid duplicates
    if (document.getElementById('addToPresetBtn')) return;

    // Wait for H1
    const h1 = await waitForElm('main h1, main section h1', 8000).catch(() => null);

    // Create button
    const addBtn = document.createElement('button');
    addBtn.id = 'addToPresetBtn';
    addBtn.textContent = 'Add to Preset';
    Object.assign(addBtn.style, smallBtnStyle('#28a745'));
    addBtn.style.fontSize = '13px';
    addBtn.style.margin = '6px 0';

    // Place near top
    const anchor = h1 || document.querySelector('main section, main');
    if (h1 && h1.parentNode) {
      h1.parentNode.insertBefore(addBtn, h1.nextSibling);
    } else if (anchor) {
      anchor.insertBefore(addBtn, anchor.firstChild);
    } else {
      document.body.insertBefore(addBtn, document.body.firstChild);
    }

    addBtn.addEventListener('click', async () => {
      addBtn.disabled = true;
      const orig = addBtn.textContent;
      try {
        const data = await scrapeModData();
        if (!data.modId) throw new Error('Mod ID not found');
        upsertMod(data);

        const deps = collectDependencies();
        let msg = 'Added!';
        if (deps.length > 0) {
          deps.forEach(d => upsertMod(d));
          msg = `Added (+${deps.length} deps)`;
        }

        addBtn.textContent = 'Added!';
        setTimeout(() => addBtn.textContent = orig, 1500);
      } catch (e) {
        console.warn(e);
        addBtn.textContent = 'Failed (see console)';
        setTimeout(() => addBtn.textContent = orig, 2000);
      } finally {
        addBtn.disabled = false;
      }
    });
  }

  async function scrapeModData() {
    const h1 = document.querySelector('main h1, main section h1');
    const name = (h1?.textContent || '').trim();

    let modId = '';
    // Try semantic pass
    document.querySelectorAll('dl, div').forEach(dl => {
      // Only inspect blocks that look like info rows
      const rows = dl.querySelectorAll(':scope > div');
      rows.forEach(div => {
        const dt = div.querySelector('dt');
        const dd = div.querySelector('dd');
        if (!dt || !dd) return;
        const label = dt.textContent.trim().toLowerCase();
        if (label === 'id') {
          const span = dd.querySelector('span');
          modId = (span ? span.textContent : dd.textContent).trim();
        }
      });
    });

    // Fallbacks
    if (!modId) modId = getIdFromUrl();
    // Normalize
    modId = (modId || '').toUpperCase();

    return { modId, name };
  }

  // Dependencies
  function xpevalFirst(xpath) {
    try {
      return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    } catch { return null; }
  }

  // Try the user-given container first
  function findDependenciesContainer() {
    const exact = xpevalFirst('/html/body/div[1]/div/main/div/section/div[2]/div[2]/section/div');
    return exact;
  }

  function collectDependencies() {
    const container = findDependenciesContainer();
    if (!container) return [];

    const anchors = Array.from(container.querySelectorAll('a[href^="/workshop/"][href*="-"]'));
    const currentId = getIdFromUrl();
    const results = [];

    for (const a of anchors) {
      const href = a.getAttribute('href') || '';
      const m = href.match(/\/workshop\/([0-9A-Fa-f]{16})-([^/?#]+)/);
      if (!m) continue;
      const modId = m[1].toUpperCase();
      if (modId === currentId) continue; // skip self
      const slug = decodeURIComponent(m[2]);
      const name = deriveNameFromAnchor(a, slug);
      results.push({ modId, name });
    }
    return uniqBy(results, x => x.modId);
  }

  function deriveNameFromAnchor(a, slug) {
    // Prefer visible text on the dependency card/link
    let txt = (a.innerText || a.textContent || '').trim();
    if (txt) {
      // Take first non-empty line (cards often have multiple lines)
      const firstLine = txt.split('\n').map(s => s.trim()).filter(Boolean)[0];
      if (firstLine) return firstLine;
    }
    // Fallback: guess from slug: underscores -> spaces, keep hyphens (e.g., UH-60)
    let s = (slug || '').replace(/_/g, ' ');
    // Insert spaces for CamelCase boundaries: ProjectRedline -> Project Redline
    s = s.replace(/([a-z])([A-Z])/g, '$1 $2');
    return s.trim();
  }

  function uniqBy(arr, keyFn) {
    const seen = new Set();
    const out = [];
    for (const it of arr) {
      const k = keyFn(it);
      if (!seen.has(k)) { seen.add(k); out.push(it); }
    }
    return out;
  }

  // Utils
  function waitForElm(selector, timeoutMs = 5000) {
    return new Promise((resolve, reject) => {
      const found = document.querySelector(selector);
      if (found) return resolve(found);

      const obs = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el) {
          obs.disconnect();
          resolve(el);
        }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });

      if (timeoutMs > 0) {
        setTimeout(() => {
          obs.disconnect();
          reject(new Error(`Timeout waiting for ${selector}`));
        }, timeoutMs);
      }
    });
  }

  // Hook SPA navigation
  function hookRouting() {
    const fire = () => window.dispatchEvent(new Event('locationchange'));
    const _ps = history.pushState;
    history.pushState = function () {
      const r = _ps.apply(this, arguments);
      fire(); return r;
    };
    const _rs = history.replaceState;
    history.replaceState = function () {
      const r = _rs.apply(this, arguments);
      fire(); return r;
    };
    window.addEventListener('popstate', fire);
    window.addEventListener('locationchange', () => {
      // Attempt to add button on route change
      setTimeout(ensureAddButton, 0);
    });
  }

  // Boot
  function boot() {
    ensureUI();
    hookRouting();
    ensureAddButton();

    // Just in case
    const mo = new MutationObserver(() => {
      if (isModPage() && !document.getElementById('addToPresetBtn')) {
        clearTimeout(mo._t);
        mo._t = setTimeout(ensureAddButton, 150);
      }
    });
    mo.observe(document.body, { childList: true, subtree: true });
  }

  // Delay boot
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }
})();