From 2eca9c26b5838f6794811562f16597a7c5dc1940 Mon Sep 17 00:00:00 2001 From: simdo01 Date: Sun, 14 Sep 2025 17:19:19 -0500 Subject: [PATCH] Add TokenGroups script v0.7.0 --- TokenGroups/README.md | 212 ++++++++++ TokenGroups/TokenGroups.js | 786 +++++++++++++++++++++++++++++++++++++ TokenGroups/scripts.json | 45 +++ 3 files changed, 1043 insertions(+) create mode 100644 TokenGroups/README.md create mode 100644 TokenGroups/TokenGroups.js create mode 100644 TokenGroups/scripts.json diff --git a/TokenGroups/README.md b/TokenGroups/README.md new file mode 100644 index 000000000..c57eca170 --- /dev/null +++ b/TokenGroups/README.md @@ -0,0 +1,212 @@ +# TokenGroups — Roll20 API Script (v0.7.0) + +Create and manage **named groups of tokens** per page, then act on the whole group at once (move between layers, show/ping, list, purge, stats). Enhanced with ScriptCards integration for superior formatting and menus. Designed for GMs with clear menus and minimal footprint. + + +## Features + +- **Page‑scoped groups**: a group name is tied to a single page. +- **Bulk actions**: move a whole group to `objects`, `gmlayer`, or `map`; ping/show locations. +- **Enhanced ScriptCards menus**: in‑chat buttons with superior formatting and styling. +- **Dual render modes**: HTML (basic) or ScriptCards (enhanced) formatting. +- **Stats & Purge**: see totals by page/group; remove missing token IDs from state. +- **Quoted names & `Name@Page`**: handle spaces; disambiguate duplicates across pages. +- **Startup whisper**: confirms load + enabled/disabled state to GM. +- **Small footprint**: namespaced state, no polling, minimal object writes. +- **Handout helper**: `!tgroup doc` creates/updates a GM handout with basics. + +--- + +## Requirements + +- **Roll20 Pro** (API access). +- **ScriptCards** (optional but recommended for enhanced formatting). +- Paste the TokenGroups v0.7.0 script into your game's **Settings → API Scripts → New Script**. + +--- + +## Installation & First Run + +1. Paste the script and **Save Script**. The API sandbox reloads. +2. You'll get a GM whisper like: + `TokenGroups v0.7.0 is loaded (currently disabled). Type !tgroup help.` +3. **Enable** the script (disabled by default, per Roll20 best practices): + ```text + !tgroup enable + ``` +4. (Optional) Configure render mode for ScriptCards integration: + ```text + !tgroup config render sc + ``` +5. (Optional) Generate a GM handout with quick instructions: + ```text + !tgroup doc + ``` + +--- + +## Core Concepts + +- A **group** is just a named list of token IDs on **one** Roll20 page. +- You can operate on a group from anywhere; the page doesn't need to be active. +- If a name exists on multiple pages, target with **`GroupName@PageNameOrID`**. +- Use **quotes** for names with spaces: `"Bandit Squad"`, and for pages: `"Dungeon L1"`. +- **ScriptCards mode** provides enhanced formatting, better buttons, and improved menus. + +--- + +## Commands + +### Basic Operations +| Command | Description | +|---------|-------------| +| `!tgroup help` | Show help and command list | +| `!tgroup enable` | Enable the script | +| `!tgroup disable` | Disable the script | +| `!tgroup status` | Show current status | + +### Configuration +| Command | Description | +|---------|-------------| +| `!tgroup config render ` | Set render mode (HTML or ScriptCards) | +| `!tgroup config whisper ` | Toggle whisper notifications | + +### Group Management +| Command | Description | +|---------|-------------| +| `!tgroup create ` | Create group from selected tokens | +| `!tgroup add ` | Add selected tokens to group | +| `!tgroup remove ` | Remove selected tokens from group | +| `!tgroup rename ` | Rename a group | +| `!tgroup delete ` | Delete a group | +| `!tgroup clear ` | Clear all tokens from group | + +### Group Actions +| Command | Description | +|---------|-------------| +| `!tgroup move ` | Move group to specified layer | +| `!tgroup show ` | Show/ping group tokens | +| `!tgroup list` | List groups on current page | +| `!tgroup list all` | List all groups across all pages | +| `!tgroup list page ` | List groups on specific page | + +### Utilities +| Command | Description | +|---------|-------------| +| `!tgroup where ` | Find which page contains a group | +| `!tgroup purge [name\|all]` | Remove missing token IDs from state | +| `!tgroup stats` | Show overall statistics | +| `!tgroup stats group ` | Show group-specific stats | +| `!tgroup stats page ` | Show page-specific stats | +| `!tgroup menu []` | Show interactive menu | +| `!tgroup doc` | Create/update GM handout | + +--- + +## ScriptCards Integration + +### Enhanced Features +- **Better formatting**: Rich text with `[b]`, `[i]`, `[c]`, `[color]`, etc. +- **Improved buttons**: Custom styling with colors and sizes +- **Enhanced menus**: ScriptCards-first design with better layout +- **Inline styles**: Superior presentation and readability + +### Render Modes +- **HTML mode**: Basic HTML formatting (default) +- **ScriptCards mode**: Enhanced formatting with ScriptCards syntax + +### Configuration +```text +!tgroup config render sc # Enable ScriptCards mode +!tgroup config render html # Use basic HTML mode +``` + +--- + +## Examples + +### Creating and Managing Groups +```text +# Select some tokens, then: +!tgroup create "Goblin Squad" + +# Add more tokens to the group: +!tgroup add "Goblin Squad" + +# Move the entire group to the GM layer: +!tgroup move "Goblin Squad" gmlayer + +# Show where the group is: +!tgroup show "Goblin Squad" +``` + +### Cross-Page Operations +```text +# Work with groups on different pages: +!tgroup move "Bandits@Dungeon Level 1" objects +!tgroup list page "Dungeon Level 1" +!tgroup stats page "Dungeon Level 1" +``` + +### Interactive Menus +```text +# Show main menu: +!tgroup menu + +# Show group-specific menu: +!tgroup menu "Goblin Squad" +``` + +--- + +## Configuration Options + +The script supports several configuration options: + +- **enabled_on_boot**: Start enabled when API sandbox boots (default: true) +- **render_mode**: Choose between "html" or "sc" (ScriptCards) rendering +- **auto_menu**: Automatically show menu when creating/updating groups + +--- + +## Troubleshooting + +### Common Issues + +1. **Script not responding**: + - Check if script is enabled: `!tgroup status` + - Enable if needed: `!tgroup enable` + +2. **Groups not found**: + - Use `!tgroup list all` to see all groups + - Check page names with `!tgroup list page ` + - Use `Name@Page` syntax for cross-page operations + +3. **ScriptCards formatting not working**: + - Ensure ScriptCards is installed + - Check render mode: `!tgroup config render sc` + - Verify ScriptCards is working with other scripts + +### Debug Commands +```text +!tgroup status # Check script status +!tgroup stats # View overall statistics +!tgroup purge all # Clean up missing tokens +!tgroup where # Find group location +``` + +--- + +## Support + +For issues or questions: +1. Check the Roll20 API log for error messages +2. Use `!tgroup help` for command reference +3. Try `!tgroup status` to verify script state +4. Use `!tgroup doc` to generate a reference handout + +--- + +## License + +This script is provided as-is for use in Roll20 games. diff --git a/TokenGroups/TokenGroups.js b/TokenGroups/TokenGroups.js new file mode 100644 index 000000000..330061712 --- /dev/null +++ b/TokenGroups/TokenGroups.js @@ -0,0 +1,786 @@ +/* ----------------------------------------------------------------------------- + * TokenGroups — Enhanced ScriptCards support for TokenGroups + * Version: v0.7.0 (Enhanced SC: better formatting, inline styles, improved menus) + * Updated: 2025-01-27 + * + * Summary: + * Enhanced version of TokenGroups with superior ScriptCards integration: + * - Better inline formatting with ScriptCards syntax + * - Improved button styling and layout + * - Enhanced menu presentation + * - ScriptCards-first design approach + * + * Commands (GM-only): + * !tgroup help + * !tgroup enable | disable | status + * !tgroup config render + * !tgroup create (from selected tokens) + * !tgroup add (add selected) + * !tgroup remove (remove selected) + * !tgroup move + * !tgroup show + * !tgroup list | list all | list page + * !tgroup rename + * !tgroup delete | clear + * !tgroup where | purge [name|all] + * !tgroup stats [group | page ] + * !tgroup menu [] (menus in configured render mode) + * !tgroup doc (creates/updates a handout) + * + * ScriptCards Features: + * - Enhanced inline formatting with [b], [i], [c], [color], etc. + * - Improved button styling with custom colors and sizes + * - Better layout and spacing for ScriptCards + * - ScriptCards-first menu design + * --------------------------------------------------------------------------- */ + +on('ready', () => { + const MOD = 'TokenGroups'; + const VER = 'v0.7.0'; + const CMD = '!tgroup'; + const LAYERS = new Set(['objects', 'gmlayer', 'map']); + + // ---------- helpers ---------- + const esc = (s) => String(s).replace(/[<>&'"]/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c])); + const unq = (s) => (s ? String(s).replace(/^"(.*)"$/,'$1') : s); + const tokenize = (str) => (String(str||'').trim().match(/(?:[^\s"]+|"[^"]*")+/g) || []); + const cmdOut = (_msg, html) => sendChat(MOD, `/w gm
${html}
`, null, { noarchive:true }); + + const pageName = (pid) => (getObj('page', pid)?.get('name')) || '(deleted page)'; + const findPageByNameOrId = (arg) => { + if (!arg) return null; + const raw = unq(arg); + const byId = getObj('page', raw); if (byId) return byId; + const byName = findObjs({ _type: 'page', name: raw }); return byName[0] || null; + }; + const isGM = (playerid) => { + const p = getObj('player', playerid); + return p ? playerIsGM(p.get('_id')) : false; + }; + const getSelectedGraphics = (msg) => + (msg.selected || []).filter(s => s._type === 'graphic').map(s => getObj('graphic', s._id)).filter(Boolean); + const pageOfSelection = (graphics) => graphics.length ? graphics[0].get('_pageid') : null; + + // ---------- state ---------- + const assertState = () => { + state[MOD] = state[MOD] || {}; + const st = state[MOD]; + if (!st.pages) st.pages = {}; + if (typeof st.enabled === 'undefined') st.enabled = false; + if (typeof st.startupWhisper === 'undefined') st.startupWhisper = true; + if (typeof st.render === 'undefined') st.render = 'html'; // Default to HTML for compatibility (ScriptCards available via config) + if (!st.actions) st.actions = { seq:0, map:{} }; + if (!st.scdebug) st.scdebug = { last:'' }; + return st; + }; + const st = assertState(); + + // ---------- send as GM (for ScriptCards) ---------- + const sendAsGM = (msg, text) => { + if (msg && msg.playerid && isGM(msg.playerid)) { + return sendChat(`player|${msg.playerid}`, text, null, { noarchive:true }); + } + const gms = findObjs({ _type:'player' }).filter(p => playerIsGM(p.id || p.get('_id'))); + if (gms.length) return sendChat(`player|${(gms[0].id || gms[0].get('_id'))}`, text, null, { noarchive:true }); + return sendChat(MOD, text, null, { noarchive:true }); + }; + + // ---------- action keys ---------- + const now = () => Date.now(); + const ensureActions = () => assertState().actions; + const pruneActions = () => { + const A = ensureActions(); + const cutoff = now() - (30*60*1000); + const keys = Object.keys(A.map); + if (keys.length > 500) { + keys.sort((k1,k2)=>A.map[k1].ts - A.map[k2].ts); + keys.slice(0, keys.length-500).forEach(k => delete A.map[k]); + } + Object.entries(A.map).forEach(([k,v]) => { if (v.ts < cutoff) delete A.map[k]; }); + }; + const mkAction = (payload) => { + const A = ensureActions(); + const key = (++A.seq).toString(36) + '-' + Math.random().toString(36).slice(2,7); + A.map[key] = { payload, ts: now() }; + pruneActions(); + return key; + }; + const getAction = (key) => ensureActions().map[key]; + + // ---------- data ---------- + const getPageStore = (pid) => { + const S = assertState(); + S.pages[pid] = S.pages[pid] || { groups:{} }; + return S.pages[pid]; + }; + const groupsByName = (name) => { + const S = assertState(); const out=[]; + Object.entries(S.pages||{}).forEach(([pid,ps]) => { + const g = (ps.groups||{})[name]; if (g) out.push({ pageid:pid, group:g }); + }); + return out; + }; + const parseGroupRef = (raw) => { + if (!raw) return { name:null, pageRef:null }; + const s = unq(String(raw)); const m = s.match(/^(.*)@(.+)$/); + if (m) return { name:unq(m[1].trim()), pageRef:unq(m[2].trim()) }; + return { name:s.trim(), pageRef:null }; + }; + const resolveGroup = (nameRaw, pageRaw) => { + const name = unq(nameRaw); const pageRef = pageRaw ? unq(pageRaw) : null; + if (!name) return { error:'No group name.' }; + const matches = groupsByName(name); + if (!matches.length) return { notfound:true }; + if (pageRef) { + const target = findPageByNameOrId(pageRef); + if (!target) return { error:`Page not found: ${esc(pageRef)}` }; + const hit = matches.find(m => m.pageid === target.id); + if (!hit) return { error:`No group ${esc(name)} on page ${esc(pageName(target.id))}.` }; + return hit; + } + if (matches.length > 1) { + const choices = matches.map(m => `• ${esc(pageName(m.pageid))} [${esc(m.pageid)}] (${m.group.ids?.length||0})`).join('
'); + return { conflict:`Multiple groups named "${esc(name)}" exist on different pages.
${choices}

