Manarion Guild Contributions Logger

Fetch guild contributions via Manarion public API (no UI clicking) and export totals. Sortable table, totals row, CSV export, and number-abbreviation toggle.

// ==UserScript==
// @name         Manarion Guild Contributions Logger
// @namespace    http://tampermonkey.net/
// @version      2.2.0
// @description  Fetch guild contributions via Manarion public API (no UI clicking) and export totals. Sortable table, totals row, CSV export, and number-abbreviation toggle.
// @match        https://manarion.com/*
// @grant        none
// ==/UserScript==

/*
======= Changelog =======
2.2.0 — Abbreviated numbers + simpler report
 - New **Numbers** toggle: Abbrev (K/M/B/T) ↔ Raw (defaults to Abbrev)
 - Removed per-column filters; kept sorting and totals
 - CSV always exports **raw numbers** regardless of the view
 - Minor UI copy updates

2.1.0 — Map numeric contribution codes → labels
 - Maps API numeric keys to columns: 1→Mana Dust, 2→Elemental Shards, 3→Codex, 7→Fish, 8→Wood, 9→Iron, 42→Battle XP
 - Fix: CSV newlines, regex escape in TitleCase converter

2.0.5 — Preserve Member IDs + Raw JSON viewer
 - Keeps member IDs when `Members` is an object-map; adds **ID column** to table/CSV
 - Adds **Raw JSON** button to inspect response after fetch
 - Clear hint when contributions are missing from the payload
*/