Tip: use ${CMD} … "${esc(name)}"@Page.` }; + } + return matches[0]; + }; + const getGroupOnPage = (pid, name) => { + const ps = assertState().pages[pid]; if (!ps) return null; + const g = ps.groups?.[name]; return g ? { pageid:pid, group:g } : null; + }; + const pruneGroup = (g) => { + let removed=0; + g.ids = (g.ids||[]).filter(id => { const o = getObj('graphic', id); if (!o){removed++; return false;} return true; }); + return removed; + }; + + // ---------- stats ---------- + const byteFmt = (n) => (n<1024?`${n} B`:n<1048576?`${(n/1024).toFixed(1)} KB`:`${(n/1048576).toFixed(2)} MB`); + const collectStats = () => { + const S = assertState(); const perPage=[]; let totalGroups=0,totalIds=0; + Object.entries(S.pages||{}).forEach(([pid,ps])=>{ + const groups = Object.values(ps.groups||{}); + const gc = groups.length; const ic = groups.reduce((a,g)=>a+(g.ids?.length||0),0); + totalGroups+=gc; totalIds+=ic; + perPage.push({pid,name:pageName(pid),groupCount:gc,idCount:ic}); + }); + perPage.sort((a,b)=>b.groupCount-a.groupCount||a.name.localeCompare(b.name)); + let stateBytes=0; try{ stateBytes = JSON.stringify(S).length; } catch {} + const largest=[]; + Object.entries(S.pages||{}).forEach(([pid,ps]) => Object.values(ps.groups||{}).forEach(g => largest.push({name:g.name,size:g.ids?.length||0,pid,pname:pageName(pid)}))); + largest.sort((a,b)=>b.size-a.size||a.name.localeCompare(b.name)); + return { perPage, totalGroups, totalIds, stateBytes, largest:largest.slice(0,10) }; + }; + const statsPage = (pid) => { + const ps = getPageStore(pid); const groups = Object.values(ps.groups||{}); + const idCount = groups.reduce((a,g)=>a+(g.ids?.length||0),0); + const lines = groups.sort((a,b)=>(b.ids?.length||0)-(a.ids?.length||0)||a.name.localeCompare(b.name)) + .map(g=>`• ${esc(g.name)} — ${g.ids?.length||0} token id(s)`).join('
') || '(none)'; + return { name:pageName(pid), groupCount:groups.length, idCount, lines }; + }; + const statsGroup = (name, pageRef) => { + const hit = resolveGroup(name, pageRef); if (!hit || hit.error || hit.notfound || hit.conflict) return hit||null; + const g=hit.group; let ok=0,missing=0; (g.ids||[]).forEach(id => (getObj('graphic',id)?ok++:missing++)); + return { name:g.name, page:pageName(hit.pageid), pid:hit.pageid, size:g.ids?.length||0, ok, missing }; + }; + + // ---------- HTML UI theme (fallback) ---------- + const THEME = { bg:'#0b1f3a', fg:'#e9f2ff', border:'#16365e', chip:'#173a74', subfg:'rgba(233,242,255,0.8)' }; + const card = (title, body) => + `
+
${esc(title)}
${body} +
`; + const chip = (txt) => `${esc(txt)}`; + const meta = (txt) => `${esc(txt)}`; + const btnB = (label, cmd) => `[🔷 ${label}](${cmd})`; // plain Roll20 link (HTML mode) + + // ---------- Enhanced ScriptCards theme + builders ---------- + // Enhanced button styles with better color schemes + const SCBTN = { + primary: { text:'white', bg:'blue', size:'13px' }, + secondary: { text:'white', bg:'cyan', size:'13px' }, + danger: { text:'white', bg:'red', size:'13px' }, + warning: { text:'white', bg:'orange', size:'13px' }, + success: { text:'white', bg:'green', size:'13px' } + }; + + // Enhanced ScriptCards builder with better formatting + const buildSC = (title, publicLines, opts = {}) => { + const CRLF = '\r\n'; + const segs = [ + `--#title|${title}`, + `--#bgcolor|${opts.bg || '#0b1f3a'}`, + `--#txcolor|${opts.tx || '#e9f2ff'}`, + `--#border|1`, + `--#titleFontSize|16px`, + `--#titleFontWeight|bold` + ]; + if ((opts.whisper ?? 'gm') === 'gm') segs.push(`--#whisper|gm`); + (publicLines || []).forEach(s => segs.push(`--+|${s}`)); + (opts.gmLines || []).forEach(s => segs.push(`--*|${s}`)); // GM-only card lines + const result = `!scriptcard {{${CRLF}${segs.join(CRLF)}${CRLF}}}`; + // Debug logging removed for cleaner console + return result; + }; + + // Enhanced ScriptCards button with better styling and inline formatting + const scBtn = (label, style = 'primary', actionKey = '') => { + const btnStyle = SCBTN[style] || SCBTN.primary; + const bg = btnStyle.bg; + // ScriptCards button format: [button:bgcolor:textcolor]label::command[/button] + const command = actionKey ? `${CMD} do ${actionKey}` : `${CMD} ${label.toLowerCase()} clicked`; + const btn = `[button:${bg}:white]${label}::${command}[/button]`; + return btn; + }; + + // Enhanced inline formatting helpers for ScriptCards + const scBold = (text) => `[b]${text}[/b]`; + const scItalic = (text) => `[i]${text}[/i]`; + const scCenter = (text) => `[c]${text}[/c]`; + const scColor = (text, color) => `[${color}]${text}[/#]`; + const scDivider = () => `[hr]`; + const scSpacer = () => ` `; + + // remember last SC payload for echo/log/sendraw + const rememberSC = (txt) => { state[MOD].scdebug.last = txt; }; + const sendSC = (msg, scText) => { rememberSC(scText); return sendAsGM(msg, scText); }; + + // ---------- startup whisper ---------- + if (st.startupWhisper) { + sendChat(MOD, `/w gm ${MOD} ${VER} is loaded${st.enabled?'':' (currently disabled)'}. Type ${CMD} help.`); + } + log(`${MOD} ${VER} loaded.`); + + // ---------- Enhanced ScriptCards Menus ---------- + const menuGroupSC = (hit) => { + const pid = hit.pageid; + const pname = pageName(pid); + const gname = hit.group.name; + const size = hit.group.ids?.length || 0; + + const kShow = mkAction({ t:'show', pid, name:gname }); + const kWhere = mkAction({ t:'where', pid, name:gname }); + const kStats = mkAction({ t:'stats', pid, name:gname }); + + const kObj = mkAction({ t:'move', pid, name:gname, layer:'objects' }); + const kGM = mkAction({ t:'move', pid, name:gname, layer:'gmlayer' }); + const kMap = mkAction({ t:'move', pid, name:gname, layer:'map' }); + + const kPurge = mkAction({ t:'purge', pid, name:gname }); + const kClear = mkAction({ t:'clear', pid, name:gname }); + const kDel = mkAction({ t:'delete', pid, name:gname }); + + // Enhanced formatting with better spacing and organization + const header = `${scBold('Group:')} ${gname} on ${scBold(pname)} (${size} token${size===1?'':'s'})`; + + const actionRow1 = [ + scBtn('Show', 'success', kShow), + scBtn('Where', 'secondary', kWhere), + scBtn('Stats', 'secondary', kStats) + ].join(' '); + + const actionRow2 = [ + scBtn('→ Objects', 'primary', kObj), + scBtn('→ GM Layer', 'primary', kGM), + scBtn('→ Map', 'primary', kMap) + ].join(' '); + + const actionRow3 = [ + scBtn('Purge Missing', 'danger', kPurge), + scBtn('Clear', 'warning', kClear), + scBtn('Delete', 'danger', kDel) + ].join(' '); + + return buildSC('TokenGroups — Group Menu', [ + header, + scDivider(), + actionRow1, + actionRow2, + actionRow3 + ], { bg: '#0f172a', tx: '#f8fafc' }); + }; + + const menuPageSC = (pid) => { + const ps = getPageStore(pid); + const groups = Object.values(ps.groups || {}); + const pname = pageName(pid); + + if (!groups.length) { + return buildSC('TokenGroups — Page Menu', [ + `No groups on ${scBold(pname)}.`, + scDivider(), + `Use ${scBold(`${CMD} create `)} to create your first group.` + ]); + } + + const lines = groups + .sort((a,b)=>(b.ids?.length||0)-(a.ids?.length||0)||a.name.localeCompare(b.name)) + .map(g => { + const kMenu = mkAction({ t:'menu', pid, name:g.name }); + const kShow = mkAction({ t:'show', pid, name:g.name }); + const kObj = mkAction({ t:'move', pid, name:g.name, layer:'objects' }); + const kGM = mkAction({ t:'move', pid, name:g.name, layer:'gmlayer' }); + const kMap = mkAction({ t:'move', pid, name:g.name, layer:'map' }); + + const groupInfo = `${scBold(g.name)} (${g.ids?.length||0})`; + const actions = [ + scBtn('Menu', 'primary', kMenu), + scBtn('Show', 'success', kShow), + scBtn('→Obj', 'secondary', kObj), + scBtn('→GM', 'secondary', kGM), + scBtn('→Map', 'secondary', kMap) + ].join(' '); + + return `${groupInfo} ${actions}`; + }); + + return buildSC('TokenGroups', [ + `${scBold('Page:')} ${pname}`, + scDivider(), + ...lines + ], { bg: '#0f172a', tx: '#f8fafc' }); + }; + + // ---------- HTML Menus (fallback) ---------- + const menuGroupHTML = (hit) => { + const pid = hit.pageid, pname = pageName(pid), gname = hit.group.name, size = hit.group.ids?.length || 0; + const kShow=mkAction({t:'show',pid,name:gname}), kWhere=mkAction({t:'where',pid,name:gname}), kStats=mkAction({t:'stats',pid,name:gname}); + const kObj=mkAction({t:'move',pid,name:gname,layer:'objects'}), kGM=mkAction({t:'move',pid,name:gname,layer:'gmlayer'}), kMap=mkAction({t:'move',pid,name:gname,layer:'map'}); + const kPurge=mkAction({t:'purge',pid,name:gname}), kClear=mkAction({t:'clear',pid,name:gname}), kDel=mkAction({t:'delete',pid,name:gname}); + const row1 = `
${chip(gname)} on ${chip(pname)} ${meta(`(${size} token${size===1?'':'s'})`)}
`; + const row2 = `
${btnB('Show',`${CMD} do ${kShow}`)} ${btnB('Where',`${CMD} do ${kWhere}`)} ${btnB('Stats',`${CMD} do ${kStats}`)}
`; + const row3 = `
${btnB('→ Objects',`${CMD} do ${kObj}`)} ${btnB('→ GM Layer',`${CMD} do ${kGM}`)} ${btnB('→ Map',`${CMD} do ${kMap}`)}
`; + const row4 = `
${btnB('Purge Missing',`${CMD} do ${kPurge}`)} ${btnB('Clear',`${CMD} do ${kClear}`)} ${btnB('Delete',`${CMD} do ${kDel}`)}
`; + return card('TokenGroups — Group Menu', row1+row2+row3+row4); + }; + + const menuPageHTML = (pid) => { + const ps = getPageStore(pid); const groups = Object.values(ps.groups||{}); const pname = pageName(pid); + if (!groups.length) return card('TokenGroups — Page Menu', `No groups on ${chip(pname)}.`); + const rows = groups.sort((a,b)=>(b.ids?.length||0)-(a.ids?.length||0)||a.name.localeCompare(b.name)).map(g=>{ + const kMenu=mkAction({t:'menu',pid,name:g.name}), kShow=mkAction({t:'show',pid,name:g.name}); + const kObj=mkAction({t:'move',pid,name:g.name,layer:'objects'}), kGM=mkAction({t:'move',pid,name:g.name,layer:'gmlayer'}), kMap=mkAction({t:'move',pid,name:g.name,layer:'map'}); + return `
+
${chip(g.name)} ${meta(`(${g.ids?.length||0})`)}
+
+ ${btnB('Menu',`${CMD} do ${kMenu}`)} ${btnB('Show',`${CMD} do ${kShow}`)} ${btnB('→Obj',`${CMD} do ${kObj}`)} ${btnB('→GM',`${CMD} do ${kGM}`)} ${btnB('→Map',`${CMD} do ${kMap}`)} +
+
`; + }).join(''); + return card(`TokenGroups — Groups on ${pname}`, rows); + }; + + // ---------- handler ---------- + on('chat:message', (msg) => { + if (msg.type!=='api' || !msg.content.startsWith(CMD)) return; + + const parts = tokenize(msg.content); + const sub = (parts[1]||'').toLowerCase(); + const arg1 = parts[2] ? unq(parts[2]) : undefined; + const arg2 = parts[3] ? unq(parts[3]) : undefined; + + if (!isGM(msg.playerid)) return cmdOut(msg, `Error: GM only.`); + + // help + if (!sub || sub==='help' || sub==='?') { + return cmdOut(msg, [ + `${CMD} — manage token groups (GM only)`, + `${CMD} enable|disable|status | config render `, + `${CMD} create|add|remove|move|show|list|rename|delete|clear`, + `${CMD} stats [group |page ] | where | purge [name|all]`, + `${CMD} cleanup | menu [] | doc`, + `SC Debug ${CMD} scdebug test|echo|log|sendraw` + ].join('
')); + } + + // admin + if (sub==='enable'){ state[MOD].enabled=true; return cmdOut(msg, `TokenGroups is now ENABLED.`); } + if (sub==='disable'){ state[MOD].enabled=false; return cmdOut(msg, `TokenGroups is now DISABLED.`); } + if (sub==='status'){ + return cmdOut(msg, `Enabled: ${state[MOD].enabled?'yes':'no'} | Render: ${state[MOD].render.toUpperCase()}`); + } + if (sub==='config' && (parts[2]||'').toLowerCase()==='render'){ + const v=(parts[3]||'').toLowerCase(); if(!['html','sc'].includes(v)) return cmdOut(msg, `Usage: ${CMD} config render `); + state[MOD].render = v; return cmdOut(msg, `Render mode set to ${v.toUpperCase()}.`); + } + + // allow while disabled (only these) + const allowed = new Set(['help','?','status','enable','config','doc','scdebug','cleanup']); + if (!state[MOD].enabled && !allowed.has(sub)) return cmdOut(msg, `TokenGroups is disabled. GM can ${CMD} enable.`); + + // SC DEBUG + if (sub === 'scdebug') { + const sub2 = (parts[2]||'').toLowerCase(); + if (sub2 === 'test') { + const t1 = buildSC('SC Test (Enhanced)', [ + 'Enhanced ScriptCards formatting test', + scDivider(), + `${scBold('Bold text')} and ${scItalic('italic text')}`, + `${scColor('Colored text', '#fbbf24')} and ${scCenter('centered text')}`, + scDivider(), + scBtn('Test Button', 'success', 'Click me!') + ]); + state[MOD].scdebug.last = t1; + return sendAsGM(msg, t1); + } + if (sub2 === 'echo') { + const raw = state[MOD].scdebug.last || ''; + if (!raw) return cmdOut(msg, `No stored ScriptCards payload yet. Run ${CMD} menu in SC mode first or ${CMD} scdebug test.`); + return cmdOut(msg, `Last ScriptCards payload
${esc(raw)}
`); + } + if (sub2 === 'log') { + const raw = state[MOD].scdebug.last || ''; + if (!raw) return cmdOut(msg, `Nothing to log. Use ${CMD} scdebug test first.`); + log(`${MOD} SC RAW >>>\n${raw.replace(/\r/g,'\\r').replace(/\n/g,'\\n\n')}`); + return cmdOut(msg, `Wrote last ScriptCards payload to API console.`); + } + if (sub2 === 'sendraw') { + const raw = state[MOD].scdebug.last || ''; + if (!raw) return cmdOut(msg, `Nothing to re-send. Use ${CMD} scdebug test first.`); + return sendAsGM(msg, raw); + } + return cmdOut(msg, `Usage: ${CMD} scdebug test|echo|log|sendraw`); + } + + // doc + if (sub === 'doc') { + const name = 'TokenGroups — User Guide'; + let ho = findObjs({ _type:'handout', name })[0]; + if (!ho) ho = createObj('handout', { name, inplayerjournals:'', controlledby:'' }); + const html = [ + `

TokenGroups (${VER})

`, + `

Enhanced version with superior ScriptCards integration. Create and manage named groups of tokens per page, then move or reveal the whole group at once.

`, + `

Quickstart

${CMD} create Goblins\n${CMD} move Goblins gmlayer\n${CMD} move Goblins objects\n${CMD} menu
`, + `

ScriptCards Features

  • Enhanced inline formatting
  • Improved button styling
  • Better menu layouts
  • ScriptCards-first design
`, + `

Help

${CMD} help
` + ].join(''); + ho.set('notes', html); + return cmdOut(msg, `Created/updated handout ${name}.`); + } + + // ---------- menu helpers ---------- + const openGroupMenu = (hit) => { + if (state[MOD].render==='sc') { const sc = menuGroupSC(hit); return sendSC(msg, sc); } + return cmdOut(msg, menuGroupHTML(hit)); + }; + const openPageMenu = (pid) => { + if (state[MOD].render==='sc') { const sc = menuPageSC(pid); return sendSC(msg, sc); } + return cmdOut(msg, menuPageHTML(pid)); + }; + + // ---------- commands ---------- + if (sub === 'menu') { + if (arg1) { + const { name, pageRef } = parseGroupRef(arg1); + const hit = pageRef ? resolveGroup(name, pageRef) : resolveGroup(name, null); + if (hit?.error) return cmdOut(msg, `Error: ${hit.error}`); + if (hit?.conflict) return cmdOut(msg, hit.conflict); + if (!hit || hit.notfound) return cmdOut(msg, `No group named ${esc(name)}.`); + return openGroupMenu(hit); + } else { + let pid = pageOfSelection(getSelectedGraphics(msg)); if (!pid) { try { pid = Campaign().get('playerpageid'); } catch {} } + const S = assertState(); + const has = pid && S.pages[pid] && Object.keys(S.pages[pid].groups||{}).length; + if (!has) { + const all = Object.entries(S.pages).flatMap(([p,ps])=>Object.values(ps.groups||{}).map(g=>({pid:p,name:g.name,count:g.ids.length}))); + if (!all.length) return cmdOut(msg, card('TokenGroups — All Groups', `No groups saved yet. Try ${CMD} create MyGroup.`)); + if (state[MOD].render==='sc') { + const lines = all.map(g=>{const kMenu=mkAction({t:'menu',pid:g.pid,name:g.name}); return `${scBold(g.name)} on ${scBold(pageName(g.pid))} (${g.count}) ${scBtn('Menu', 'primary', kMenu)}`;}); + return sendSC(msg, buildSC('TokenGroups — All Groups', lines)); + } + const rows = all.map(g=>{ + const kMenu=mkAction({t:'menu',pid:g.pid,name:g.name}); const pn = pageName(g.pid); + return `
+
${chip(g.name)} on ${chip(pn)} ${meta(`(${g.count})`)}
+
${btnB('Menu',`${CMD} do ${kMenu}`)}
+
`; + }).join(''); + return cmdOut(msg, card('TokenGroups — All Groups', rows)); + } + return openPageMenu(pid); + } + } + + if (sub === 'list' && arg1 && arg1.toLowerCase()==='all') { + const S = assertState(); + const all = Object.entries(S.pages).flatMap(([pid,ps]) => Object.values(ps.groups).map(g=>({pid,name:g.name,count:g.ids.length}))); + if (!all.length) return cmdOut(msg, `No groups saved yet.`); + if (state[MOD].render==='sc') { + const lines = all.map(g => { + const kMenu=mkAction({t:'menu',pid:g.pid,name:g.name}); + return `${scBold(g.name)} (${g.count}) — Page: ${scBold(pageName(g.pid))} [${g.pid}] ${scBtn('Menu', 'primary', kMenu)}`; + }); + return sendSC(msg, buildSC('TokenGroups — All Groups', lines)); + } + const lines = all.map(g => { + const kMenu=mkAction({t:'menu',pid:g.pid,name:g.name}); + return `• ${esc(g.name)} (${g.count}) — Page: ${esc(pageName(g.pid))} [${esc(g.pid)}] ${btnB('Menu',`${CMD} do ${kMenu}`)}`; + }).join('
'); + return cmdOut(msg, `All Groups (all pages)
${lines}`); + } + + if (sub === 'list' && arg1 && arg1.toLowerCase()==='page') { + const target = findPageByNameOrId(arg2); if (!target) return cmdOut(msg, `Error: Page not found: ${esc(arg2||'')}`); + return openPageMenu(target.id); + } + + if (sub === 'where') { + const { name, pageRef } = parseGroupRef(arg1); if (!name) return cmdOut(msg, `Usage: ${CMD} where <name[@page]>`); + const hit = resolveGroup(name, pageRef); if (hit?.error) return cmdOut(msg, `Error: ${hit.error}`); if (hit?.conflict) return cmdOut(msg, hit.conflict); + if (!hit || hit.notfound) return cmdOut(msg, `No group named ${esc(name)}.`); + return cmdOut(msg, `${esc(name)} — Page: ${esc(pageName(hit.pageid))} [${esc(hit.pageid)}], Size: ${hit.group.ids.length}`); + } + + if (sub === 'purge') { + const targetRaw = arg1; let removedTotal=0, scanned=0; + const S = assertState(); const purgeOne = (g)=>{ scanned++; removedTotal += pruneGroup(g); }; + if (!targetRaw || targetRaw.toLowerCase()==='all') { + Object.values(S.pages).forEach(ps=>Object.values(ps.groups).forEach(purgeOne)); + return cmdOut(msg, `Purged missing tokens across all groups. Groups scanned: ${scanned}, IDs removed: ${removedTotal}.`); + } else { + const { name, pageRef } = parseGroupRef(targetRaw); const hit = resolveGroup(name, pageRef); + if (hit?.error) return cmdOut(msg, `Error: ${hit.error}`); if (hit?.conflict) return cmdOut(msg, hit.conflict); + if (!hit || hit.notfound) return cmdOut(msg, `No group named ${esc(name)}.`); + const removed = pruneGroup(hit.group); + return cmdOut(msg, `Purged ${esc(name)}. IDs removed: ${removed}. Size now ${hit.group.ids.length}.`); + } + } + + if (sub === 'cleanup') { + const S = assertState(); + let orphanedCount = 0; + let cleanedGroups = []; + + // Find orphaned groups (groups that exist in state but can't be accessed by name) + Object.entries(S.pages).forEach(([pid, ps]) => { + if (ps.groups) { + Object.entries(ps.groups).forEach(([key, group]) => { + // Check if this group can be resolved by its stored name + const resolved = resolveGroup(group.name, null); + if (!resolved || resolved.notfound || resolved.error) { + // This group is orphaned - it exists in state but can't be accessed + orphanedCount++; + cleanedGroups.push({ + page: pageName(pid), + key: key, + name: group.name, + size: group.ids?.length || 0 + }); + // Remove the orphaned group + delete ps.groups[key]; + } + }); + } + }); + + if (orphanedCount === 0) { + return cmdOut(msg, `No orphaned groups found. All groups are accessible.`); + } else { + const details = cleanedGroups.map(g => + `• ${esc(g.name)} (${g.size} tokens) on ${esc(g.page)} [key: ${esc(g.key)}]` + ).join('
'); + return cmdOut(msg, `Cleaned up ${orphanedCount} orphaned group${orphanedCount===1?'':'s'}:
${details}`); + } + } + + if (sub === 'stats') { + const mode = (arg1||'').toLowerCase(); + if (mode==='group' || (mode && mode!=='page')) { + const ref = mode==='group' ? arg2 : arg1; const { name, pageRef } = parseGroupRef(ref); + if (!name) return cmdOut(msg, `Usage: ${CMD} stats group <name[@page]>`); + const sg = statsGroup(name, pageRef); if (!sg) return cmdOut(msg, `No group named ${esc(name)}.`); + if (sg.error) return cmdOut(msg, `Error: ${sg.error}`); if (sg.conflict) return cmdOut(msg, sg.conflict); + const kMenu = mkAction({t:'menu',pid:sg.pid,name:sg.name}); + return cmdOut(msg, [`Stats — Group ${esc(sg.name)}`,`Page: ${esc(sg.page)} [${esc(sg.pid)}]`,`Size: ${sg.size}`,`Resolvable: ${sg.ok}, Missing: ${sg.missing}`,`
[🔷 Menu](${CMD} do ${kMenu})`].join('
')); + } + if (mode==='page') { + const target=findPageByNameOrId(arg2); if(!target) return cmdOut(msg, `Error: Page not found: ${esc(arg2||'')}`); + const sp=statsPage(target.id); + return cmdOut(msg, [`Stats — Page ${esc(sp.name)} [${esc(target.id)}]`,`Groups: ${sp.groupCount}, Token IDs stored: ${sp.idCount}`,`
Groups (largest first)
${sp.lines}`].join('
')); + } + const s=collectStats(); + const per = s.perPage.length ? s.perPage.map(p=>`• ${esc(p.name)} [${esc(p.pid)}] — ${p.groupCount} group(s), ${p.idCount} id(s)`).join('
') : '(none)'; + const big = s.largest.length ? s.largest.map(g=>`• ${esc(g.name)} — ${g.size} id(s) (${esc(g.pname)})`).join('
') : '(none)'; + return cmdOut(msg, [`TokenGroups Stats (overall)`,`Pages tracked: ${s.perPage.length}`,`Groups total: ${s.totalGroups}`,`Token IDs total: ${s.totalIds}`,`Estimated state size: ${byteFmt(s.stateBytes)}`,`
Per Page
${per}`,`
Largest Groups (top 10)
${big}`].join('
')); + } + + // core commands + if (sub==='create'){ + const name = arg1; if(!name) return cmdOut(msg, `Usage: ${CMD} create <name>`); + const sel = getSelectedGraphics(msg); const ids = sel.map(g=>g.id); if(!ids.length) return cmdOut(msg, `Select some tokens first.`); + const pid = pageOfSelection(sel); const ps = getPageStore(pid); + ps.groups[name] = { name, pageid:pid, ids: Array.from(new Set(ids)) }; + const kMenu = mkAction({t:'menu',pid,name}); + return cmdOut(msg, card('TokenGroups', `Created group ${chip(name)} on ${chip(pageName(pid))} ${meta(`(${ps.groups[name].ids.length} token${ps.groups[name].ids.length===1?'':'s'})`)}
[🔷 Open Menu](${CMD} do ${kMenu})
`)); + } + + if (sub==='add'){ + const name = arg1; if(!name) return cmdOut(msg, `Usage: ${CMD} add <name>`); + const sel = getSelectedGraphics(msg); const ids = sel.map(g=>g.id); if(!ids.length) return cmdOut(msg, `Select tokens to add.`); + const pid = pageOfSelection(sel); const ps = getPageStore(pid); + ps.groups[name] = ps.groups[name] || { name, pageid:pid, ids:[] }; + const before = ps.groups[name].ids.length; ps.groups[name].ids = Array.from(new Set(ps.groups[name].ids.concat(ids))); + const kMenu = mkAction({t:'menu',pid,name}); + return cmdOut(msg, card('TokenGroups', `Added ${chip(String(ps.groups[name].ids.length-before))} to ${chip(name)} on ${chip(pageName(pid))} ${meta(`(size ${ps.groups[name].ids.length})`)}
[🔷 Open Menu](${CMD} do ${kMenu})
`)); + } + + if (sub==='remove'){ + const { name, pageRef } = parseGroupRef(arg1); if(!name) return cmdOut(msg, `Usage: ${CMD} remove <name[@page]>`); + const hit = resolveGroup(name, pageRef); if (hit?.error) return cmdOut(msg, `Error: ${hit.error}`); if (hit?.conflict) return cmdOut(msg, hit.conflict); + if (!hit || hit.notfound) return cmdOut(msg, `No group named ${esc(name)}.`); + const sel=getSelectedGraphics(msg); const ids=sel.map(g=>g.id); if(!ids.length) return cmdOut(msg, `Select tokens to remove.`); + const before=hit.group.ids.length; hit.group.ids=hit.group.ids.filter(id=>!ids.includes(id)); + const kMenu=mkAction({t:'menu',pid:hit.pageid,name:hit.group.name}); + return cmdOut(msg, card('TokenGroups', `Removed ${chip(String(before-hit.group.ids.length))} from ${chip(hit.group.name)} on ${chip(pageName(hit.pageid))} ${meta(`(size ${hit.group.ids.length})`)}
[🔷 Open Menu](${CMD} do ${kMenu})
`)); + } + + if (sub==='rename'){ + const { name:oldName, pageRef } = parseGroupRef(arg1); const nn = arg2; + if(!oldName||!nn) return cmdOut(msg, `Usage: ${CMD} rename <old[@page]> <newName>`); + const hit=resolveGroup(oldName,pageRef); if(hit?.error) return cmdOut(msg, `Error: ${hit.error}`); if(hit?.conflict) return cmdOut(msg, hit.conflict); + if(!hit||hit.notfound) return cmdOut(msg, `No group named ${esc(oldName)}.`); + const ps=getPageStore(hit.pageid); if(ps.groups[nn]) return cmdOut(msg, `A group named ${esc(nn)} already exists on this page.`); + + // Find the actual key used to store this group in the state + let actualKey = null; + Object.entries(ps.groups).forEach(([key, group]) => { + if (group === hit.group) { + actualKey = key; + } + }); + + if (!actualKey) return cmdOut(msg, `Error: Could not find the group's storage key.`); + + // Create new group with new name and same data + ps.groups[nn] = { + name: nn, + pageid: hit.pageid, + ids: hit.group.ids || [] + }; + + // Delete the old group using the actual key + delete ps.groups[actualKey]; + + const kMenu=mkAction({t:'menu',pid:hit.pageid,name:nn}); + return cmdOut(msg, card('TokenGroups', `Renamed ${chip(oldName)} → ${chip(nn)} on ${chip(pageName(hit.pageid))}
[🔷 Open Menu](${CMD} do ${kMenu})
`)); + } + + if (sub==='delete'){ + const { name, pageRef } = parseGroupRef(arg1); if(!name) return cmdOut(msg, `Usage: ${CMD} delete <name[@page]>`); + const hit=resolveGroup(name,pageRef); if(hit?.error) return cmdOut(msg, `Error: ${hit.error}`); if(hit?.conflict) return cmdOut(msg, hit.conflict); + if(!hit||hit.notfound) return cmdOut(msg, `No group named ${esc(name)}.`); + const ps=getPageStore(hit.pageid); delete ps.groups[hit.group.name]; + return cmdOut(msg, card('TokenGroups', `Deleted group ${chip(name)}.`)); + } + + if (sub==='clear'){ + const { name, pageRef } = parseGroupRef(arg1); if(!name) return cmdOut(msg, `Usage: ${CMD} clear <name[@page]>`); + const hit=resolveGroup(name,pageRef); if(hit?.error) return cmdOut(msg, `Error: ${hit.error}`); if(hit?.conflict) return cmdOut(msg, hit.conflict); + if(!hit||hit.notfound) return cmdOut(msg, `No group named ${esc(name)}.`); + hit.group.ids=[]; const kMenu=mkAction({t:'menu',pid:hit.pageid,name:hit.group.name}); + return cmdOut(msg, card('TokenGroups', `Cleared ${chip(hit.group.name)} on ${chip(pageName(hit.pageid))}
[🔷 Open Menu](${CMD} do ${kMenu})
`)); + } + + if (sub==='show'){ + const { name, pageRef } = parseGroupRef(arg1); if(!name) return cmdOut(msg, `Usage: ${CMD} show <name[@page]>`); + const hit=resolveGroup(name,pageRef); if(hit?.error) return cmdOut(msg, `Error: ${hit.error}`); if(hit?.conflict) return cmdOut(msg, hit.conflict); + if(!hit||hit.notfound) return cmdOut(msg, `No group named ${esc(name)}.`); + let shown=0; hit.group.ids.forEach(id=>{const g=getObj('graphic',id); if(g){shown++; sendPing(g.get('left'),g.get('top'),g.get('_pageid'),null,true);}}); + const kMenu=mkAction({t:'menu',pid:hit.pageid,name:hit.group.name}); + return cmdOut(msg, card('TokenGroups', `Pinged ${chip(String(shown))} token${shown===1?'':'s'} in ${chip(hit.group.name)} on ${chip(pageName(hit.pageid))}
[🔷 Open Menu](${CMD} do ${kMenu})
`)); + } + + if (sub==='move'){ + const ref=parseGroupRef(arg1); const layer=(arg2||'').toLowerCase(); + if(!ref.name||!layer) return cmdOut(msg, `Usage: ${CMD} move <name[@page]> <objects|gmlayer|map>`); + if(!LAYERS.has(layer)) return cmdOut(msg, `Error: Invalid layer: ${esc(layer)}.`); + const hit=resolveGroup(ref.name,ref.pageRef); if(hit?.error) return cmdOut(msg, `Error: ${hit.error}`); if(hit?.conflict) return cmdOut(msg, hit.conflict); + if(!hit||hit.notfound) return cmdOut(msg, `No group named ${esc(ref.name)}.`); + let moved=0,missing=0; hit.group.ids = hit.group.ids.filter(id=>{const g=getObj('graphic',id); if(!g){missing++; return false;} try{g.set('layer',layer); moved++;}catch{} return true;}); + const kMenu=mkAction({t:'menu',pid:hit.pageid,name:hit.group.name}); + const missNote = missing ? ` ${meta(`(removed ${missing} missing)`)}` : ''; + return cmdOut(msg, card('TokenGroups', `Moved ${chip(hit.group.name)} → ${chip(layer)} on ${chip(pageName(hit.pageid))} ${meta(`(moved ${moved})`)}${missNote}
[🔷 Open Menu](${CMD} do ${kMenu})
`)); + } + + if (sub==='list'){ + // First priority: page from selected tokens + let pid = pageOfSelection(getSelectedGraphics(msg)); + + // Second priority: try to get current page from player ribbon (only if it has groups AND no other pages have groups) + if(!pid){ + try{ + const playerPage = Campaign().get('playerpageid'); + const S = assertState(); + const playerPageHasGroups = playerPage && S.pages[playerPage] && Object.keys(S.pages[playerPage].groups || {}).length > 0; + const otherPagesHaveGroups = Object.entries(S.pages).some(([p, ps]) => p !== playerPage && Object.keys(ps.groups || {}).length > 0); + + // Only use player page if it has groups AND no other pages have groups + if (playerPageHasGroups && !otherPagesHaveGroups) { + pid = playerPage; + } + } catch {} + } + + if (!pid || !assertState().pages[pid]) { + const S=assertState(); + const all=Object.entries(S.pages).flatMap(([p,ps])=>Object.values(ps.groups).map(g=>({pid:p,name:g.name,count:g.ids.length}))); + if (!all.length) { + if (state[MOD].render==='sc') { + const sc = buildSC('TokenGroups — All Groups',[`No groups saved yet. Try ${CMD} create MyGroup`]); + rememberSC(sc); + return sendSC(msg, sc); + } + return cmdOut(msg, card('TokenGroups — All Groups', `No groups saved yet. Try ${CMD} create MyGroup.`)); + } + if (state[MOD].render==='sc') { + const lines = all.slice(0,20).map(g=>{ + const kMenu=mkAction({t:'menu',pid:g.pid,name:g.name}); + return `${scBold(g.name)} on ${scBold(pageName(g.pid))} (${g.count}) ${scBtn('Menu', 'primary', kMenu)}`; + }); + const sc = buildSC('TokenGroups — Some Groups (up to 20)', lines); + rememberSC(sc); + return sendSC(msg, sc); + } + const rows = all.slice(0,20).map(g=>{ + const kMenu=mkAction({t:'menu',pid:g.pid,name:g.name}); const pn=pageName(g.pid); + return `
+
${chip(g.name)} on ${chip(pn)} ${meta(`(${g.count})`)}
+
${btnB('Menu',`${CMD} do ${kMenu}`)}
+
`; + }).join(''); + return cmdOut(msg, card(`TokenGroups — All Groups (showing up to 20)`, rows) + `
${meta(`Use ${CMD} list all for the full list.`)}
`); + } + return openPageMenu(pid); + } + + // action dispatcher (from menu buttons) + if (sub==='do'){ + const key=arg1; const rec=key&&getAction(key); if(!rec) return cmdOut(msg, `This menu action expired. Reopen the menu and try again.`); + const p=rec.payload; let hit; + switch(p.t){ + case 'menu': hit=getGroupOnPage(p.pid,p.name); if(!hit) return cmdOut(msg, `Group not found anymore: ${esc(p.name)}.`); + return openGroupMenu(hit); + case 'show': hit=getGroupOnPage(p.pid,p.name); if(!hit) return cmdOut(msg, `Group not found anymore: ${esc(p.name)}.`); + let shown=0; hit.group.ids.forEach(id=>{const g=getObj('graphic',id); if(g){shown++; sendPing(g.get('left'),g.get('top'),g.get('_pageid'),null,true);}}); + return cmdOut(msg, card('TokenGroups', `Pinged ${chip(String(shown))} token${shown===1?'':'s'} in ${chip(hit.group.name)} on ${chip(pageName(hit.pageid))}`)); + case 'where': return cmdOut(msg, `${esc(p.name)} — Page: ${esc(pageName(p.pid))} [${esc(p.pid)}]`); + case 'stats': hit=getGroupOnPage(p.pid,p.name); if(!hit) return cmdOut(msg, `Group not found anymore: ${esc(p.name)}.`); + let ok=0,miss=0; (hit.group.ids||[]).forEach(id=> (getObj('graphic',id)?ok++:miss++)); + return cmdOut(msg, [`Stats — Group ${esc(hit.group.name)}`,`Page: ${esc(pageName(hit.pageid))} [${esc(p.pid)}]`,`Size: ${hit.group.ids?.length||0}`,`Resolvable: ${ok}, Missing: ${miss}`].join('
')); + case 'move': hit=getGroupOnPage(p.pid,p.name); if(!hit) return cmdOut(msg, `Group not found anymore: ${esc(p.name)}.`); + if(!LAYERS.has(p.layer)) return cmdOut(msg, `Error: Invalid layer: ${esc(p.layer)}.`); + let moved=0,mr=0; hit.group.ids=hit.group.ids.filter(id=>{const g=getObj('graphic',id); if(!g){mr++; return false;} try{g.set('layer',p.layer); moved++;}catch{} return true;}); + return cmdOut(msg, card('TokenGroups', `Moved ${chip(hit.group.name)} → ${chip(p.layer)} on ${chip(pageName(hit.pageid))} ${meta(`(moved ${moved})`)}${mr?` ${meta(`(removed ${mr} missing)`)}`:''}`)); + case 'purge': hit=getGroupOnPage(p.pid,p.name); if(!hit) return cmdOut(msg, `Group not found anymore: ${esc(p.name)}.`); + const rem=pruneGroup(hit.group); return cmdOut(msg, `Purged ${esc(hit.group.name)}. IDs removed: ${rem}. Size now ${hit.group.ids.length}.`); + case 'clear': hit=getGroupOnPage(p.pid,p.name); if(!hit) return cmdOut(msg, `Group not found anymore: ${esc(p.name)}.`); + hit.group.ids=[]; return cmdOut(msg, card('TokenGroups', `Cleared ${chip(hit.group.name)} on ${chip(pageName(hit.pageid))}`)); + case 'delete': hit=getGroupOnPage(p.pid,p.name); if(!hit) return cmdOut(msg, `Group not found anymore: ${esc(p.name)}.`); + const ps=getPageStore(p.pid); delete ps.groups[p.name]; return cmdOut(msg, card('TokenGroups', `Deleted group ${chip(p.name)}.`)); + } + } + + // unknown + return cmdOut(msg, `Error: Unknown subcommand "${esc(sub)}". Type ${CMD} help.`); + }); +}); diff --git a/TokenGroups/scripts.json b/TokenGroups/scripts.json new file mode 100644 index 000000000..3cc178c60 --- /dev/null +++ b/TokenGroups/scripts.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://github.com/Roll20/roll20-api-scripts/master/_Example%20Script%20-%20Check%20for%20formatting%20details/script.schema.json", + "name": "TokenGroups", + "script": "TokenGroups.js", + "version": "0.7.0", + "previousversions": [], + "description": "An API Script to create and manage named groups of tokens on a page, then apply actions (like switching layers) to the whole group at once. Includes GM-only tools to list groups across pages, find where a group lives, and purge deleted token IDs. Enhanced with ScriptCards integration for better formatting and menus.", + "authors": "Joe Simmons", + "roll20userid": "2447959", + "patreon": "https://www.patreon.com/c/joeuser", + "useroptions": [ + { + "name": "enabled_on_boot", + "type": "checkbox", + "value": "true", + "checked": "checked", + "description": "Start TokenGroups enabled when the API sandbox boots." + }, + { + "name": "render_mode", + "type": "select", + "options": ["html", "sc"], + "default": "sc", + "description": "Render mode: html (basic HTML) or sc (ScriptCards enhanced formatting)." + }, + { + "name": "auto_menu", + "type": "checkbox", + "value": "false", + "checked": "", + "description": "Automatically show menu when creating/updating groups." + } + ], + "dependencies": [], + "optional": ["ScriptCards"], + "modifies": { + "graphic.represents": "read", + "graphic.layer": "write", + "graphic.visible": "write", + "graphic.controlledby": "read", + "page.id": "read", + "page.name": "read" + }, + "conflicts": [] +}