(function(){
  'use strict';

  // =============== Config ===============
  const API_BASE = 'https://api.manarion.com';
  const STORAGE_KEY = 'mgl_api_v2';
  const contributionFields = ['Mana Dust','Elemental Shards','Codex','Fish','Wood','Iron','Battle XP'];
  const baseHeaders = ['ID','Name',...contributionFields];
  const codeMap = { '1':'Mana Dust','2':'Elemental Shards','3':'Codex','7':'Fish','8':'Wood','9':'Iron','42':'Battle XP' };

  // =============== State ===============
  const state = { apiKey:'', guildId:'', guilds:[], data:[], lastRaw:null, isRunning:false, showAbbrev:true };

  // =============== Storage ===============
  function loadPrefs(){ try{ const o=JSON.parse(localStorage.getItem(STORAGE_KEY)||'{}'); if(o&&typeof o==='object'){ if(o.apiKey) state.apiKey=o.apiKey; if(o.guildId) state.guildId=o.guildId; } }catch(_){} }
  function savePrefs(){ localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: state.apiKey, guildId: state.guildId })); }
  loadPrefs();

  // =============== Helpers ===============
  function toCSV(rows){ if(!rows.length) return ''; const headers=baseHeaders; const q=v=>JSON.stringify(v==null?'':v); const lines=[headers.map(q).join(',')]; for(const r of rows){ lines.push(headers.map(h=>q(r[h]||'')).join(',')); } return lines.join('\n'); }
  function download(name, text, type='text/plain'){ const blob=new Blob([text],{type}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name; document.body.appendChild(a); a.click(); a.remove(); }
  function num(v){ const n=Number(String(v||'').replace(/,/g,'')); return Number.isFinite(n)?n:0; }
  function abbrev(n){ n=Number(n)||0; const a=Math.abs(n); if(a>=1e12) return (n/1e12).toFixed(2).replace(/\.00$/,'')+'T'; if(a>=1e9) return (n/1e9).toFixed(2).replace(/\.00$/,'')+'B'; if(a>=1e6) return (n/1e6).toFixed(2).replace(/\.00$/,'')+'M'; if(a>=1e3) return (n/1e3).toFixed(2).replace(/\.00$/,'')+'K'; return String(Math.trunc(n)); }

  // =============== API ===============
  async function apiGet(path){ const url=`${API_BASE}${path}`; const res=await fetch(url,{credentials:'omit',cache:'no-store',mode:'cors',referrerPolicy:'no-referrer'}); if(!res.ok){ const body=await res.text().catch(()=>'(no body)'); console.error('API error',{url,status:res.status,body}); throw new Error(`HTTP ${res.status} for ${path}`); } return res.json(); }
  async function fetchGuildList(){ const guilds=await apiGet('/guilds'); if(!Array.isArray(guilds)){ console.warn('Unexpected /guilds format', guilds); return []; } state.guilds=guilds; return guilds; }
  async function fetchGuildDetails(gid,key){ const path=`/guilds/${encodeURIComponent(gid)}?apikey=${encodeURIComponent(key)}`; return apiGet(path); }

  // =============== Normalization ===============
  function valueByLabel(obj, label){ if(!obj) return ''; const variants=[label,label.replace(/ /g,'_'),label.replace(/ /g,'').toLowerCase(),label.toLowerCase(),label.replace(/\b([a-z])/g,(_,c)=>c.toUpperCase()),label.replace(/ /g,'')]; for(const v of variants){ if(Object.prototype.hasOwnProperty.call(obj,v)) return obj[v]; const snake=v.replace(/[A-Z]/g,m=>'_'+m.toLowerCase()); if(Object.prototype.hasOwnProperty.call(obj,snake)) return obj[snake]; const camel=v.replace(/[_ ]([a-z])/g,(_,c)=>c.toUpperCase()).replace(/[_ ]/g,''); if(Object.prototype.hasOwnProperty.call(obj,camel)) return obj[camel]; } return ''; }

  function pickContribs(member){ const out={}; let found=false; const buckets = member && (member.Contributions||member.contributions||member.Totals||member.totals||member.Stats||member.stats||member); if(buckets && typeof buckets==='object'){ const keys=Object.keys(buckets); if(keys.some(k=>/^\d+$/.test(k))){ for(const k of keys){ const label=codeMap[k]; if(label){ out[label]=String(buckets[k]); found=true; } } } }
    if(!found){ for(const k of contributionFields){ const val=valueByLabel(buckets,k); if(val!=='' && val!=null) found=true; out[k]=(val==null||val==='')?'':String(val); } } else { for(const k of contributionFields){ if(!(k in out)) out[k]=''; } }
    return out; }

  function normalizeGuildData(details){ const rows=[]; let members = details.members || details.Members || details.roster || details.players || details.Players || [];
    if(members && !Array.isArray(members) && typeof members==='object'){ const vals=[]; for(const [mk,mv] of Object.entries(members)){ if(mv && typeof mv==='object'){ if(mv.ID==null && mv.Id==null && mv.id==null) mv.ID = mk; vals.push(mv); } } if(vals.length){ members=vals; console.log('Members detected as object map; converted & preserved IDs'); } }
    if(!Array.isArray(members) || members.length===0){ let picked=null; try{ for(const [k,v] of Object.entries(details)){ if(Array.isArray(v) && v.length>0 && typeof v[0]==='object'){ picked={key:k,arr:v}; break; } if(!Array.isArray(v) && v && typeof v==='object'){ const vals=Object.values(v).filter(x=>x && typeof x==='object'); if(vals.length>5){ picked={key:k,arr:vals}; break; } } } }catch(e){} if(picked){ members=picked.arr; console.log('Picked members collection from key:',picked.key); } }
    if(!Array.isArray(members)) return rows;
    for(const m of members){ const id = m.ID||m.Id||m.id||''; const name = m.Name||m.name||m.player||m.username||m.display_name||id||'Unknown'; rows.push({ ID:String(id), Name:String(name), ...pickContribs(m) }); }
    return rows; }

  // =============== Raw JSON Viewer ===============
  function showRawJson(raw){ const existing=document.getElementById('mgl-raw'); if(existing) existing.remove(); const modal=document.createElement('div'); modal.id='mgl-raw'; Object.assign(modal.style,{position:'fixed',top:'50%',left:'50%',transform:'translate(-50%,-50%)',zIndex:100000,background:'#121212',color:'#fff',padding:'12px',width:'80vw',height:'70vh',borderRadius:'10px',boxShadow:'0 0 20px rgba(0,0,0,.6)',display:'flex',flexDirection:'column'}); const bar=document.createElement('div'); Object.assign(bar.style,{display:'flex',justifyContent:'space-between',marginBottom:'8px'}); const title=document.createElement('div'); title.textContent='Raw JSON (guild details)'; const close=document.createElement('button'); close.textContent='Close'; Object.assign(close.style,{padding:'6px 10px',background:'#6A0DAD',color:'#fff',border:'none',borderRadius:'6px',cursor:'pointer'}); close.onclick=()=>modal.remove(); bar.appendChild(title); bar.appendChild(close); const pre=document.createElement('pre'); Object.assign(pre.style,{flex:'1',overflow:'auto',whiteSpace:'pre-wrap',background:'#0f0f0f',padding:'10px',borderRadius:'6px'}); pre.textContent=typeof raw==='string'? raw : JSON.stringify(raw,null,2); modal.appendChild(bar); modal.appendChild(pre); document.body.appendChild(modal); }

  // =============== Results Modal (sortable + totals) ===============
  function showResultsModal(){ const existing=document.getElementById('mgl-results'); if(existing) existing.remove(); const modal=document.createElement('div'); modal.id='mgl-results'; Object.assign(modal.style,{position:'fixed',top:'50%',left:'50%',transform:'translate(-50%,-50%)',zIndex:99999,background:'#1e1e1e',color:'#fff',padding:'16px',maxHeight:'82vh',overflow:'auto',width:'980px',borderRadius:'12px',boxShadow:'0 0 20px rgba(0,0,0,.6)',fontFamily:'monospace'});
    const top=document.createElement('div'); Object.assign(top.style,{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:'10px'}); const title=document.createElement('div'); title.textContent='Guild Contributions — Sort / Totals'; Object.assign(title.style,{fontSize:'16px',fontWeight:'bold'});
    const actions=document.createElement('div'); Object.assign(actions.style,{display:'flex',gap:'8px'});
    const dlBtn=document.createElement('button'); dlBtn.textContent='Download CSV'; Object.assign(dlBtn.style,{padding:'6px 10px',background:'#F1C40F',color:'#000',border:'none',borderRadius:'6px',cursor:'pointer'});
    const rawBtn=document.createElement('button'); rawBtn.textContent='Raw JSON'; Object.assign(rawBtn.style,{padding:'6px 10px',background:'#555',color:'#fff',border:'none',borderRadius:'6px',cursor:'pointer'});
    const toggleBtn=document.createElement('button'); toggleBtn.textContent='Numbers: Abbrev'; Object.assign(toggleBtn.style,{padding:'6px 10px',background:'#2d2d2d',color:'#fff',border:'none',borderRadius:'6px',cursor:'pointer'});
    const closeBtn=document.createElement('button'); closeBtn.textContent='Close'; Object.assign(closeBtn.style,{padding:'6px 10px',background:'#6A0DAD',color:'#fff',border:'none',borderRadius:'6px',cursor:'pointer'});
    actions.appendChild(dlBtn); actions.appendChild(rawBtn); actions.appendChild(toggleBtn); actions.appendChild(closeBtn);
    top.appendChild(title); top.appendChild(actions);

    const tableWrap=document.createElement('div'); Object.assign(tableWrap.style,{border:'1px solid #333',borderRadius:'8px',overflow:'hidden'});
    const table=document.createElement('table'); Object.assign(table.style,{width:'100%',borderCollapse:'separate',borderSpacing:'0'});
    const thead=document.createElement('thead'); const headRow=document.createElement('tr');
    const tbody=document.createElement('tbody'); const tfoot=document.createElement('tfoot');

    const sort={key:'Name',dir:'asc'}; function headerCell(label){ const th=document.createElement('th'); th.textContent=label+(sort.key===label?(sort.dir==='asc'?' ▲':' ▼'):''); Object.assign(th.style,{position:'sticky',top:'0',background:'#2a2a2a',borderBottom:'1px solid #444',padding:'6px 8px',textAlign:'left',cursor:'pointer',userSelect:'none'}); th.addEventListener('click',()=>{ if(sort.key===label) sort.dir=sort.dir==='asc'?'desc':'asc'; else { sort.key=label; sort.dir='asc'; } render(); }); return th; }
    function sortRows(arr){ const key=sort.key; const dir=sort.dir==='asc'?1:-1; const isNum=(key!=='Name' && key!=='ID'); return arr.slice().sort((a,b)=> isNum ? (num(a[key])-num(b[key]))*dir : String(a[key]||'').localeCompare(String(b[key]||''))*dir ); }
    function renderBody(arr){ tbody.innerHTML=''; arr.forEach((r,i)=>{ const tr=document.createElement('tr'); tr.style.background=i%2?'#1a1a1a':'transparent'; const tdId=document.createElement('td'); tdId.textContent=r.ID||''; Object.assign(tdId.style,{padding:'6px 8px',borderBottom:'1px solid #333'}); tr.appendChild(tdId); const tdN=document.createElement('td'); tdN.textContent=r.Name||''; Object.assign(tdN.style,{padding:'6px 8px',borderBottom:'1px solid #333'}); tr.appendChild(tdN); for(const f of contributionFields){ const td=document.createElement('td'); const raw=r[f]||''; td.textContent = state.showAbbrev ? abbrev(raw) : String(raw); Object.assign(td.style,{padding:'6px 8px',borderBottom:'1px solid #333'}); tr.appendChild(td);} tbody.appendChild(tr); }); }
    function renderTotals(arr){ const tr=document.createElement('tr'); const label=document.createElement('td'); label.colSpan=2; label.textContent='Totals (visible)'; Object.assign(label.style,{padding:'8px',background:'#1f1f1f',borderTop:'1px solid #444',fontWeight:'bold'}); tr.appendChild(label); for(const f of contributionFields){ const td=document.createElement('td'); const sum=arr.reduce((a,r)=>a+num(r[f]),0); td.textContent = state.showAbbrev ? abbrev(sum) : String(sum); Object.assign(td.style,{padding:'8px',background:'#1f1f1f',borderTop:'1px solid #444'}); tr.appendChild(td);} tfoot.innerHTML=''; tfoot.appendChild(tr); }
    function render(){ headRow.innerHTML=''; headRow.appendChild(headerCell('ID')); headRow.appendChild(headerCell('Name')); for(const f of contributionFields) headRow.appendChild(headerCell(f)); const sorted=sortRows(state.data); renderBody(sorted); renderTotals(sorted); }

    dlBtn.addEventListener('click',()=> download('manarion_contributions.csv', toCSV(state.data), 'text/csv'));
    rawBtn.addEventListener('click',()=> state.lastRaw ? showRawJson(state.lastRaw) : alert('No raw data yet. Fetch via API first.'));
    toggleBtn.addEventListener('click',()=>{ state.showAbbrev=!state.showAbbrev; toggleBtn.textContent = state.showAbbrev ? 'Numbers: Abbrev' : 'Numbers: Raw'; render(); });
    closeBtn.addEventListener('click',()=> modal.remove());

    modal.appendChild(top); modal.appendChild(tableWrap); tableWrap.appendChild(table); table.appendChild(thead); thead.appendChild(headRow); table.appendChild(tbody); table.appendChild(tfoot); document.body.appendChild(modal);

    if(!Array.isArray(state.data) || state.data.length===0){ const hint=document.createElement('div'); hint.textContent='No rows. Check API key & Guild ID — also see console for fetch logs.'; Object.assign(hint.style,{marginTop:'8px',color:'#bbb'}); modal.appendChild(hint); }

    const anyContrib=(state.data||[]).some(r=> contributionFields.some(f=> (r[f]||'')!=='' && num(r[f])>0)); if(!anyContrib){ const note=document.createElement('div'); note.textContent='Heads-up: contribution totals were not found in this payload for some members. If the API exposes them under different keys or another endpoint, share a sample and I’ll map them.'; Object.assign(note.style,{marginTop:'8px',color:'#f1c40f'}); modal.appendChild(note); }

    render();
  }

  // =============== Toolbar (draggable + collapsible) ===============
  function buildControlBar(){ const existing=document.getElementById('mgl-bar'); if(existing) return existing; const bar=document.createElement('div'); bar.id='mgl-bar'; Object.assign(bar.style,{position:'fixed',bottom:'10px',right:'10px',zIndex:99999,display:'flex',gap:'6px',alignItems:'center',background:'#1e1e1e',padding:'8px',borderRadius:'10px',boxShadow:'0 0 10px rgba(0,0,0,.5)',fontFamily:'monospace'});
    const drag=document.createElement('div'); drag.textContent='≡'; Object.assign(drag.style,{cursor:'move',color:'#ccc',padding:'0 6px',userSelect:'none'}); let dragging=false,sx=0,sy=0,ox=0,oy=0; drag.addEventListener('mousedown',e=>{dragging=true; sx=e.clientX; sy=e.clientY; const r=bar.getBoundingClientRect(); ox=r.left; oy=r.top; e.preventDefault();}); document.addEventListener('mousemove',e=>{ if(!dragging) return; const dx=e.clientX-sx,dy=e.clientY-sy; bar.style.left=(ox+dx)+'px'; bar.style.top=(oy+dy)+'px'; bar.style.right='auto'; bar.style.bottom='auto';}); document.addEventListener('mouseup',()=> dragging=false);

    const guildSel=document.createElement('select'); Object.assign(guildSel.style,{padding:'6px',background:'#222',color:'#fff',border:'1px solid #333',borderRadius:'6px',maxWidth:'280px'});
    const idInput=document.createElement('input'); idInput.placeholder='Guild ID'; Object.assign(idInput.style,{padding:'6px',background:'#222',color:'#fff',border:'1px solid #333',borderRadius:'6px',width:'120px'});
    const keyInput=document.createElement('input'); keyInput.placeholder='API Key'; keyInput.type='password'; Object.assign(keyInput.style,{padding:'6px',background:'#222',color:'#fff',border:'1px solid #333',borderRadius:'6px',width:'200px'});
    const fetchBtn=document.createElement('button'); fetchBtn.textContent='Fetch via API'; Object.assign(fetchBtn.style,{padding:'6px 10px',background:'#6A0DAD',color:'#fff',border:'none',borderRadius:'6px',cursor:'pointer'});
    const showBtn=document.createElement('button'); showBtn.textContent='Show Results'; Object.assign(showBtn.style,{padding:'6px 10px',background:'#3E3A5E',color:'#fff',border:'none',borderRadius:'6px',cursor:'pointer'});

    const collapse=document.createElement('button'); collapse.textContent='«'; Object.assign(collapse.style,{padding:'4px 6px',background:'#444',color:'#fff',border:'none',borderRadius:'6px',cursor:'pointer'}); let collapsed=false; function setCollapsed(v){ collapsed=!!v; bar.innerHTML=''; if(collapsed){ bar.appendChild(drag); const tab=document.createElement('button'); tab.textContent='Guild Logger'; Object.assign(tab.style,{padding:'6px 8px',background:'#6A0DAD',color:'#fff',border:'none',borderRadius:'6px',cursor:'pointer'}); tab.addEventListener('click',()=> setCollapsed(false)); bar.appendChild(tab); } else { bar.appendChild(drag); bar.appendChild(collapse); bar.appendChild(guildSel); bar.appendChild(idInput); bar.appendChild(keyInput); bar.appendChild(fetchBtn); bar.appendChild(showBtn); } }

    idInput.value=state.guildId||''; keyInput.value=state.apiKey||'';

    async function populateGuilds(){ guildSel.innerHTML=''; const opt0=document.createElement('option'); opt0.value=''; opt0.textContent='— Select guild —'; guildSel.appendChild(opt0); try{ const list=await fetchGuildList(); if(!Array.isArray(list) || list.length===0){ const o=document.createElement('option'); o.value=''; o.textContent='(no guilds returned)'; guildSel.appendChild(o); } else { list.forEach(g=>{ const o=document.createElement('option'); o.value=String(g.id||g._id||g.slug||g.name||''); o.textContent=`${g.name||g.title||o.value}`; guildSel.appendChild(o); }); } }catch(e){ const o=document.createElement('option'); o.value=''; o.textContent='(failed to load guilds)'; guildSel.appendChild(o); console.warn(e); } }
    populateGuilds();

    guildSel.addEventListener('change',()=>{ state.guildId=guildSel.value; idInput.value=state.guildId; savePrefs(); });
    idInput.addEventListener('input',()=>{ state.guildId=idInput.value.trim(); savePrefs(); });
    keyInput.addEventListener('input',()=>{ state.apiKey=keyInput.value.trim(); savePrefs(); });

    fetchBtn.addEventListener('click', async()=>{
      if(state.isRunning) return;
      const gid=(idInput.value || guildSel.value || '').trim();
      const key=keyInput.value.trim();
      if(!gid) return alert('Please select a guild or enter a Guild ID.');
      if(!key) return alert('Please enter your guild API key.');
      state.guildId=gid; state.apiKey=key; savePrefs();
      try{
        state.isRunning=true; fetchBtn.disabled=true; fetchBtn.textContent='Fetching…';
        const details=await fetchGuildDetails(gid, key);
        state.lastRaw=details;
        const rows=normalizeGuildData(details);
        if(!rows.length){ console.warn('No members parsed from details:', details); alert('Fetched guild details, but found no members. Check console for the response shape and confirm your API key/guild ID.'); }
        state.data=rows; showResultsModal();
      }catch(err){ console.error('Fetch failed', err); alert('Failed to fetch guild details. See console for details.'); }
      finally{ state.isRunning=false; fetchBtn.disabled=false; fetchBtn.textContent='Fetch via API'; }
    });

    showBtn.addEventListener('click',()=>{ if(!state.data.length) return alert('No data yet. Click “Fetch via API” first.'); showResultsModal(); });

    bar.appendChild(drag); bar.appendChild(collapse); bar.appendChild(guildSel); bar.appendChild(idInput); bar.appendChild(keyInput); bar.appendChild(fetchBtn); bar.appendChild(showBtn);
    document.body.appendChild(bar);

    setTimeout(()=>{ const r=bar.getBoundingClientRect(); if(r.right>window.innerWidth-8) setCollapsed(true); },0);
    collapse.addEventListener('click',()=> setCollapsed(true));

    return bar;
  }

  // =============== Boot ===============
  (function boot(){ buildControlBar(); })();

})();