diff --git a/Fix Turn Order/1.0.0/fixTurnOrder.js b/Fix Turn Order/1.0.0/fixTurnOrder.js new file mode 100644 index 000000000..b55dc4eff --- /dev/null +++ b/Fix Turn Order/1.0.0/fixTurnOrder.js @@ -0,0 +1,276 @@ +// Script: Fix Turn Order +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.fixTurnOrder={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.fixTurnOrder.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} + + + +on('ready', () => { + + const scriptName = 'Fix Turn Order'; + const version = '1.0.0'; //version number set here + log('-=> Fix Turnorder v' + version + ' is loaded. Use !fixturnorder to scan for orphaned turns.'); + //1.0.0 Debut + + + +on('chat:message', (msg) => { + if (msg.type !== 'api') return; + if (!playerIsGM(msg.playerid)) return; + + + + + + /* ---------- helpers ---------- */ + + const normalizeForChat = (html) => + html.trim().replace(/\r?\n/g, ''); + + const Pictos = (char) => + `${char}`; + +const getCSS = () => ({ + box: "background:#bababa;border:2px solid #666;border-radius:8px;padding:8px;font-size:14px;color:#222;", + playerBanner: "background:#d6d6d6;border:2px solid #555;border-radius:8px;padding:6px 8px;margin-bottom:6px;line-height:24px;white-space:nowrap;", + playerBannerImage: "height:24px;width:auto;vertical-align:middle;margin-right:6px;", + playerBannerText: "font-size:16px;font-weight:bold;vertical-align:middle;", + header: "font-weight:bold;margin-bottom:6px;", + groupBox: "background:#555;border:1px solid #666;border-radius:8px;padding:6px 8px;margin:8px 0;color:#eee;", + groupHeader: "font-weight:bold;margin:4px 0;color:#eee;", + pageRow: "background:#d0d0d0;border:1px solid #777;border-radius:6px;padding:4px 6px;margin:4px 0;", + tokenRow: "background:#e6e6e6;border:1px solid #999;border-radius:6px;padding:4px 6px;margin:3px 0;", + rowItem: "color:#111;display:inline-block;vertical-align:middle;white-space:nowrap;font-weight:bold", + trashButton: "font-weight:bold;display:inline-block;margin-right:6px;padding:2px 6px;background:#a44;color:#eee;text-decoration:none;border-radius:4px;font-size:14px;", + tokenImage: "display:inline-block;max-height:35px;max-width:35px;border-radius:4px;margin-right:6px;vertical-align:middle;", + tokenName: "font-weight:bold;color:#111;display:inline-block;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;", + footer: "margin-top:10px;text-align:right;", + footerLeft: "float:left;", + confirmButton: "font-weight:bold;padding:3px 8px;background:#156616;color:#eee;text-decoration:none;border-radius:4px;font-size:11px;", + messageContainer: "background:#dcdcdc;border:3px solid #666;border-radius:8px;padding:8px;font-size:14px;color:#222;", + messageTitle: "font-size:16px;font-weight:bold;margin-bottom:4px;", + messageButton: "padding:2px 6px;background:#777;color:#eee;text-decoration:none;border-radius:4px;font-size:14px;" +}); + + + const PLAYER_FLAG_SRC = `data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjkiIGhlaWdodD0iMzUiIHZpZXdCb3g9IjAgMCAyOSAzNSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI4IDM0TDE1IDI0LjRMMiAzNFYzLjZDMiAyLjcyIDIuOTc1IDIgNC4xNjY2NyAySDI1LjgzMzNDMjcuMDI1IDIgMjggMi43MiAyOCAzLjZWMzRaIiBzdHJva2U9IiMwQzBDMEMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPHBhdGggZD0iTTI3IDMzTDE0IDIzLjRMMSAzM1YyLjZDMSAxLjcyIDEuOTc1IDEgMy4xNjY2NyAxSDI0LjgzMzNDMjYuMDI1IDEgMjcgMS43MiAyNyAyLjZWMzNaIiBmaWxsPSIjRkZCQzMzIiBzdHJva2U9IiM5OTY3MDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01LjUgM0M2LjMyODQzIDMgNyAzLjQ0NzcyIDcgNEw3IDIzQzcgMjMuNTUyMyA2LjMyODQzIDI0IDUuNSAyNEM0LjY3MTU3IDI0IDQgMjMuNTUyMyA0IDIzTDQgNEM0IDMuNDQ3NzIgNC42NzE1NyAzIDUuNSAzWiIgZmlsbD0iI0ZGREQ5OSIvPgo8L3N2Zz4K`; + + const sendHTML = (html) => { + sendChat(scriptName, normalizeForChat(html), null, { noarchive: true }); + }; + + const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => { + const css = getCSS(); + let title, message; + + if (messageOrUndefined === undefined) { + title = scriptName; + message = titleOrMessage; + } else { + title = titleOrMessage || scriptName; + message = messageOrUndefined; + } + + message = String(message).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, label, command) => + `${label}` + ); + + const html = + `
` + + `
${title}
` + + `${message}` + + `
`; + + sendChat(scriptName, `${isPublic ? '' : '/w gm '}${normalizeForChat(html)}`, null, { noarchive: true }); + }; + + const getPageForPlayer = (playerid) => { + const player = getObj('player', playerid); + if (playerIsGM(playerid)) return player.get('lastpage') || Campaign().get('playerpageid'); + const psp = Campaign().get('playerspecificpages'); + if (psp && psp[playerid]) return psp[playerid]; + return Campaign().get('playerpageid'); + }; + + /* ---------- routing ---------- */ + + const args = msg.content.trim().split(/\s+/); + if (args[0] !== '!fixturnorder') return; + + const playerPageId = Campaign().get('playerpageid'); + const gmPageId = getPageForPlayer(msg.playerid); + + /* ---------- deletions ---------- */ + + if (args.length > 1) { + if (gmPageId !== playerPageId) return; + +let turnorderRaw = Campaign().get('turnorder'); +if (!turnorderRaw || turnorderRaw === "") { + sendStyledMessage('This Turnorder looks correct.'); + return; +} + let turnorder = JSON.parse(turnorderRaw); + let modified = false; + +if (args[1] === '--clearall') { + let turnorderRaw = Campaign().get('turnorder'); + + if (!turnorderRaw || turnorderRaw === "") { + sendStyledMessage('Turn order is already empty.'); + return; + } + + Campaign().set('turnorder', "[]"); + sendStyledMessage('The entire Turn Tracker has been cleared.'); + return; +} + + + + if (args[1] === '--delete' && args[2]) { + const token = getObj('graphic', args[2]); + const page = token && getObj('page', token.get('pageid')); + const before = turnorder.length; + turnorder = turnorder.filter(e => e.id !== args[2]); + modified = turnorder.length !== before; + + if (modified && token) { + sendStyledMessage(`Turn for "${token.get('name') || 'Unnamed Token'}" from page "${page ? page.get('name') : 'Unknown Page'}" was deleted.`); + } + } + + if (args[1] === '--deletepage' && args[2]) { + const page = getObj('page', args[2]); + const before = turnorder.length; + + turnorder = turnorder.filter(e => { + if (!e.id || e.id === '-1') return true; + const t = getObj('graphic', e.id); + return !t || t.get('pageid') !== args[2]; + }); + + modified = turnorder.length !== before; + + if (modified) { + sendStyledMessage(`All turns from page "${page ? page.get('name') : 'Unknown Page'}" were deleted.`); + } + } + + if (modified) Campaign().set('turnorder', JSON.stringify(turnorder)); + return; + } + + /* ---------- page mismatch ---------- */ + + if (gmPageId !== playerPageId) { + const css = getCSS(); + const gmPage = getObj('page', gmPageId); + const playerPage = getObj('page', playerPageId); + + + sendStyledMessage( + 'Page Mismatch', + `You are viewing the page:
+
+ ${(gmPage && gmPage.get('name')) || 'Unknown Page'} +
+
+ but the player ribbon is on:
+
+ + + ${(playerPage && playerPage.get('name')) || 'Unknown Page'} +
+
+ Switch pages before running this command. + ` + ); + return; +} + + + /* ---------- scan + UI ---------- */ + + let turnorderRaw = Campaign().get('turnorder'); + if (!turnorderRaw) { + sendStyledMessage('This Turnorder looks correct.'); + return; + } + + const turnorder = JSON.parse(turnorderRaw); + const tokensByPage = {}; + const pageNames = {}; + const css = getCSS(); + + turnorder.forEach(e => { + if (!e.id || e.id === '-1') return; + const t = getObj('graphic', e.id); + if (!t || t.get('pageid') === playerPageId) return; + const pid = t.get('pageid'); + tokensByPage[pid] = tokensByPage[pid] || []; + tokensByPage[pid].push(t); + if (!pageNames[pid]) { + const p = getObj('page', pid); + pageNames[pid] = p ? p.get('name') : 'Unknown Page'; + } + }); + + const pageIds = Object.keys(tokensByPage); + if (!pageIds.length) { + sendStyledMessage('This Turnorder looks correct.'); + return; + } + +const playerPage = getObj('page', playerPageId); +const currentPageName = playerPage ? playerPage.get('name') : 'Unknown Page'; + + + +let html = `
`; +html += `
This is the active player page; the turn entries below it are from other pages.
`; +html += `
${currentPageName}
`; + +pageIds.forEach(pid => { + + html += `
`; + + html += `
Delete all turns from this page:
`; + html += + `
` + + `${Pictos('#')}` + + `${pageNames[pid]}` + + `
`; + + html += `
Delete individual off-page turns:
`; + + tokensByPage[pid].forEach(t => { + html += + `
` + + `${Pictos('#')}` + + `` + + `${t.get('name') || 'Unnamed Token'}` + + `
`; + }); + + html += `
`; +}); + +html += + `
` + + `` + + `Clear all turns` + + `` + + `Check again?` + + `
`; + + + sendHTML(html); +}); +}); + +{try{throw new Error('');}catch(e){API_Meta.fixTurnOrder.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.fixTurnOrder.offset);}} diff --git a/Fix Turn Order/fixTurnOrder.js b/Fix Turn Order/fixTurnOrder.js new file mode 100644 index 000000000..b55dc4eff --- /dev/null +++ b/Fix Turn Order/fixTurnOrder.js @@ -0,0 +1,276 @@ +// Script: Fix Turn Order +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.fixTurnOrder={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.fixTurnOrder.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} + + + +on('ready', () => { + + const scriptName = 'Fix Turn Order'; + const version = '1.0.0'; //version number set here + log('-=> Fix Turnorder v' + version + ' is loaded. Use !fixturnorder to scan for orphaned turns.'); + //1.0.0 Debut + + + +on('chat:message', (msg) => { + if (msg.type !== 'api') return; + if (!playerIsGM(msg.playerid)) return; + + + + + + /* ---------- helpers ---------- */ + + const normalizeForChat = (html) => + html.trim().replace(/\r?\n/g, ''); + + const Pictos = (char) => + `${char}`; + +const getCSS = () => ({ + box: "background:#bababa;border:2px solid #666;border-radius:8px;padding:8px;font-size:14px;color:#222;", + playerBanner: "background:#d6d6d6;border:2px solid #555;border-radius:8px;padding:6px 8px;margin-bottom:6px;line-height:24px;white-space:nowrap;", + playerBannerImage: "height:24px;width:auto;vertical-align:middle;margin-right:6px;", + playerBannerText: "font-size:16px;font-weight:bold;vertical-align:middle;", + header: "font-weight:bold;margin-bottom:6px;", + groupBox: "background:#555;border:1px solid #666;border-radius:8px;padding:6px 8px;margin:8px 0;color:#eee;", + groupHeader: "font-weight:bold;margin:4px 0;color:#eee;", + pageRow: "background:#d0d0d0;border:1px solid #777;border-radius:6px;padding:4px 6px;margin:4px 0;", + tokenRow: "background:#e6e6e6;border:1px solid #999;border-radius:6px;padding:4px 6px;margin:3px 0;", + rowItem: "color:#111;display:inline-block;vertical-align:middle;white-space:nowrap;font-weight:bold", + trashButton: "font-weight:bold;display:inline-block;margin-right:6px;padding:2px 6px;background:#a44;color:#eee;text-decoration:none;border-radius:4px;font-size:14px;", + tokenImage: "display:inline-block;max-height:35px;max-width:35px;border-radius:4px;margin-right:6px;vertical-align:middle;", + tokenName: "font-weight:bold;color:#111;display:inline-block;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;", + footer: "margin-top:10px;text-align:right;", + footerLeft: "float:left;", + confirmButton: "font-weight:bold;padding:3px 8px;background:#156616;color:#eee;text-decoration:none;border-radius:4px;font-size:11px;", + messageContainer: "background:#dcdcdc;border:3px solid #666;border-radius:8px;padding:8px;font-size:14px;color:#222;", + messageTitle: "font-size:16px;font-weight:bold;margin-bottom:4px;", + messageButton: "padding:2px 6px;background:#777;color:#eee;text-decoration:none;border-radius:4px;font-size:14px;" +}); + + + const PLAYER_FLAG_SRC = `data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjkiIGhlaWdodD0iMzUiIHZpZXdCb3g9IjAgMCAyOSAzNSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI4IDM0TDE1IDI0LjRMMiAzNFYzLjZDMiAyLjcyIDIuOTc1IDIgNC4xNjY2NyAySDI1LjgzMzNDMjcuMDI1IDIgMjggMi43MiAyOCAzLjZWMzRaIiBzdHJva2U9IiMwQzBDMEMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPHBhdGggZD0iTTI3IDMzTDE0IDIzLjRMMSAzM1YyLjZDMSAxLjcyIDEuOTc1IDEgMy4xNjY2NyAxSDI0LjgzMzNDMjYuMDI1IDEgMjcgMS43MiAyNyAyLjZWMzNaIiBmaWxsPSIjRkZCQzMzIiBzdHJva2U9IiM5OTY3MDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01LjUgM0M2LjMyODQzIDMgNyAzLjQ0NzcyIDcgNEw3IDIzQzcgMjMuNTUyMyA2LjMyODQzIDI0IDUuNSAyNEM0LjY3MTU3IDI0IDQgMjMuNTUyMyA0IDIzTDQgNEM0IDMuNDQ3NzIgNC42NzE1NyAzIDUuNSAzWiIgZmlsbD0iI0ZGREQ5OSIvPgo8L3N2Zz4K`; + + const sendHTML = (html) => { + sendChat(scriptName, normalizeForChat(html), null, { noarchive: true }); + }; + + const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => { + const css = getCSS(); + let title, message; + + if (messageOrUndefined === undefined) { + title = scriptName; + message = titleOrMessage; + } else { + title = titleOrMessage || scriptName; + message = messageOrUndefined; + } + + message = String(message).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, label, command) => + `${label}` + ); + + const html = + `
` + + `
${title}
` + + `${message}` + + `
`; + + sendChat(scriptName, `${isPublic ? '' : '/w gm '}${normalizeForChat(html)}`, null, { noarchive: true }); + }; + + const getPageForPlayer = (playerid) => { + const player = getObj('player', playerid); + if (playerIsGM(playerid)) return player.get('lastpage') || Campaign().get('playerpageid'); + const psp = Campaign().get('playerspecificpages'); + if (psp && psp[playerid]) return psp[playerid]; + return Campaign().get('playerpageid'); + }; + + /* ---------- routing ---------- */ + + const args = msg.content.trim().split(/\s+/); + if (args[0] !== '!fixturnorder') return; + + const playerPageId = Campaign().get('playerpageid'); + const gmPageId = getPageForPlayer(msg.playerid); + + /* ---------- deletions ---------- */ + + if (args.length > 1) { + if (gmPageId !== playerPageId) return; + +let turnorderRaw = Campaign().get('turnorder'); +if (!turnorderRaw || turnorderRaw === "") { + sendStyledMessage('This Turnorder looks correct.'); + return; +} + let turnorder = JSON.parse(turnorderRaw); + let modified = false; + +if (args[1] === '--clearall') { + let turnorderRaw = Campaign().get('turnorder'); + + if (!turnorderRaw || turnorderRaw === "") { + sendStyledMessage('Turn order is already empty.'); + return; + } + + Campaign().set('turnorder', "[]"); + sendStyledMessage('The entire Turn Tracker has been cleared.'); + return; +} + + + + if (args[1] === '--delete' && args[2]) { + const token = getObj('graphic', args[2]); + const page = token && getObj('page', token.get('pageid')); + const before = turnorder.length; + turnorder = turnorder.filter(e => e.id !== args[2]); + modified = turnorder.length !== before; + + if (modified && token) { + sendStyledMessage(`Turn for "${token.get('name') || 'Unnamed Token'}" from page "${page ? page.get('name') : 'Unknown Page'}" was deleted.`); + } + } + + if (args[1] === '--deletepage' && args[2]) { + const page = getObj('page', args[2]); + const before = turnorder.length; + + turnorder = turnorder.filter(e => { + if (!e.id || e.id === '-1') return true; + const t = getObj('graphic', e.id); + return !t || t.get('pageid') !== args[2]; + }); + + modified = turnorder.length !== before; + + if (modified) { + sendStyledMessage(`All turns from page "${page ? page.get('name') : 'Unknown Page'}" were deleted.`); + } + } + + if (modified) Campaign().set('turnorder', JSON.stringify(turnorder)); + return; + } + + /* ---------- page mismatch ---------- */ + + if (gmPageId !== playerPageId) { + const css = getCSS(); + const gmPage = getObj('page', gmPageId); + const playerPage = getObj('page', playerPageId); + + + sendStyledMessage( + 'Page Mismatch', + `You are viewing the page:
+
+ ${(gmPage && gmPage.get('name')) || 'Unknown Page'} +
+
+ but the player ribbon is on:
+
+ + + ${(playerPage && playerPage.get('name')) || 'Unknown Page'} +
+
+ Switch pages before running this command. + ` + ); + return; +} + + + /* ---------- scan + UI ---------- */ + + let turnorderRaw = Campaign().get('turnorder'); + if (!turnorderRaw) { + sendStyledMessage('This Turnorder looks correct.'); + return; + } + + const turnorder = JSON.parse(turnorderRaw); + const tokensByPage = {}; + const pageNames = {}; + const css = getCSS(); + + turnorder.forEach(e => { + if (!e.id || e.id === '-1') return; + const t = getObj('graphic', e.id); + if (!t || t.get('pageid') === playerPageId) return; + const pid = t.get('pageid'); + tokensByPage[pid] = tokensByPage[pid] || []; + tokensByPage[pid].push(t); + if (!pageNames[pid]) { + const p = getObj('page', pid); + pageNames[pid] = p ? p.get('name') : 'Unknown Page'; + } + }); + + const pageIds = Object.keys(tokensByPage); + if (!pageIds.length) { + sendStyledMessage('This Turnorder looks correct.'); + return; + } + +const playerPage = getObj('page', playerPageId); +const currentPageName = playerPage ? playerPage.get('name') : 'Unknown Page'; + + + +let html = `
`; +html += `
This is the active player page; the turn entries below it are from other pages.
`; +html += `
${currentPageName}
`; + +pageIds.forEach(pid => { + + html += `
`; + + html += `
Delete all turns from this page:
`; + html += + `
` + + `${Pictos('#')}` + + `${pageNames[pid]}` + + `
`; + + html += `
Delete individual off-page turns:
`; + + tokensByPage[pid].forEach(t => { + html += + `
` + + `${Pictos('#')}` + + `` + + `${t.get('name') || 'Unnamed Token'}` + + `
`; + }); + + html += `
`; +}); + +html += + `
` + + `` + + `Clear all turns` + + `` + + `Check again?` + + `
`; + + + sendHTML(html); +}); +}); + +{try{throw new Error('');}catch(e){API_Meta.fixTurnOrder.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.fixTurnOrder.offset);}} diff --git a/Fix Turn Order/readme.md b/Fix Turn Order/readme.md new file mode 100644 index 000000000..b127e06ce --- /dev/null +++ b/Fix Turn Order/readme.md @@ -0,0 +1,31 @@ +New Script: +# Fix Turn Order + +You call out “Roll initiative!” and then realize the Turn Tracker is a museum exhibit. Half the entries are from a fight three rooms ago, some forgotten goblin is still somehow in the order, and now you’re squinting at the list trying to remember what’s real. Now it’s a debate: which entries are new, which are old, and fistfights are breaking out between the fumblers who want to re-roll, and the critters who want you to just fix it. The table waits while you perform turn-order archaeology, and the tension drains out of the scene. + +**Fix Turn Order** is a GM-only Roll20 API script that helps clean up the Turn Order when it contains leftover token entries from other pages because you forgot to clear the tracker. + +## What It Does + +When run, the script checks the Turn Order and compares each token entry to the current player page. Any turns belonging to tokens that are not on the active page are listed in a clear chat report, grouped by the page they came from. + +From that report, the GM can: + +- Delete all off-page turns from a specific page at once +- Delete individual off-page turns one by one + +Nothing happens automatically. The script only runs when invoked, and no Turn Order entries are removed unless the GM clicks a button. + +## What It Does *Not* Do + +- It does not monitor the game continuously +- It does not remove turns for tokens on the current player page +- It does not affect custom Turn Order items that are not tied to tokens (such as lair actions, round counters, or reminders) + +## Usage + +**Base Command:** `!fixturnorder` + +Running the command opens an interactive chat report with buttons to review and clean up off-page turns. + +This script is to help GMs who want a simple, safe way to clean up forgotten Turn Order entries without disrupting the current encounter or custom tracker items. \ No newline at end of file diff --git a/Fix Turn Order/script.json b/Fix Turn Order/script.json new file mode 100644 index 000000000..69c989c45 --- /dev/null +++ b/Fix Turn Order/script.json @@ -0,0 +1,15 @@ +{ + "name": "Fix Turn Order", + "script": "fixTurnOrder.js", + "version": "1.0.0", + "description": "# Fix Turn Order\n\nFix Turn Order is a GM-only Roll20 API script that helps clean up the Turn Order when it contains leftover token entries from other pages. This is a common issue when moving between maps and forgetting to clear the tracker.\n\n---\n\n## What It Does\n\nWhen run, the script checks the Turn Order and compares each token entry to the current player page. Any turns belonging to tokens that are not on the active page are listed in a clear chat report, grouped by the page they came from.\n\nFrom that report, the GM can:\n\n- Delete all off-page turns from a specific page at once\n- Delete individual off-page turns one by one\n\nNothing happens automatically. The script only runs when invoked, and no Turn Order entries are removed unless the GM clicks a button.\n\n---\n\n## What It Does *Not* Do\n\n- It does not monitor the game continuously\n- It does not remove turns for tokens on the current player page\n- It does not affect custom Turn Order items that are not tied to tokens (such as lair actions, round counters, or reminders)\n\n---\n\n## Usage\n\n**Base Command:** `!fixturnorder`\n\nRunning the command opens an interactive chat report with buttons to review and clean up off-page turns.\n\n---\n\nDesigned for GMs who frequently move between pages and want a quick, safe way to clean up forgotten Turn Order entries without touching custom or manual items.", + "authors": "Keith Curtis", + "roll20userid": "162065", + "dependencies": [], + "modifies": { + "campaign": "read", + "turnorder": "write" + }, + "conflicts": [], + "previousversions": ["1.0.0"] +} \ No newline at end of file diff --git a/PinTool/1.0.2/PinTool.js b/PinTool/1.0.2/PinTool.js new file mode 100644 index 000000000..d77a350a5 --- /dev/null +++ b/PinTool/1.0.2/PinTool.js @@ -0,0 +1,1453 @@ +// Script: PinTool +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +var API_Meta = API_Meta || {}; //eslint-disable-line no-var +API_Meta.PinTool = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; +{ try { throw new Error(''); } catch (e) { API_Meta.PinTool.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - 6); } } + +on("ready", () => { + + const version = '1.0.2'; //version number set here + log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); + //1.0.2 Cleaned up Help Documentation. Added basic control panel + //1.0.1 Added burndown to many parts to account for timeouts - Thanks to the Aaron + //1.0.0 Debut + + + // ============================================================ + // HELPERS + // ============================================================ + + const scriptName = "PinTool"; + const PINTOOL_HELP_NAME = "Help: PinTool"; + const PINTOOL_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; + + const PINTOOL_HELP_TEXT = ` +

PinTool Script Help

+ +

+PinTool provides bulk creation, inspection, and modification of map pins. +It also provides commands for conversion of old-style note tokens to new +map pins. +

+ + + +

Base Command: !pintool

+ +

Primary Commands

+ + + +
+ +

Set Command

+ +

Format:

+
+!pintool --set property|value [property|value ...] [filter|target]
+
+ +

All supplied properties apply to every pin matched by the filter.

+ +

Filter Options

+ + + +

Settable Properties

+ +

+Values are case-sensitive unless otherwise noted. +Values indicated by "" mean no value. +Do not type quotation marks. +See examples at the end of this document. +

+ +

Position

+ + +

Text & Content

+ + +

Links

+ + +

Visibility

+ + +

Notes Behavior

+ + +

Appearance

+ + +

State

+ + +
+ +

Convert Command

+ +

+The convert command builds or updates a handout by extracting data +from map tokens. +

+ +

Format:

+
+!pintool --convert key|value key|value ...
+
+ +

+A single token must be selected. +All tokens on the same page that represent the +same character are processed. +All note pins must represent a common character. +

+ +

Required Arguments

+ + + +

Optional Arguments

+ + + +

Format may be:

+ + +

Behavior Flags

+ + + +

Convert Rules

+ + + +
+ +

Place Command

+ +

+The place command creates or replaces map pins on the current page +based on headers found in an existing handout. +

+ +

Format:

+
+!pintool --place name|h1–h4 handout|Exact Handout Name
+
+ +

Required Arguments

+ + + + + + +

Behavior

+ + + +

Notes

+ + + +
+ +

Purge Command

+ +

+The purge command removes all tokens on the map similar to the selected token (i.e. that represent the same character), or pins similar to the selected pin (i.e. that are linked to the same handout). +

+ +

Format:

+
+!pintool --purge tokens
+
+ +

Required Arguments

+ + +
+ +

Example Macros

+ + + +
+ +

General Rules

+ + +`; + + let sender; + + const getPageForPlayer = (playerid) => { + let player = getObj('player', playerid); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + + let psp = Campaign().get('playerspecificpages'); + if (psp[playerid]) { + return psp[playerid]; + } + + return Campaign().get('playerpageid'); + }; + + function handleHelp(msg) { + if (msg.type !== "api") return; + + let handout = findObjs( + { + _type: "handout", + name: PINTOOL_HELP_NAME + })[0]; + + if (!handout) { + handout = createObj("handout", + { + name: PINTOOL_HELP_NAME, + archived: false + }); + handout.set("avatar", PINTOOL_HELP_AVATAR); + } + + handout.set("notes", PINTOOL_HELP_TEXT); + + const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; + + const box = ` +
+
PinTool Help
+ Open Help Handout +
`.trim().replace(/\r?\n/g, ''); + + sendChat("PinTool", `/w gm ${box}`); + } + + + function getCSS() { + return { + messageContainer: + "background:#1e1e1e;" + + "border:1px solid #444;" + + "border-radius:6px;" + + "padding:8px;" + + "margin:4px 0;" + + "font-family:Arial, sans-serif;" + + "color:#ddd;", + + messageTitle: + "font-weight:bold;" + + "font-size:14px;" + + "margin-bottom:6px;" + + "color:#fff;", + + messageButton: + "display:inline-block;" + + "padding:2px 6px;" + + "margin:2px 4px 2px 0;" + + "border-radius:4px;" + + "background:#333;" + + "border:1px solid #555;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-weight:bold;" + + "font-size:12px;" + + "white-space:nowrap;", + + sectionLabel: + "display:block;" + + "margin-top:6px;" + + "font-weight:bold;" + + "color:#ccc;", + + panel: + "background:#ccc;" + + "border:1px solid #444;" + + "border-radius:6px;" + + "padding:8px;" + + "margin:4px 0;" + + "font-family:Arial, sans-serif;" + + "color:#111;", + + + panelButtonLeft: + "display:inline-block;" + + "padding:2px 6px;" + + "border-radius:14px 0 0 14px;" + + "background:#333;" + + "border:1px solid #555;" + + "border-right:none;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-size:12px;" + + "margin-bottom:4px;", + + panelButtonAll: + "display:inline-block;" + + "padding:2px 6px;" + + "border-radius:0 14px 14px 0;" + + "background:#222;" + + "border:1px solid #555;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-size:11px;" + + "font-weight:bold;" + + "margin-right:10px;" + + "margin-bottom:4px;" + + }; + } + + function splitButton(label, command) { + const css = getCSS(); + + return ( + `${label}` + + `++` + ); + } + + function messageButton(label, command) { + const css = getCSS(); + + return ( + `${label}` + ); + } + + function showControlPanel() { + const css = getCSS(); + + const panel = + `
` + + + `
Click on button name to affect selected pins, or "++" to apply that setting to all pins on page
` + + + `
Size
` + + splitButton("Teeny", "!pintool --set scale|.25") + + splitButton("Tiny", "!pintool --set scale|.5") + + splitButton("Sm", "!pintool --set scale|.75") + + splitButton("Med", "!pintool --set scale|1") + + splitButton("Lrg", "!pintool --set scale|1.25") + + splitButton("Huge", "!pintool --set scale|1.5") + + splitButton("Gig", "!pintool --set scale|2") + + `
` + + + `
Visible
` + + splitButton("GM Only", "!pintool --set visibleTo|") + + splitButton("All Players", "!pintool --set visibleTo|all") + + `
` + + + `
Blockquote as player text
` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + + splitButton("Off", "!pintool --set autoNotesType|") + + `
` + + + `
Display
` + + splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + + splitButton("Custom", "!pintool --set imageDesynced|true imageVisibleTo|all") + + `
` + + + `
Place Pins from Handout
` + + messageButton("Place Pins from Handout", "!pintool --place handout|?{Exact Handout Name} name|?{Choose Header Level for Map Pins|h1,h1|h2,h2|h3,h3|h4,h4}") + + `
` + + + `
Delete All Pins on Page
Select an example pin first.
` + + messageButton("Delete All Pins on Page", "!pintool --purge pins") + + `
` + + + `
`; + + sendStyledMessage( + "PinTool Control Panel", + panel + ); + } + + + function handlePurge(msg, args) { + if (!args.length) return; + + const mode = args[0]; + if (mode !== "tokens" && mode !== "pins") return; + + const confirmed = args.includes("--confirm"); + + // -------------------------------- + // CONFIRM PATH (no selection) + // -------------------------------- + if (confirmed) { + let charId, handoutId, pageId; + + args.forEach(a => { + if (a.startsWith("char|")) charId = a.slice(5); + if (a.startsWith("handout|")) handoutId = a.slice(8); + if (a.startsWith("page|")) pageId = a.slice(5); + }); + + if (!pageId) return; + + /* ===== PURGE TOKENS (CONFIRM) ===== */ + if (mode === "tokens" && charId) { + const char = getObj("character", charId); + if (!char) return; + + const charName = char.get("name") || "Unknown Character"; + + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); + + if (!targets.length) return; + + targets.forEach(t => t.remove()); + + sendChat( + "PinTool", + `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` + ); + } + + /* ===== PURGE PINS (CONFIRM) ===== */ + if (mode === "pins" && handoutId) { + const handout = getObj("handout", handoutId); + if (!handout) return; + + const handoutName = handout.get("name") || "Unknown Handout"; + + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); + + if (!targets.length) return; + + const count = targets.length; + + const burndown = () => { + let p = targets.shift(); + if (p) { + p.remove(); + setTimeout(burndown, 0); + } else { + sendChat( + "PinTool", + `/w gm ✅ Deleted ${count} pin(s) linked to "${_.escape(handoutName)}".` + ); + } + }; + burndown(); + } + + return; + } + + // -------------------------------- + // INITIAL PATH (requires selection) + // -------------------------------- + if (!msg.selected || msg.selected.length !== 1) return; + + const sel = msg.selected[0]; + + /* =============================== + PURGE TOKENS (INITIAL) + =============================== */ + if (mode === "tokens" && sel._type === "graphic") { + const token = getObj("graphic", sel._id); + if (!token) return; + + const charId = token.get("represents"); + if (!charId) return; + + const pageId = token.get("_pageid"); + const char = getObj("character", charId); + const charName = char?.get("name") || "Unknown Character"; + + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); + + if (!targets.length) return; + + sendStyledMessage( + "Confirm Purge", + ` +
+
+ This will permanently delete ${targets.length} token(s) +
+
+ representing ${_.escape(charName)} on this page. +
+ +
+ This cannot be undone. +
+ +
+ + Click here to confirm + +
+
+ ` + ); + + return; + } + + /* =============================== + PURGE PINS (INITIAL) + =============================== */ + if (mode === "pins" && sel._type === "pin") { + const pin = getObj("pin", sel._id); + if (!pin) return; + + const handoutId = pin.get("link"); + if (!handoutId) return; + + const pageId = pin.get("_pageid"); + const handout = getObj("handout", handoutId); + const handoutName = handout?.get("name") || "Unknown Handout"; + + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); + + if (!targets.length) return; + + sendStyledMessage( + "Confirm Purge", + `

This will permanently delete ${targets.length} pin(s)
+ linked to handout ${_.escape(handoutName)}.

+

This cannot be undone.

+

+ + Click here to confirm + +

` + ); + return; + } + } + + + + function normalizeForChat(html) { + return String(html).replace(/\r\n|\r|\n/g, "").trim(); + } + + const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => { + const css = getCSS(); + let title, message; + + if (messageOrUndefined === undefined) { + title = scriptName; + message = titleOrMessage; + } + else { + title = titleOrMessage || scriptName; + message = messageOrUndefined; + } + + message = String(message).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, label, command) => + `${label}` + ); + + const html = + `
+
${title}
+ ${message} +
`; + + sendChat( + scriptName, + `${isPublic ? "" : "/w gm "}${normalizeForChat(html)}`, + null, + { + noarchive: true + } + ); + }; + + function sendError(msg) { + sendStyledMessage("PinTool — Error", msg); + } + + function sendWarning(msg) { + sendStyledMessage("PinTool — Warning", msg); + } + + // ============================================================ + // IMAGE → CHAT + // ============================================================ + + function handleImageToChat(encodedUrl) { + let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); + if (!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); + + const imageHtml = + `
` + + `` + + `
` + + `` + + `Send to All` + + `
` + + `
`; + + sendChat("PinTool", `/w "${sender}" ${imageHtml}`, + null, + { noarchive: true }); + } + + + function handleImageToChatAll(encodedUrl) { + let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); + if (!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); + + sendChat( + "PinTool", `
`, + null, + { noarchive: true }); + } + + // ============================================================ + // SET MODE (pins) + // ============================================================ + + const PIN_SET_PROPERTIES = { + x: "number", + y: "number", + title: "string", + notes: "string", + image: "string", + tooltipImage: "string", + link: "string", + linkType: ["", "handout"], + subLink: "string", + subLinkType: ["", "headerPlayer", "headerGM"], + visibleTo: ["", "all"], + tooltipVisibleTo: ["", "all"], + nameplateVisibleTo: ["", "all"], + imageVisibleTo: ["", "all"], + notesVisibleTo: ["", "all"], + gmNotesVisibleTo: ["", "all"], + autoNotesType: ["", "blockquote"], + scale: + { + min: 0.25, + max: 2.0 + }, + imageDesynced: "boolean", + notesDesynced: "boolean", + gmNotesDesynced: "boolean" + }; + + function handleSet(msg, tokens) { + const flags = {}; + let filterRaw = ""; + + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + const idx = t.indexOf("|"); + if (idx === -1) continue; + + const key = t.slice(0, idx); + let val = t.slice(idx + 1); + + if (key === "filter") { + const parts = [val]; + let j = i + 1; + while (j < tokens.length && !tokens[j].includes("|")) { + parts.push(tokens[j++]); + } + filterRaw = parts.join(" ").trim(); + i = j - 1; + continue; + } + + if (!PIN_SET_PROPERTIES.hasOwnProperty(key)) + return sendError(`Unknown pin property, or improper capitalization: ${key}`); + + const parts = [val]; + let j = i + 1; + while (j < tokens.length && !tokens[j].includes("|")) { + parts.push(tokens[j++]); + } + + flags[key] = parts.join(" ").trim(); + i = j - 1; + } + + if (!Object.keys(flags).length) + return sendError("No valid properties supplied to --set."); + + + + + const pageId = getPageForPlayer(msg.playerid); + /* + (Campaign().get("playerspecificpages") || {})[msg.playerid] || + Campaign().get("playerpageid"); +*/ + + let pins = []; + + if (!filterRaw || filterRaw === "selected") { + if (!msg.selected?.length) return sendError("No pins selected."); + pins = msg.selected + .map(s => getObj("pin", s._id)) + .filter(p => p && p.get("_pageid") === pageId); + } + else if (filterRaw === "all") { + pins = findObjs( + { + _type: "pin", + _pageid: pageId + }); + } + else { + pins = filterRaw.split(/\s+/) + .map(id => getObj("pin", id)) + .filter(p => p && p.get("_pageid") === pageId); + } + + if (!pins.length) + return sendWarning("Filter matched no pins on the current page."); + + const updates = {}; + try { + Object.entries(flags).forEach(([key, raw]) => { + const spec = PIN_SET_PROPERTIES[key]; + let value = raw; + + if (spec === "boolean") value = raw === "true"; + else if (spec === "number") value = Number(raw); + else if (Array.isArray(spec) && !spec.includes(value)) throw 0; + else if (!Array.isArray(spec) && typeof spec === "object") { + value = Number(raw); + if (value < spec.min || value > spec.max) throw 0; + } + updates[key] = value; + }); + } + catch { + return sendError("Invalid value supplied to --set."); + } + pins.forEach(p => p.set(updates)); + //sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); + } + + // ============================================================ + // CONVERT MODE (tokens → handout) + // ============================================================ + + function sendConvertHelp() { + sendStyledMessage( + "PinTool — Convert", + "Usage
!pintool --convert name|h2 title|My Handout [options]" + ); + } + + // ============================================================ + // CONVERT MODE + // ============================================================ + + function handleConvert(msg, tokens) { + + if (!tokens.length) { + sendConvertHelp(); + return; + } + + // ---------------- Parse convert specs (greedy tail preserved) ---------------- + const flags = {}; + const orderedSpecs = []; + + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + const idx = t.indexOf("|"); + if (idx === -1) continue; + + const key = t.slice(0, idx).toLowerCase(); + let val = t.slice(idx + 1); + + const parts = [val]; + let j = i + 1; + + while (j < tokens.length) { + const next = tokens[j]; + if (next.indexOf("|") !== -1) break; + parts.push(next); + j++; + } + + val = parts.join(" "); + flags[key] = val; + orderedSpecs.push( + { + key, + val + }); + i = j - 1; + } + + // ---------------- Required args ---------------- + if (!flags.title) return sendError("--convert requires title|"); + if (!flags.name) return sendError("--convert requires name|h1–h5"); + + const nameMatch = flags.name.match(/^h([1-5])$/i); + if (!nameMatch) return sendError("name must be h1 through h5"); + + const nameHeaderLevel = parseInt(nameMatch[1], 10); + const minAllowedHeader = Math.min(nameHeaderLevel + 1, 6); + + const supernotes = flags.supernotesgmtext === "true"; + const imagelinks = flags.imagelinks === "true"; + const replace = flags.replace === "true"; // NEW + + // ---------------- Token validation ---------------- + if (!msg.selected || !msg.selected.length) { + sendError("Please select a token."); + return; + } + + const selectedToken = getObj("graphic", msg.selected[0]._id); + if (!selectedToken) return sendError("Invalid token selection."); + + const pageId = getPageForPlayer(msg.playerid); + const charId = selectedToken.get("represents"); + if (!charId) return sendError("Selected token does not represent a character."); + + const tokensOnPage = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); + + if (!tokensOnPage.length) { + sendError("No matching map tokens found."); + return; + } + + // ---------------- Helpers ---------------- + const decodeUnicode = str => + str.replace(/%u[0-9A-Fa-f]{4}/g, m => + String.fromCharCode(parseInt(m.slice(2), 16)) + ); + + function decodeNotes(raw) { + if (!raw) return ""; + let s = decodeUnicode(raw); + try { + s = decodeURIComponent(s); + } + catch { + try { + s = unescape(s); + } + catch (e) { + log(e); + } + } + return s.replace(/^]*>/i, "").replace(/<\/div>$/i, "").trim(); + } + + function normalizeVisibleText(html) { + return html + .replace(//gi, "\n") + .replace(/<\/p\s*>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /gi, " ") + .replace(/\s+/g, " ") + .trim(); + } + + function applyBlockquoteSplit(html) { + const blocks = html.match(//gi); + if (!blocks) return `
${html}
`; + + const idx = blocks.findIndex( + b => normalizeVisibleText(b) === "-----" + ); + + // NEW: no separator → everything is player-visible + if (idx === -1) { + return `
${blocks.join("")}
`; + } + + // Separator exists → split as before + const player = blocks.slice(0, idx).join(""); + const gm = blocks.slice(idx + 1).join(""); + + return `
${player}
\n${gm}`; + } + + + function downgradeHeaders(html) { + return html + .replace(/<\s*h[1-2]\b[^>]*>/gi, "

") + .replace(/<\s*\/\s*h[1-2]\s*>/gi, "

"); + } + + function encodeProtocol(url) { + return url.replace(/^(https?):\/\//i, "$1!!!"); + } + + function convertImages(html) { + if (!html) return html; + + html = html.replace( + /\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gi, + (m, alt, url) => { + const enc = encodeProtocol(url); + let out = + `${_.escape(alt)}`; + if (imagelinks) { + out += `
[Image]`; + } + return out; + } + ); + + if (imagelinks) { + html = html.replace( + /(]*\bsrc=["']([^"']+)["'][^>]*>)(?![\s\S]*?\[Image\])/gi, + (m, img, url) => + `${img}
[Image]` + ); + } + + return html; + } + + function applyFormat(content, format) { + if (/^h[1-6]$/.test(format)) { + const lvl = Math.max(parseInt(format[1], 10), minAllowedHeader); + return `${content}`; + } + if (format === "blockquote") return `
${content}
`; + if (format === "code") return `
${_.escape(content)}
`; + return content; + } + + // ---------------- Build output ---------------- + const output = []; + const tokenByName = {}; // NEW: exact name → token + const pinsToCreateCache = new Set(); + + let workTokensOnPage = tokensOnPage + .sort((a, b) => (a.get("name") || "").localeCompare(b.get("name") || "", undefined, + { + sensitivity: "base" + })); + + + const finishUp = () => { + // ---------------- Handout creation ---------------- + let h = findObjs( + { + _type: "handout", + name: flags.title + })[0]; + if (!h) h = createObj("handout", + { + name: flags.title + }); + + h.set("notes", output.join("\n")); + const handoutId = h.id; + + sendChat("PinTool", `/w gm Handout "${flags.title}" updated.`); + + if (!replace) return; + + const skipped = []; + // const headerRegex = new RegExp(`([\\s\\S]*?)<\\/h${nameHeaderLevel}>`, "gi"); + + const headers = [...pinsToCreateCache]; + + const replaceBurndown = () => { + let header = headers.shift(); + if (header) { + const headerText = _.unescape(header).trim(); + const token = tokenByName[headerText]; + + if (!token) { + skipped.push(headerText); + return; + } + + const existingPin = findObjs( + { + _type: "pin", + _pageid: pageId, + link: handoutId, + subLink: headerText + })[0]; + + + if (existingPin) { + existingPin.set( + { + x: token.get("left"), + y: token.get("top"), + link: handoutId, + linkType: "handout", + subLink: headerText + }); + + } + else { + // Two-step pin creation to avoid desync errors + const pin = + + createObj("pin", + { + pageid: pageId, + x: token.get("left"), + y: token.get("top") + 16, + link: handoutId, + linkType: "handout", + subLink: headerText, + subLinkType: "headerPlayer", + autoNotesType: "blockquote", + scale: 1, + notesDesynced: false, + imageDesynced: false, + gmNotesDesynced: false + }); + + if (pin) { + pin.set( + { + link: handoutId, + linkType: "handout", + subLink: headerText + }); + } + } + setTimeout(replaceBurndown, 0); + } else { + + if (skipped.length) { + sendStyledMessage( + "Convert: Pins Skipped", + `
    ${skipped.map(s => `
  • ${_.escape(s)}
  • `).join("")}
` + ); + } else { + sendStyledMessage( + "Finished Adding Pins", + `Created ${pinsToCreateCache.size} Map Pins.` + ); + } + } + }; + replaceBurndown(); + }; + + const burndown = () => { + let token = workTokensOnPage.shift(); + if (token) { + const tokenName = token.get("name") || ""; + tokenByName[tokenName] = token; // exact string match + + output.push(`${_.escape(tokenName)}`); + pinsToCreateCache.add(_.escape(tokenName)); + + orderedSpecs.forEach(spec => { + if (["name", "title", "supernotesgmtext", "imagelinks", "replace"].includes(spec.key)) return; + + let value = ""; + if (spec.key === "gmnotes") { + value = decodeNotes(token.get("gmnotes") || ""); + if (supernotes) value = applyBlockquoteSplit(value); + value = downgradeHeaders(value); + value = convertImages(value); + } + else if (spec.key === "tooltip") { + value = token.get("tooltip") || ""; + } + else if (/^bar[1-3]_(value|max)$/.test(spec.key)) { + value = token.get(spec.key) || ""; + } + + if (value) output.push(applyFormat(value, spec.val)); + }); + setTimeout(burndown, 0); + } else { + finishUp(); + } + }; + + burndown(); + + } + + // ============================================================ + // PLACE MODE + // ============================================================ + + function handlePlace(msg, args) { + + if (!args.length) return; + + /* ---------------- Parse args ---------------- */ + const flags = {}; + + for (let i = 0; i < args.length; i++) { + const t = args[i]; + const idx = t.indexOf("|"); + if (idx === -1) continue; + + const key = t.slice(0, idx).toLowerCase(); + let val = t.slice(idx + 1); + + const parts = [val]; + let j = i + 1; + + while (j < args.length && args[j].indexOf("|") === -1) { + parts.push(args[j]); + j++; + } + + flags[key] = parts.join(" "); + i = j - 1; + } + + if (!flags.name) return sendError("--place requires name|h1–h4"); + if (!flags.handout) return sendError("--place requires handout|"); + + const nameMatch = flags.name.match(/^h([1-4])$/i); + if (!nameMatch) return sendError("name must be h1 through h4"); + + const headerLevel = parseInt(nameMatch[1], 10); + const handoutName = flags.handout; + + /* ---------------- Resolve handout ---------------- */ + const handouts = findObjs( + { + _type: "handout", + name: handoutName + }); + if (!handouts.length) + return sendError(`No handout named "${handoutName}" found (case-sensitive).`); + if (handouts.length > 1) + return sendError(`More than one handout named "${handoutName}" exists.`); + + const handout = handouts[0]; + const handoutId = handout.id; + + /* ---------------- Page ---------------- */ + const pageId = getPageForPlayer(msg.playerid); + + if (typeof pageId === "undefined") + return sendError("pageId is not defined."); + + const page = getObj("page", pageId); + if (!page) return sendError("Invalid pageId."); + + const gridSize = page.get("snapping_increment") * 70 || 70; + const maxCols = Math.floor((page.get("width") * 70) / gridSize); + + const startX = gridSize / 2; + const startY = gridSize / 2; + + let col = 0; + let row = 0; + + /* ---------------- Header extraction ---------------- */ + const headerRegex = new RegExp( + `([\\s\\S]*?)<\\/h${headerLevel}>`, + "gi" + ); + + const headers = []; // { text, subLinkType } + + function extractHeaders(html, subLinkType) { + let m; + while ((m = headerRegex.exec(html)) !== null) { + headers.push( + { + text: _.unescape(m[1]).trim(), + subLinkType + }); + } + } + + handout.get("notes", html => extractHeaders(html, "headerPlayer")); + handout.get("gmnotes", html => extractHeaders(html, "headerGM")); + + if (!headers.length) + return sendError(`No headers found in handout.`); + + /* ---------------- Existing pins ---------------- */ + const existingPins = findObjs( + { + _type: "pin", + _pageid: pageId, + link: handoutId + }); + + const pinByKey = {}; + existingPins.forEach(p => { + const key = `${p.get("subLink")}||${p.get("subLinkType") || ""}`; + pinByKey[key] = p; + }); + + let created = 0; + let replaced = 0; + + /* ---------------- Placement ---------------- */ + const burndown = () => { + let h = headers.shift(); + if (h) { + + const headerText = h.text; + const subLinkType = h.subLinkType; + const key = `${headerText}||${subLinkType}`; + + let x, y; + const existing = pinByKey[key]; + + if (existing) { + existing.set({ + link: handoutId, + linkType: "handout", + subLink: headerText, + subLinkType: subLinkType, + autoNotesType: "blockquote", + scale: 1, + notesDesynced: false, + imageDesynced: false, + gmNotesDesynced: false + }); + replaced++; + } + else { + x = startX + col * gridSize; + + // Stagger every other pin in the row by 20px vertically + y = startY + row * gridSize + (col % 2 ? 20 : 0); + + col++; + if (col >= maxCols) { + col = 0; + row++; + } + + + // Two-step creation (same defaults as convert) + createObj("pin", + { + pageid: pageId, + x: x, + y: y, + link: handoutId, + linkType: "handout", + subLink: headerText, + subLinkType: subLinkType, + autoNotesType: "blockquote", + scale: 1, + notesDesynced: false, + imageDesynced: false, + gmNotesDesynced: false + }); + created++; + } + setTimeout(burndown, 0); + } else { + /* ---------------- Report ---------------- */ + sendStyledMessage( + "Place Pins", + `

Handout: ${_.escape(handoutName)}

+
    +
  • Pins created: ${created}
  • +
  • Pins replaced: ${replaced}
  • +
` + ); + } + }; + burndown(); + + } + + + + + + // ============================================================ + // CHAT DISPATCH + // ============================================================ + + on("chat:message", msg => { + if (msg.type !== "api" || !/^!pintool\b/i.test(msg.content)) return; + sender = msg.who.replace(/\s\(GM\)$/, ''); + const parts = msg.content.trim().split(/\s+/); + const cmd = parts[1]?.toLowerCase(); + + if (parts.length === 1) { + showControlPanel(); + return; + } + + if (cmd === "--set") return handleSet(msg, parts.slice(2)); + if (cmd === "--convert") return handleConvert(msg, parts.slice(2)); + if (cmd === "--place") return handlePlace(msg, parts.slice(2)); + if (cmd === "--purge") return handlePurge(msg, parts.slice(2)); + if (cmd === "--help") return handleHelp(msg); + if (cmd?.startsWith("--imagetochat|")) + return handleImageToChat(parts[1].slice(14)); + if (cmd?.startsWith("--imagetochatall|")) + return handleImageToChatAll(parts[1].slice(17)); + + sendError("Unknown subcommand. Use --help."); + }); +}); + +{ try { throw new Error(''); } catch (e) { API_Meta.PinTool.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.PinTool.offset); } } diff --git a/PinTool/PinTool.js b/PinTool/PinTool.js index 1966ac318..d77a350a5 100644 --- a/PinTool/PinTool.js +++ b/PinTool/PinTool.js @@ -1,28 +1,28 @@ // Script: PinTool // By: Keith Curtis // Contact: https://app.roll20.net/users/162065/keithcurtis -var API_Meta = API_Meta||{}; //eslint-disable-line no-var -API_Meta.PinTool={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; -{try{throw new Error('');}catch(e){API_Meta.PinTool.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} +var API_Meta = API_Meta || {}; //eslint-disable-line no-var +API_Meta.PinTool = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; +{ try { throw new Error(''); } catch (e) { API_Meta.PinTool.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - 6); } } -on("ready", () => -{ +on("ready", () => { - const version = '1.0.1'; //version number set here - log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); - //1.0.1 Added burndown to many parts to account for timeouts - Thanks to the Aaron - //1.0.0 Debut + const version = '1.0.2'; //version number set here + log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); + //1.0.2 Cleaned up Help Documentation. Added basic control panel + //1.0.1 Added burndown to many parts to account for timeouts - Thanks to the Aaron + //1.0.0 Debut - // ============================================================ - // HELPERS - // ============================================================ + // ============================================================ + // HELPERS + // ============================================================ - const scriptName = "PinTool"; - const PINTOOL_HELP_NAME = "Help: PinTool"; - const PINTOOL_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; + const scriptName = "PinTool"; + const PINTOOL_HELP_NAME = "Help: PinTool"; + const PINTOOL_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; - const PINTOOL_HELP_TEXT = ` + const PINTOOL_HELP_TEXT = `

PinTool Script Help

@@ -197,15 +197,18 @@ All note pins must represent a common character. imagelinks|true
Adds clickable [Image] links after images that send them to chat. +

  • + replace|true
    + Places a pin at the location of every token note, linked to the handout. Afterward, you can delete either pins or tokens with the purge [pins/tokens] command. +
  • Convert Rules

      -
    • Arguments are not prefixed with --.
    • Argument order is preserved and controls output order.
    • title| values may contain spaces.
    • -
    • Images in notes are converted to inline image links.
    • +
    • Images in notes can be converted to inline image links. Inline images in pins are not supported at this time
    • Only tokens on the same page representing the same character are included.
    @@ -302,205 +305,311 @@ The purge command removes all tokens on the map similar to the `; - const getPageForPlayer = (playerid) => - { - let player = getObj('player', playerid); - if(playerIsGM(playerid)) - { - return player.get('lastpage') || Campaign().get('playerpageid'); - } + let sender; - let psp = Campaign().get('playerspecificpages'); - if(psp[playerid]) - { - return psp[playerid]; - } + const getPageForPlayer = (playerid) => { + let player = getObj('player', playerid); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } - return Campaign().get('playerpageid'); - }; + let psp = Campaign().get('playerspecificpages'); + if (psp[playerid]) { + return psp[playerid]; + } - function handleHelp(msg) - { - if(msg.type !== "api") return; + return Campaign().get('playerpageid'); + }; - let handout = findObjs( - { - _type: "handout", - name: PINTOOL_HELP_NAME - })[0]; + function handleHelp(msg) { + if (msg.type !== "api") return; + + let handout = findObjs( + { + _type: "handout", + name: PINTOOL_HELP_NAME + })[0]; - if(!handout) + if (!handout) { + handout = createObj("handout", { - handout = createObj("handout", - { - name: PINTOOL_HELP_NAME, - archived: false - }); - handout.set("avatar", PINTOOL_HELP_AVATAR); - } + name: PINTOOL_HELP_NAME, + archived: false + }); + handout.set("avatar", PINTOOL_HELP_AVATAR); + } - handout.set("notes", PINTOOL_HELP_TEXT); + handout.set("notes", PINTOOL_HELP_TEXT); - const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; + const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; - const box = ` + const box = `
    PinTool Help
    Open Help Handout
    `.trim().replace(/\r?\n/g, ''); - sendChat("PinTool", `/w gm ${box}`); - } + sendChat("PinTool", `/w gm ${box}`); + } - function getCSS() - { - return { - messageContainer: "background:#1e1e1e;" + - "border:1px solid #444;" + - "border-radius:6px;" + - "padding:8px;" + - "margin:4px 0;" + - "font-family:Arial, sans-serif;" + - "color:#ddd;", - messageTitle: "font-weight:bold;" + - "font-size:13px;" + - "margin-bottom:6px;" + - "color:#fff;", - messageButton: "display:inline-block;" + - "padding:2px 6px;" + - "margin:2px 0;" + - "border-radius:4px;" + - "background:#333;" + - "border:1px solid #555;" + - "color:#9fd3ff;" + - "text-decoration:none;" + - "font-size:12px;" - }; - } + function getCSS() { + return { + messageContainer: + "background:#1e1e1e;" + + "border:1px solid #444;" + + "border-radius:6px;" + + "padding:8px;" + + "margin:4px 0;" + + "font-family:Arial, sans-serif;" + + "color:#ddd;", + + messageTitle: + "font-weight:bold;" + + "font-size:14px;" + + "margin-bottom:6px;" + + "color:#fff;", + + messageButton: + "display:inline-block;" + + "padding:2px 6px;" + + "margin:2px 4px 2px 0;" + + "border-radius:4px;" + + "background:#333;" + + "border:1px solid #555;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-weight:bold;" + + "font-size:12px;" + + "white-space:nowrap;", + + sectionLabel: + "display:block;" + + "margin-top:6px;" + + "font-weight:bold;" + + "color:#ccc;", + + panel: + "background:#ccc;" + + "border:1px solid #444;" + + "border-radius:6px;" + + "padding:8px;" + + "margin:4px 0;" + + "font-family:Arial, sans-serif;" + + "color:#111;", + + + panelButtonLeft: + "display:inline-block;" + + "padding:2px 6px;" + + "border-radius:14px 0 0 14px;" + + "background:#333;" + + "border:1px solid #555;" + + "border-right:none;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-size:12px;" + + "margin-bottom:4px;", + + panelButtonAll: + "display:inline-block;" + + "padding:2px 6px;" + + "border-radius:0 14px 14px 0;" + + "background:#222;" + + "border:1px solid #555;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-size:11px;" + + "font-weight:bold;" + + "margin-right:10px;" + + "margin-bottom:4px;" - function handlePurge(msg, args) - { - if(!args.length) return; + }; + } + + function splitButton(label, command) { + const css = getCSS(); + + return ( + `${label}` + + `++` + ); + } - const mode = args[0]; - if(mode !== "tokens" && mode !== "pins") return; + function messageButton(label, command) { + const css = getCSS(); - const confirmed = args.includes("--confirm"); + return ( + `${label}` + ); + } - // -------------------------------- - // CONFIRM PATH (no selection) - // -------------------------------- - if(confirmed) - { - let charId, handoutId, pageId; + function showControlPanel() { + const css = getCSS(); - args.forEach(a => - { - if(a.startsWith("char|")) charId = a.slice(5); - if(a.startsWith("handout|")) handoutId = a.slice(8); - if(a.startsWith("page|")) pageId = a.slice(5); - }); + const panel = + `
    ` + - if(!pageId) return; + `
    Click on button name to affect selected pins, or "++" to apply that setting to all pins on page
    ` + - /* ===== PURGE TOKENS (CONFIRM) ===== */ - if(mode === "tokens" && charId) - { - const char = getObj("character", charId); - if(!char) return; + `
    Size
    ` + + splitButton("Teeny", "!pintool --set scale|.25") + + splitButton("Tiny", "!pintool --set scale|.5") + + splitButton("Sm", "!pintool --set scale|.75") + + splitButton("Med", "!pintool --set scale|1") + + splitButton("Lrg", "!pintool --set scale|1.25") + + splitButton("Huge", "!pintool --set scale|1.5") + + splitButton("Gig", "!pintool --set scale|2") + + `
    ` + - const charName = char.get("name") || "Unknown Character"; + `
    Visible
    ` + + splitButton("GM Only", "!pintool --set visibleTo|") + + splitButton("All Players", "!pintool --set visibleTo|all") + + `
    ` + - const targets = findObjs( - { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); + `
    Blockquote as player text
    ` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + + splitButton("Off", "!pintool --set autoNotesType|") + + `
    ` + - if(!targets.length) return; + `
    Display
    ` + + splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + + splitButton("Custom", "!pintool --set imageDesynced|true imageVisibleTo|all") + + `
    ` + - targets.forEach(t => t.remove()); + `
    Place Pins from Handout
    ` + + messageButton("Place Pins from Handout", "!pintool --place handout|?{Exact Handout Name} name|?{Choose Header Level for Map Pins|h1,h1|h2,h2|h3,h3|h4,h4}") + + `
    ` + - sendChat( - "PinTool", - `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` - ); - } + `
    Delete All Pins on Page
    Select an example pin first.
    ` + + messageButton("Delete All Pins on Page", "!pintool --purge pins") + + `
    ` + - /* ===== PURGE PINS (CONFIRM) ===== */ - if(mode === "pins" && handoutId) - { - const handout = getObj("handout", handoutId); - if(!handout) return; + `
    `; - const handoutName = handout.get("name") || "Unknown Handout"; + sendStyledMessage( + "PinTool Control Panel", + panel + ); + } - const targets = findObjs( - { - _type: "pin", - _pageid: pageId - }).filter(p => p.get("link") === handoutId); - - if(!targets.length) return; - - const count = targets.length; - - const burndown = () => { - let p = targets.shift(); - if(p){ - p.remove(); - setTimeout(burndown,0); - } else { - sendChat( - "PinTool", - `/w gm ✅ Deleted ${count} pin(s) linked to "${_.escape(handoutName)}".` - ); - } - }; - burndown(); - } - return; - } + function handlePurge(msg, args) { + if (!args.length) return; - // -------------------------------- - // INITIAL PATH (requires selection) - // -------------------------------- - if(!msg.selected || msg.selected.length !== 1) return; + const mode = args[0]; + if (mode !== "tokens" && mode !== "pins") return; - const sel = msg.selected[0]; + const confirmed = args.includes("--confirm"); - /* =============================== - PURGE TOKENS (INITIAL) - =============================== */ - if(mode === "tokens" && sel._type === "graphic") - { - const token = getObj("graphic", sel._id); - if(!token) return; + // -------------------------------- + // CONFIRM PATH (no selection) + // -------------------------------- + if (confirmed) { + let charId, handoutId, pageId; - const charId = token.get("represents"); - if(!charId) return; + args.forEach(a => { + if (a.startsWith("char|")) charId = a.slice(5); + if (a.startsWith("handout|")) handoutId = a.slice(8); + if (a.startsWith("page|")) pageId = a.slice(5); + }); - const pageId = token.get("_pageid"); - const char = getObj("character", charId); - const charName = char?.get("name") || "Unknown Character"; + if (!pageId) return; - const targets = findObjs( - { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); + /* ===== PURGE TOKENS (CONFIRM) ===== */ + if (mode === "tokens" && charId) { + const char = getObj("character", charId); + if (!char) return; - if(!targets.length) return; + const charName = char.get("name") || "Unknown Character"; - sendStyledMessage( - "Confirm Purge", - ` + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); + + if (!targets.length) return; + + targets.forEach(t => t.remove()); + + sendChat( + "PinTool", + `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` + ); + } + + /* ===== PURGE PINS (CONFIRM) ===== */ + if (mode === "pins" && handoutId) { + const handout = getObj("handout", handoutId); + if (!handout) return; + + const handoutName = handout.get("name") || "Unknown Handout"; + + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); + + if (!targets.length) return; + + const count = targets.length; + + const burndown = () => { + let p = targets.shift(); + if (p) { + p.remove(); + setTimeout(burndown, 0); + } else { + sendChat( + "PinTool", + `/w gm ✅ Deleted ${count} pin(s) linked to "${_.escape(handoutName)}".` + ); + } + }; + burndown(); + } + + return; + } + + // -------------------------------- + // INITIAL PATH (requires selection) + // -------------------------------- + if (!msg.selected || msg.selected.length !== 1) return; + + const sel = msg.selected[0]; + + /* =============================== + PURGE TOKENS (INITIAL) + =============================== */ + if (mode === "tokens" && sel._type === "graphic") { + const token = getObj("graphic", sel._id); + if (!token) return; + + const charId = token.get("represents"); + if (!charId) return; + + const pageId = token.get("_pageid"); + const char = getObj("character", charId); + const charName = char?.get("name") || "Unknown Character"; + + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); + + if (!targets.length) return; + + sendStyledMessage( + "Confirm Purge", + `
    This will permanently delete ${targets.length} token(s) @@ -520,37 +629,36 @@ The purge command removes all tokens on the map similar to the
    ` - ); + ); - return; - } + return; + } - /* =============================== - PURGE PINS (INITIAL) - =============================== */ - if(mode === "pins" && sel._type === "pin") - { - const pin = getObj("pin", sel._id); - if(!pin) return; + /* =============================== + PURGE PINS (INITIAL) + =============================== */ + if (mode === "pins" && sel._type === "pin") { + const pin = getObj("pin", sel._id); + if (!pin) return; - const handoutId = pin.get("link"); - if(!handoutId) return; + const handoutId = pin.get("link"); + if (!handoutId) return; - const pageId = pin.get("_pageid"); - const handout = getObj("handout", handoutId); - const handoutName = handout?.get("name") || "Unknown Handout"; + const pageId = pin.get("_pageid"); + const handout = getObj("handout", handoutId); + const handoutName = handout?.get("name") || "Unknown Handout"; - const targets = findObjs( - { - _type: "pin", - _pageid: pageId - }).filter(p => p.get("link") === handoutId); + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); - if(!targets.length) return; + if (!targets.length) return; - sendStyledMessage( - "Confirm Purge", - `

    This will permanently delete ${targets.length} pin(s)
    + sendStyledMessage( + "Confirm Purge", + `

    This will permanently delete ${targets.length} pin(s)
    linked to handout ${_.escape(handoutName)}.

    This cannot be undone.

    @@ -558,243 +666,239 @@ The purge command removes all tokens on the map similar to the Click here to confirm

    ` - ); - return; - } + ); + return; } + } - function normalizeForChat(html) - { - return String(html).replace(/\r\n|\r|\n/g, "").trim(); - } + function normalizeForChat(html) { + return String(html).replace(/\r\n|\r|\n/g, "").trim(); + } - const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => - { - const css = getCSS(); - let title, message; + const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => { + const css = getCSS(); + let title, message; - if(messageOrUndefined === undefined) - { - title = scriptName; - message = titleOrMessage; - } - else - { - title = titleOrMessage || scriptName; - message = messageOrUndefined; - } + if (messageOrUndefined === undefined) { + title = scriptName; + message = titleOrMessage; + } + else { + title = titleOrMessage || scriptName; + message = messageOrUndefined; + } - message = String(message).replace( - /\[([^\]]+)\]\(([^)]+)\)/g, - (_, label, command) => - `${label}` - ); + message = String(message).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, label, command) => + `${label}` + ); - const html = - `
    + const html = + `
    ${title}
    ${message}
    `; - sendChat( - scriptName, - `${isPublic ? "" : "/w gm "}${normalizeForChat(html)}`, - null, - { - noarchive: true - } - ); - }; + sendChat( + scriptName, + `${isPublic ? "" : "/w gm "}${normalizeForChat(html)}`, + null, + { + noarchive: true + } + ); + }; - function sendError(msg) - { - sendStyledMessage("PinTool — Error", msg); - } + function sendError(msg) { + sendStyledMessage("PinTool — Error", msg); + } - function sendWarning(msg) - { - sendStyledMessage("PinTool — Warning", msg); - } + function sendWarning(msg) { + sendStyledMessage("PinTool — Warning", msg); + } - // ============================================================ - // IMAGE → CHAT - // ============================================================ + // ============================================================ + // IMAGE → CHAT + // ============================================================ + + function handleImageToChat(encodedUrl) { + let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); + if (!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); + + const imageHtml = + `
    ` + + `` + + `
    ` + + `` + + `Send to All` + + `
    ` + + `
    `; + + sendChat("PinTool", `/w "${sender}" ${imageHtml}`, + null, + { noarchive: true }); + } - function handleImageToChat(encodedUrl) - { - let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); - if(!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); - sendChat( - "PinTool", - `/direct
    - -
    ` - ); - } + function handleImageToChatAll(encodedUrl) { + let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); + if (!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); - // ============================================================ - // SET MODE (pins) - // ============================================================ - - const PIN_SET_PROPERTIES = { - x: "number", - y: "number", - title: "string", - notes: "string", - image: "string", - tooltipImage: "string", - link: "string", - linkType: ["", "handout"], - subLink: "string", - subLinkType: ["", "headerPlayer", "headerGM"], - visibleTo: ["", "all"], - tooltipVisibleTo: ["", "all"], - nameplateVisibleTo: ["", "all"], - imageVisibleTo: ["", "all"], - notesVisibleTo: ["", "all"], - gmNotesVisibleTo: ["", "all"], - autoNotesType: ["", "blockquote"], - scale: - { - min: 0.25, - max: 2.0 - }, - imageDesynced: "boolean", - notesDesynced: "boolean", - gmNotesDesynced: "boolean" - }; + sendChat( + "PinTool", `
    `, + null, + { noarchive: true }); + } - function handleSet(msg, tokens) + // ============================================================ + // SET MODE (pins) + // ============================================================ + + const PIN_SET_PROPERTIES = { + x: "number", + y: "number", + title: "string", + notes: "string", + image: "string", + tooltipImage: "string", + link: "string", + linkType: ["", "handout"], + subLink: "string", + subLinkType: ["", "headerPlayer", "headerGM"], + visibleTo: ["", "all"], + tooltipVisibleTo: ["", "all"], + nameplateVisibleTo: ["", "all"], + imageVisibleTo: ["", "all"], + notesVisibleTo: ["", "all"], + gmNotesVisibleTo: ["", "all"], + autoNotesType: ["", "blockquote"], + scale: { - const flags = {}; - let filterRaw = ""; + min: 0.25, + max: 2.0 + }, + imageDesynced: "boolean", + notesDesynced: "boolean", + gmNotesDesynced: "boolean" + }; + + function handleSet(msg, tokens) { + const flags = {}; + let filterRaw = ""; - for(let i = 0; i < tokens.length; i++) - { - const t = tokens[i]; - const idx = t.indexOf("|"); - if(idx === -1) continue; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + const idx = t.indexOf("|"); + if (idx === -1) continue; - const key = t.slice(0, idx); - let val = t.slice(idx + 1); + const key = t.slice(0, idx); + let val = t.slice(idx + 1); - if(key === "filter") - { - const parts = [val]; - let j = i + 1; - while(j < tokens.length && !tokens[j].includes("|")) - { - parts.push(tokens[j++]); - } - filterRaw = parts.join(" ").trim(); - i = j - 1; - continue; - } + if (key === "filter") { + const parts = [val]; + let j = i + 1; + while (j < tokens.length && !tokens[j].includes("|")) { + parts.push(tokens[j++]); + } + filterRaw = parts.join(" ").trim(); + i = j - 1; + continue; + } - if(!PIN_SET_PROPERTIES.hasOwnProperty(key)) - return sendError(`Unknown pin property, or improper capitalization: ${key}`); + if (!PIN_SET_PROPERTIES.hasOwnProperty(key)) + return sendError(`Unknown pin property, or improper capitalization: ${key}`); - const parts = [val]; - let j = i + 1; - while(j < tokens.length && !tokens[j].includes("|")) - { - parts.push(tokens[j++]); - } + const parts = [val]; + let j = i + 1; + while (j < tokens.length && !tokens[j].includes("|")) { + parts.push(tokens[j++]); + } - flags[key] = parts.join(" ").trim(); - i = j - 1; - } + flags[key] = parts.join(" ").trim(); + i = j - 1; + } - if(!Object.keys(flags).length) - return sendError("No valid properties supplied to --set."); + if (!Object.keys(flags).length) + return sendError("No valid properties supplied to --set."); - const pageId = getPageForPlayer(msg.playerid); - /* - (Campaign().get("playerspecificpages") || {})[msg.playerid] || - Campaign().get("playerpageid"); + const pageId = getPageForPlayer(msg.playerid); + /* + (Campaign().get("playerspecificpages") || {})[msg.playerid] || + Campaign().get("playerpageid"); */ - let pins = []; - - if(!filterRaw || filterRaw === "selected") - { - if(!msg.selected?.length) return sendError("No pins selected."); - pins = msg.selected - .map(s => getObj("pin", s._id)) - .filter(p => p && p.get("_pageid") === pageId); - } - else if(filterRaw === "all") - { - pins = findObjs( - { - _type: "pin", - _pageid: pageId - }); - } - else - { - pins = filterRaw.split(/\s+/) - .map(id => getObj("pin", id)) - .filter(p => p && p.get("_pageid") === pageId); - } - - if(!pins.length) - return sendWarning("Filter matched no pins on the current page."); + let pins = []; - const updates = {}; - try + if (!filterRaw || filterRaw === "selected") { + if (!msg.selected?.length) return sendError("No pins selected."); + pins = msg.selected + .map(s => getObj("pin", s._id)) + .filter(p => p && p.get("_pageid") === pageId); + } + else if (filterRaw === "all") { + pins = findObjs( { - Object.entries(flags).forEach(([key, raw]) => - { - const spec = PIN_SET_PROPERTIES[key]; - let value = raw; + _type: "pin", + _pageid: pageId + }); + } + else { + pins = filterRaw.split(/\s+/) + .map(id => getObj("pin", id)) + .filter(p => p && p.get("_pageid") === pageId); + } - if(spec === "boolean") value = raw === "true"; - else if(spec === "number") value = Number(raw); - else if(Array.isArray(spec) && !spec.includes(value)) throw 0; - else if(!Array.isArray(spec) && typeof spec === "object") - { - value = Number(raw); - if(value < spec.min || value > spec.max) throw 0; - } - updates[key] = value; - }); + if (!pins.length) + return sendWarning("Filter matched no pins on the current page."); + + const updates = {}; + try { + Object.entries(flags).forEach(([key, raw]) => { + const spec = PIN_SET_PROPERTIES[key]; + let value = raw; + + if (spec === "boolean") value = raw === "true"; + else if (spec === "number") value = Number(raw); + else if (Array.isArray(spec) && !spec.includes(value)) throw 0; + else if (!Array.isArray(spec) && typeof spec === "object") { + value = Number(raw); + if (value < spec.min || value > spec.max) throw 0; } - catch - { - return sendError("Invalid value supplied to --set."); - } - pins.forEach(p => p.set(updates)); - sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); + updates[key] = value; + }); } + catch { + return sendError("Invalid value supplied to --set."); + } + pins.forEach(p => p.set(updates)); + //sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); + } - // ============================================================ - // CONVERT MODE (tokens → handout) - // ============================================================ + // ============================================================ + // CONVERT MODE (tokens → handout) + // ============================================================ - function sendConvertHelp() - { - sendStyledMessage( - "PinTool — Convert", - "Usage
    !pintool --convert name|h2 title|My Handout [options]" - ); - } + function sendConvertHelp() { + sendStyledMessage( + "PinTool — Convert", + "Usage
    !pintool --convert name|h2 title|My Handout [options]" + ); + } - // ============================================================ - // CONVERT MODE - // ============================================================ + // ============================================================ + // CONVERT MODE + // ============================================================ - function handleConvert(msg, tokens) - { + function handleConvert(msg, tokens) { - if(!tokens.length) - { + if (!tokens.length) { sendConvertHelp(); return; } @@ -803,11 +907,10 @@ The purge command removes all tokens on the map similar to the const flags = {}; const orderedSpecs = []; - for(let i = 0; i < tokens.length; i++) - { + for (let i = 0; i < tokens.length; i++) { const t = tokens[i]; const idx = t.indexOf("|"); - if(idx === -1) continue; + if (idx === -1) continue; const key = t.slice(0, idx).toLowerCase(); let val = t.slice(idx + 1); @@ -815,10 +918,9 @@ The purge command removes all tokens on the map similar to the const parts = [val]; let j = i + 1; - while(j < tokens.length) - { + while (j < tokens.length) { const next = tokens[j]; - if(next.indexOf("|") !== -1) break; + if (next.indexOf("|") !== -1) break; parts.push(next); j++; } @@ -834,11 +936,11 @@ The purge command removes all tokens on the map similar to the } // ---------------- Required args ---------------- - if(!flags.title) return sendError("--convert requires title|"); - if(!flags.name) return sendError("--convert requires name|h1–h5"); + if (!flags.title) return sendError("--convert requires title|"); + if (!flags.name) return sendError("--convert requires name|h1–h5"); const nameMatch = flags.name.match(/^h([1-5])$/i); - if(!nameMatch) return sendError("name must be h1 through h5"); + if (!nameMatch) return sendError("name must be h1 through h5"); const nameHeaderLevel = parseInt(nameMatch[1], 10); const minAllowedHeader = Math.min(nameHeaderLevel + 1, 6); @@ -848,18 +950,17 @@ The purge command removes all tokens on the map similar to the const replace = flags.replace === "true"; // NEW // ---------------- Token validation ---------------- - if(!msg.selected || !msg.selected.length) - { + if (!msg.selected || !msg.selected.length) { sendError("Please select a token."); return; } const selectedToken = getObj("graphic", msg.selected[0]._id); - if(!selectedToken) return sendError("Invalid token selection."); + if (!selectedToken) return sendError("Invalid token selection."); const pageId = getPageForPlayer(msg.playerid); const charId = selectedToken.get("represents"); - if(!charId) return sendError("Selected token does not represent a character."); + if (!charId) return sendError("Selected token does not represent a character."); const tokensOnPage = findObjs( { @@ -869,8 +970,7 @@ The purge command removes all tokens on the map similar to the represents: charId }); - if(!tokensOnPage.length) - { + if (!tokensOnPage.length) { sendError("No matching map tokens found."); return; } @@ -881,30 +981,24 @@ The purge command removes all tokens on the map similar to the String.fromCharCode(parseInt(m.slice(2), 16)) ); - function decodeNotes(raw) - { - if(!raw) return ""; + function decodeNotes(raw) { + if (!raw) return ""; let s = decodeUnicode(raw); - try - { + try { s = decodeURIComponent(s); } - catch - { - try - { + catch { + try { s = unescape(s); } - catch (e) - { - log(e); + catch (e) { + log(e); } } return s.replace(/^]*>/i, "").replace(/<\/div>$/i, "").trim(); } - function normalizeVisibleText(html) - { + function normalizeVisibleText(html) { return html .replace(//gi, "\n") .replace(/<\/p\s*>/gi, "\n") @@ -914,18 +1008,16 @@ The purge command removes all tokens on the map similar to the .trim(); } - function applyBlockquoteSplit(html) - { + function applyBlockquoteSplit(html) { const blocks = html.match(//gi); - if(!blocks) return `
    ${html}
    `; + if (!blocks) return `
    ${html}
    `; const idx = blocks.findIndex( b => normalizeVisibleText(b) === "-----" ); // NEW: no separator → everything is player-visible - if(idx === -1) - { + if (idx === -1) { return `
    ${blocks.join("")}
    `; } @@ -937,58 +1029,50 @@ The purge command removes all tokens on the map similar to the } - function downgradeHeaders(html) - { + function downgradeHeaders(html) { return html .replace(/<\s*h[1-2]\b[^>]*>/gi, "

    ") .replace(/<\s*\/\s*h[1-2]\s*>/gi, "

    "); } - function encodeProtocol(url) - { + function encodeProtocol(url) { return url.replace(/^(https?):\/\//i, "$1!!!"); } - function convertImages(html) - { - if(!html) return html; + function convertImages(html) { + if (!html) return html; html = html.replace( /\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gi, - (m, alt, url) => - { - const enc = encodeProtocol(url); - let out = - `${_.escape(alt)}`; - if(imagelinks) - { - out += `
    [Image]`; + (m, alt, url) => { + const enc = encodeProtocol(url); + let out = + `${_.escape(alt)}`; + if (imagelinks) { + out += `
    [Image]`; + } + return out; } - return out; - } ); - if(imagelinks) - { + if (imagelinks) { html = html.replace( /(]*\bsrc=["']([^"']+)["'][^>]*>)(?![\s\S]*?\[Image\])/gi, (m, img, url) => - `${img}
    [Image]` + `${img}
    [Image]` ); } return html; } - function applyFormat(content, format) - { - if(/^h[1-6]$/.test(format)) - { + function applyFormat(content, format) { + if (/^h[1-6]$/.test(format)) { const lvl = Math.max(parseInt(format[1], 10), minAllowedHeader); return `${content}`; } - if(format === "blockquote") return `
    ${content}
    `; - if(format === "code") return `
    ${_.escape(content)}
    `; + if (format === "blockquote") return `
    ${content}
    `; + if (format === "code") return `
    ${_.escape(content)}
    `; return content; } @@ -1011,7 +1095,7 @@ The purge command removes all tokens on the map similar to the _type: "handout", name: flags.title })[0]; - if(!h) h = createObj("handout", + if (!h) h = createObj("handout", { name: flags.title }); @@ -1021,21 +1105,20 @@ The purge command removes all tokens on the map similar to the sendChat("PinTool", `/w gm Handout "${flags.title}" updated.`); - if(!replace) return; + if (!replace) return; const skipped = []; -// const headerRegex = new RegExp(`([\\s\\S]*?)<\\/h${nameHeaderLevel}>`, "gi"); - + // const headerRegex = new RegExp(`([\\s\\S]*?)<\\/h${nameHeaderLevel}>`, "gi"); + const headers = [...pinsToCreateCache]; const replaceBurndown = () => { let header = headers.shift(); - if( header ) { + if (header) { const headerText = _.unescape(header).trim(); const token = tokenByName[headerText]; - if(!token) - { + if (!token) { skipped.push(headerText); return; } @@ -1049,8 +1132,7 @@ The purge command removes all tokens on the map similar to the })[0]; - if(existingPin) - { + if (existingPin) { existingPin.set( { x: token.get("left"), @@ -1061,8 +1143,7 @@ The purge command removes all tokens on the map similar to the }); } - else - { + else { // Two-step pin creation to avoid desync errors const pin = @@ -1082,8 +1163,7 @@ The purge command removes all tokens on the map similar to the gmNotesDesynced: false }); - if(pin) - { + if (pin) { pin.set( { link: handoutId, @@ -1092,11 +1172,10 @@ The purge command removes all tokens on the map similar to the }); } } - setTimeout(replaceBurndown,0); + setTimeout(replaceBurndown, 0); } else { - if(skipped.length) - { + if (skipped.length) { sendStyledMessage( "Convert: Pins Skipped", `
      ${skipped.map(s => `
    • ${_.escape(s)}
    • `).join("")}
    ` @@ -1112,39 +1191,35 @@ The purge command removes all tokens on the map similar to the replaceBurndown(); }; - const burndown = ()=>{ + const burndown = () => { let token = workTokensOnPage.shift(); - if(token) { + if (token) { const tokenName = token.get("name") || ""; tokenByName[tokenName] = token; // exact string match output.push(`${_.escape(tokenName)}`); pinsToCreateCache.add(_.escape(tokenName)); - orderedSpecs.forEach(spec => - { - if(["name", "title", "supernotesgmtext", "imagelinks", "replace"].includes(spec.key)) return; + orderedSpecs.forEach(spec => { + if (["name", "title", "supernotesgmtext", "imagelinks", "replace"].includes(spec.key)) return; - let value = ""; - if(spec.key === "gmnotes") - { - value = decodeNotes(token.get("gmnotes") || ""); - if(supernotes) value = applyBlockquoteSplit(value); - value = downgradeHeaders(value); - value = convertImages(value); - } - else if(spec.key === "tooltip") - { - value = token.get("tooltip") || ""; - } - else if(/^bar[1-3]_(value|max)$/.test(spec.key)) - { - value = token.get(spec.key) || ""; - } + let value = ""; + if (spec.key === "gmnotes") { + value = decodeNotes(token.get("gmnotes") || ""); + if (supernotes) value = applyBlockquoteSplit(value); + value = downgradeHeaders(value); + value = convertImages(value); + } + else if (spec.key === "tooltip") { + value = token.get("tooltip") || ""; + } + else if (/^bar[1-3]_(value|max)$/.test(spec.key)) { + value = token.get(spec.key) || ""; + } - if(value) output.push(applyFormat(value, spec.val)); - }); - setTimeout(burndown,0); + if (value) output.push(applyFormat(value, spec.val)); + }); + setTimeout(burndown, 0); } else { finishUp(); } @@ -1154,23 +1229,21 @@ The purge command removes all tokens on the map similar to the } - // ============================================================ - // PLACE MODE - // ============================================================ + // ============================================================ + // PLACE MODE + // ============================================================ - function handlePlace(msg, args) - { + function handlePlace(msg, args) { - if(!args.length) return; + if (!args.length) return; /* ---------------- Parse args ---------------- */ const flags = {}; - for(let i = 0; i < args.length; i++) - { + for (let i = 0; i < args.length; i++) { const t = args[i]; const idx = t.indexOf("|"); - if(idx === -1) continue; + if (idx === -1) continue; const key = t.slice(0, idx).toLowerCase(); let val = t.slice(idx + 1); @@ -1178,8 +1251,7 @@ The purge command removes all tokens on the map similar to the const parts = [val]; let j = i + 1; - while(j < args.length && args[j].indexOf("|") === -1) - { + while (j < args.length && args[j].indexOf("|") === -1) { parts.push(args[j]); j++; } @@ -1188,11 +1260,11 @@ The purge command removes all tokens on the map similar to the i = j - 1; } - if(!flags.name) return sendError("--place requires name|h1–h4"); - if(!flags.handout) return sendError("--place requires handout|"); + if (!flags.name) return sendError("--place requires name|h1–h4"); + if (!flags.handout) return sendError("--place requires handout|"); const nameMatch = flags.name.match(/^h([1-4])$/i); - if(!nameMatch) return sendError("name must be h1 through h4"); + if (!nameMatch) return sendError("name must be h1 through h4"); const headerLevel = parseInt(nameMatch[1], 10); const handoutName = flags.handout; @@ -1203,9 +1275,9 @@ The purge command removes all tokens on the map similar to the _type: "handout", name: handoutName }); - if(!handouts.length) + if (!handouts.length) return sendError(`No handout named "${handoutName}" found (case-sensitive).`); - if(handouts.length > 1) + if (handouts.length > 1) return sendError(`More than one handout named "${handoutName}" exists.`); const handout = handouts[0]; @@ -1214,11 +1286,11 @@ The purge command removes all tokens on the map similar to the /* ---------------- Page ---------------- */ const pageId = getPageForPlayer(msg.playerid); - if(typeof pageId === "undefined") + if (typeof pageId === "undefined") return sendError("pageId is not defined."); const page = getObj("page", pageId); - if(!page) return sendError("Invalid pageId."); + if (!page) return sendError("Invalid pageId."); const gridSize = page.get("snapping_increment") * 70 || 70; const maxCols = Math.floor((page.get("width") * 70) / gridSize); @@ -1237,11 +1309,9 @@ The purge command removes all tokens on the map similar to the const headers = []; // { text, subLinkType } - function extractHeaders(html, subLinkType) - { + function extractHeaders(html, subLinkType) { let m; - while((m = headerRegex.exec(html)) !== null) - { + while ((m = headerRegex.exec(html)) !== null) { headers.push( { text: _.unescape(m[1]).trim(), @@ -1253,7 +1323,7 @@ The purge command removes all tokens on the map similar to the handout.get("notes", html => extractHeaders(html, "headerPlayer")); handout.get("gmnotes", html => extractHeaders(html, "headerGM")); - if(!headers.length) + if (!headers.length) return sendError(`No headers found in handout.`); /* ---------------- Existing pins ---------------- */ @@ -1265,11 +1335,10 @@ The purge command removes all tokens on the map similar to the }); const pinByKey = {}; - existingPins.forEach(p => - { - const key = `${p.get("subLink")}||${p.get("subLinkType") || ""}`; - pinByKey[key] = p; - }); + existingPins.forEach(p => { + const key = `${p.get("subLink")}||${p.get("subLinkType") || ""}`; + pinByKey[key] = p; + }); let created = 0; let replaced = 0; @@ -1277,7 +1346,7 @@ The purge command removes all tokens on the map similar to the /* ---------------- Placement ---------------- */ const burndown = () => { let h = headers.shift(); - if(h) { + if (h) { const headerText = h.text; const subLinkType = h.subLinkType; @@ -1286,8 +1355,7 @@ The purge command removes all tokens on the map similar to the let x, y; const existing = pinByKey[key]; - if(existing) - { + if (existing) { existing.set({ link: handoutId, linkType: "handout", @@ -1301,16 +1369,14 @@ The purge command removes all tokens on the map similar to the }); replaced++; } - else - { + else { x = startX + col * gridSize; // Stagger every other pin in the row by 20px vertically y = startY + row * gridSize + (col % 2 ? 20 : 0); col++; - if(col >= maxCols) - { + if (col >= maxCols) { col = 0; row++; } @@ -1334,7 +1400,7 @@ The purge command removes all tokens on the map similar to the }); created++; } - setTimeout(burndown,0); + setTimeout(burndown, 0); } else { /* ---------------- Report ---------------- */ sendStyledMessage( @@ -1355,27 +1421,33 @@ The purge command removes all tokens on the map similar to the - // ============================================================ - // CHAT DISPATCH - // ============================================================ - - on("chat:message", msg => - { - if(msg.type !== "api" || !/^!pintool\b/i.test(msg.content)) return; + // ============================================================ + // CHAT DISPATCH + // ============================================================ - const parts = msg.content.trim().split(/\s+/); - const cmd = parts[1]?.toLowerCase(); + on("chat:message", msg => { + if (msg.type !== "api" || !/^!pintool\b/i.test(msg.content)) return; + sender = msg.who.replace(/\s\(GM\)$/, ''); + const parts = msg.content.trim().split(/\s+/); + const cmd = parts[1]?.toLowerCase(); - if(cmd === "--set") return handleSet(msg, parts.slice(2)); - if(cmd === "--convert") return handleConvert(msg, parts.slice(2)); - if(cmd === "--place") return handlePlace(msg, parts.slice(2)); - if(cmd === "--purge") return handlePurge(msg, parts.slice(2)); - if(cmd === "--help") return handleHelp(msg); - if(cmd?.startsWith("--imagetochat|")) - return handleImageToChat(parts[1].slice(14)); + if (parts.length === 1) { + showControlPanel(); + return; + } - sendError("Unknown subcommand. Use --help."); - }); + if (cmd === "--set") return handleSet(msg, parts.slice(2)); + if (cmd === "--convert") return handleConvert(msg, parts.slice(2)); + if (cmd === "--place") return handlePlace(msg, parts.slice(2)); + if (cmd === "--purge") return handlePurge(msg, parts.slice(2)); + if (cmd === "--help") return handleHelp(msg); + if (cmd?.startsWith("--imagetochat|")) + return handleImageToChat(parts[1].slice(14)); + if (cmd?.startsWith("--imagetochatall|")) + return handleImageToChatAll(parts[1].slice(17)); + + sendError("Unknown subcommand. Use --help."); + }); }); -{try{throw new Error('');}catch(e){API_Meta.PinTool.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.PinTool.offset);}} +{ try { throw new Error(''); } catch (e) { API_Meta.PinTool.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.PinTool.offset); } } diff --git a/PinTool/readme.md b/PinTool/readme.md index 55c99f8ab..69b238c9d 100644 --- a/PinTool/readme.md +++ b/PinTool/readme.md @@ -12,7 +12,10 @@ PinTool is a GM-only Roll20 API script for creating, inspecting, converting, and - Automatic placement of map pins from handout headers (player and GM) - Optional chat display of images referenced in notes -**Base Command:** `!pintool` +**Base Command:** `!pintool` opens a control panel for commonly used editing controls. Add priaru commands afterward to access specific functions. + +`!pintool --help` creates a handout with full documentation + --- diff --git a/PinTool/script.json b/PinTool/script.json index 65d779fcf..88614b3e4 100644 --- a/PinTool/script.json +++ b/PinTool/script.json @@ -1,8 +1,8 @@ { "name": "PinTool", "script": "PinTool.js", - "version": "1.0.1", - "description": "# PinTool\n\nPinTool is a GM-only Roll20 API script for creating, inspecting, converting, and managing **map pins** at scale. It can convert older token-based note workflows with Roll20’s newer map pin system, allowing structured handouts and pins to stay in sync.\n\n---\n\n## Core Capabilities\n\n- Bulk modification of map pin properties\n- Precise targeting of selected pins, all pins on a page, or explicit pin IDs\n- Conversion of legacy note tokens into structured handouts\n- Automatic placement of map pins from handout headers (player and GM)\n- Optional chat display of images referenced in notes\n\n**Base Command:** `!pintool`\n\n---\n\n## Primary Commands\n\n```\n!pintool --set\n!pintool --convert\n!pintool --place\n!pintool --purge\n!pintool --help\n```\n\n- `--set` updates one or more properties across many pins at once.\n- `--convert` extracts data from tokens representing the same character and builds or updates a handout.\n- `--place` scans a handout for headers and creates or replaces pins linked directly to those sections.\n- `--purge` removes related tokens or pins in bulk.\n\n---\n\n## Highlights\n\n- Pins created via `--place` link directly to specific headers in Notes or GM Notes.\n- Existing pins are replaced in-place, preserving their positions.\n- Conversion supports header levels, blockquotes, code blocks, and inline image links.\n- Visibility, scale, links, and sync state can all be controlled programmatically.\n\nDesigned for GMs who want more automated control over pin placement and management.", + "version": "1.0.2", + "description": "# PinTool\n\nPinTool is a GM-only Roll20 API script for creating, inspecting, converting, and managing **map pins** at scale. It can convert older token-based note workflows with Roll20’s newer map pin system, allowing structured handouts and pins to stay in sync.\n\n---\n\n## Core Capabilities\n\n- Bulk modification of map pin properties\n- Precise targeting of selected pins, all pins on a page, or explicit pin IDs\n- Conversion of legacy note tokens into structured handouts\n- Automatic placement of map pins from handout headers (player and GM)\n- Optional chat display of images referenced in notes\n\n**Base Command:** `!pintool`\n\n---\n\n## Primary Commands\n\n```\n!pintool --set\n!pintool --convert\n!pintool --place\n!pintool --purge\n!pintool --help\n```\n\n- `--set` updates one or more properties across many pins at once.\n- `--convert` extracts data from tokens representing the same character and builds or updates a handout.\n- `--place` scans a handout for headers and creates or replaces pins linked directly to those sections.\n- `--purge` removes related tokens or pins in bulk.\n\n---\n\n## Highlights\n\n- Pins created via `--place` link directly to specific headers in Notes or GM Notes.\n- Existing pins are replaced in-place, preserving their positions.\n- Conversion supports header levels, blockquotes, code blocks, and inline image links.\n- Visibility, scale, links, and sync state can all be controlled programmatically.\n\nDesigned for GMs who want more automated control over pin placement and management.\n\nType **!pintool** in chat for a handy control panel.", "authors": "Keith Curtis", "roll20userid": "162065", "dependencies": [], @@ -11,5 +11,5 @@ "pin": "write" }, "conflicts": [], - "previousversions": ["1.0.0"] + "previousversions": ["1.0.0","1.0.1"] } \ No newline at end of file diff --git a/TokenHome/1.0.0/TokenHome.js b/TokenHome/1.0.0/TokenHome.js new file mode 100644 index 000000000..0f27ae4bd --- /dev/null +++ b/TokenHome/1.0.0/TokenHome.js @@ -0,0 +1,546 @@ +// Script: TokenHome +// By: Keith Curtis, based on a script by the Aaron +// Contact: https://app.roll20.net/users/162065/keithcurtis +var API_Meta = API_Meta || {}; //eslint-disable-line no-var +API_Meta.TokenHome = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; +{ try { throw new Error(''); } catch (e) { API_Meta.TokenHome.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - 6); } } + +on('ready', () => { + + /************************* + * CONFIG + *************************/ + const STORAGE_ATTR = 'gmnotes'; + const DEFAULT_LOC = 'L1'; + const VALID_LAYERS = ['objects', 'map', 'gmlayer', 'walls']; + const DEFAULT_RADIUS = 300; + + /************************* + * REGEX + *************************/ + const HOME_BLOCK_REGEX = + /
    \s*TOKENHOME([\s\S]*?)<\/div>/i; + + const HOME_LINE_REGEX = + /^\s*(L\d+)\s*:\s*(-?\d+(?:\.\d*)?)\s*,\s*(-?\d+(?:\.\d*)?)\s*,\s*(\w+)\s*$/gim; + + const LEGACY_BLOCK_REGEX = + /
    ]*data-tokenhomes\s*=\s*"(?:true|1)"[^>]*>([\s\S]*?)<\/div>/i; + + + /************************* + * Help System + *************************/ + + const HOME_HELP_NAME = "Help: Token Home"; +const HOME_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; + +const HOME_HELP_TEXT = ` +

    Token Home Script Help

    + +

    +The Token Home script allows tokens to store and recall multiple +named locations on the current page. +Each location records an X/Y position and the token’s layer. +

    + +

    +Tokens can be sent back to saved locations, queried, or summoned to a selected +anchor point based on proximity. +

    + +
      +
    • Store multiple locations per token (L1, L2, L3, …)
    • +
    • Recall tokens to stored locations
    • +
    • Preserve token layer when moving
    • +
    • Summon tokens to a selected map object based on distance
    • +
    • Compatible with tokens placed outside page bounds
    • +
    + +

    Base Command: !home

    + +
    + +

    Primary Commands

    + +
      +
    • --set — Store the selected token’s current position as a location.
    • +
    • --lN — Recall the selected token to a stored location.
    • +
    • --summon — Pull tokens to a selected anchor based on proximity.
    • +
    • --clear — Remove stored location data from selected tokens.
    • +
    • --help — Open this help handout.
    • +
    + +
    + +

    Location Storage

    + +

    +Locations are identified by numbered slots: +L1, L2, L3, and higher. +There is no fixed upper limit. +

    + +
      +
    • L1 — Typically used as the token’s default location
    • +
    • L2 — Commonly used for Residence
    • +
    • L3 — Commonly used for Work
    • +
    • L4 — Commonly used for Encounter
    • +
    + +

    +Each stored location records: +

    + +
      +
    • X position (pixels)
    • +
    • Y position (pixels)
    • +
    • Token layer
    • +
    + +
    + +

    Set Command

    + +

    Format:

    +
    +!home --set --lN
    +
    + +

    +Stores the selected token’s current position and layer into location L N. +

    + +

    Rules

    + +
      +
    • Exactly one token must be selected
    • +
    • Existing data for that location is overwritten
    • +
    • Page ID is not stored
    • +
    + +

    Examples

    + +
      +
    • !home --set --l1 — Set default location
    • +
    • !home --set --l2 — Set residence
    • +
    • !home --set --l5 — Set custom location
    • +
    + +
    + +

    Recall Command

    + +

    Format:

    +
    +!home --lN
    +
    + +

    +Moves the selected token to the stored location L N. +

    + +

    Rules

    + +
      +
    • Exactly one token must be selected
    • +
    • If the location does not exist, the command aborts
    • +
    • The token’s layer is restored
    • +
    + +

    Examples

    + +
      +
    • !home --l1
    • +
    • !home --l3
    • +
    + +
    + +

    Summon Command

    + +

    +The summon command pulls tokens toward a selected anchor object +based on proximity to their stored locations. +

    + +

    Format:

    +
    +!home --summon [--lN] [--r pixels or grid squares]
    +
    +

    +if no value is given, then pixels are assumed. Use 'g' for grid squares.

    --r300
    = 300 pixels,
    --r5g
    = 5 grid squares. +

    + +

    Anchor Selection

    + +

    +Exactly one object must be selected: +

    + +
      +
    • Token (graphic)
    • +
    • Text object (text)
    • +
    • Map pin (pin)
    • +
    + +

    +The selected object’s X/Y position is used as the summon target. +

    + +

    Optional Arguments

    + +
      +
    • + --lN
      + Restrict the summon to a specific stored location. +
    • +
    • + --r pixels
      + Maximum distance from the anchor. + Default: 70. +
    • +
    + +

    Behavior

    + +
      +
    • If --lN is supplied, only that location is tested
    • +
    • If omitted, all stored locations are considered
    • +
    • The closest matching location is used per token
    • +
    • Distance is measured from the stored location, not current token position
    • +
    • Tokens outside the radius are ignored
    • +
    + +

    Examples

    + +
      +
    • !home --summon
    • +
    • !home --summon --r 210
    • +
    • !home --summon --l2
    • +
    • !home --summon --l4 --r 140
    • +
    + +
    + +

    Clear Command

    + +

    Format:

    +
    +!home --clear [--lN]
    +
    + +
      +
    • If --lN is supplied, only that location is removed
    • +
    • If omitted, all stored locations are removed
    • +
    + +
    + +

    General Rules

    + +
      +
    • All commands are GM-only
    • +
    • Commands operate only on the current page
    • +
    • Tokens may be placed outside page bounds
    • +
    • Invalid arguments abort the command
    • +
    +`; + + /************************* + * HELP HANDOUT + *************************/ +const showHomeHelp = () => { + let handout = findObjs({ + type: 'handout', + name: HOME_HELP_NAME + })[0]; + + if (!handout) { + handout = createObj('handout', { + name: HOME_HELP_NAME, + avatar: HOME_HELP_AVATAR, + notes: HOME_HELP_TEXT, + inplayerjournals: 'gm', + controlledby: 'gm' + }); + } else { + // Ensure content stays current + handout.set({ + avatar: HOME_HELP_AVATAR, + notes: HOME_HELP_TEXT + }); + } + +sendChat( + 'TokenHome', `/w gm
    Token Home Help
    ${HOME_HELP_NAME}
    ` +); + +}; + + + + + /************************* + * LOW-LEVEL HELPERS + *************************/ + const readNotes = (token) => + unescape(token.get(STORAGE_ATTR) || ''); + + const writeNotes = (token, text) => + token.set(STORAGE_ATTR, escape(text)); + + const distance = (a, b) => + Math.hypot(a.left - b.left, a.top - b.top); + + /************************* + * STORAGE + *************************/ + const getHomes = (token) => { + let notes = readNotes(token); + + // 🔁 Auto-upgrade legacy once, silently + if (!HOME_BLOCK_REGEX.test(notes) && LEGACY_BLOCK_REGEX.test(notes)) { + convertLegacyHomes(token); + notes = readNotes(token); + } + + const match = notes.match(HOME_BLOCK_REGEX); + const homes = {}; + if (!match) return homes; + + HOME_LINE_REGEX.lastIndex = 0; + let m; + while ((m = HOME_LINE_REGEX.exec(match[1])) !== null) { + const [, loc, left, top, layer] = m; + homes[loc.toUpperCase()] = { + left: Number(left), + top: Number(top), + layer: VALID_LAYERS.includes(layer) ? layer : 'objects' + }; + } + return homes; + }; + + const saveHomes = (token, homes) => { + let notes = readNotes(token).replace(HOME_BLOCK_REGEX, ''); + + const lines = Object.entries(homes) + .map(([loc, h]) => `${loc}:${h.left},${h.top},${h.layer}`) + .join('\n'); + + if (!lines.trim()) { + writeNotes(token, notes); + return; + } + + const block = +`
    +TOKENHOME +${lines} +
    `; + + writeNotes(token, notes + block); + }; + + const setHome = (token, loc) => { + const homes = getHomes(token); + homes[loc] = { + left: token.get('left'), + top: token.get('top'), + layer: VALID_LAYERS.includes(token.get('layer')) + ? token.get('layer') + : 'objects' + }; + saveHomes(token, homes); + }; + + const clearHome = (token, loc) => { + const homes = getHomes(token); + if (loc) delete homes[loc]; + else Object.keys(homes).forEach(k => delete homes[k]); + saveHomes(token, homes); + }; + + /************************* + * LEGACY CONVERSION + *************************/ + const convertLegacyHomes = (token) => { + const notes = readNotes(token); + if (HOME_BLOCK_REGEX.test(notes)) return { skipped: true }; + + const legacyMatch = notes.match(LEGACY_BLOCK_REGEX); + if (!legacyMatch) return { skipped: true }; + + let raw = legacyMatch[1]; + try { + if (/%[0-9A-Fa-f]{2}/.test(raw)) raw = decodeURIComponent(raw); + const legacy = JSON.parse(raw); + + const homes = {}; + Object.keys(legacy).forEach(k => { + const h = legacy[k]; + if (typeof h.left !== 'number' || typeof h.top !== 'number') return; + const loc = /^L\d+$/i.test(k) ? k.toUpperCase() : 'L1'; + homes[loc] = { + left: h.left, + top: h.top, + layer: VALID_LAYERS.includes(h.layer) + ? h.layer + : token.get('layer') + }; + }); + + writeNotes(token, notes.replace(LEGACY_BLOCK_REGEX, '')); + saveHomes(token, homes); + return { converted: true }; + } catch { + return { failed: true }; + } + }; + + /************************* + * ANCHORS + *************************/ + const getAnchorFromSelection = (sel) => { + if (!sel || sel.length !== 1) return null; + const o = sel[0]; + const obj = getObj(o._type, o._id); + if (!obj) return null; + + if (o._type === 'graphic' || o._type === 'text') + return { left: obj.get('left'), top: obj.get('top'), pageid: obj.get('pageid') }; + + if (o._type === 'pin') + return { left: obj.get('x'), top: obj.get('y'), pageid: obj.get('pageid') }; + + if (o._type === 'path') { + const pts = JSON.parse(obj.get('path')); + const a = pts[0], b = pts[pts.length - 1]; + return { + left: (a[1] + b[1]) / 2 + obj.get('left'), + top: (a[2] + b[2]) / 2 + obj.get('top'), + pageid: obj.get('pageid') + }; + } + return null; + }; + + const findClosestHome = (homes, anchor, limitLoc) => { + let best = null; + Object.entries(homes).forEach(([loc, h]) => { + if (limitLoc && loc !== limitLoc) return; + const d = distance(h, anchor); + if (!best || d < best.dist) best = { home: h, dist: d }; + }); + return best; + }; + + /************************* + * CHAT HANDLER + *************************/ + on('chat:message', (msg) => { + if (msg.type !== 'api' || !/^!home\b/i.test(msg.content)) return; + if (!playerIsGM(msg.playerid)) return; + + const args = msg.content.split(/\s+--/).slice(1); + const flags = args.map(a => a.toLowerCase()); + + let location = null; + flags.forEach(f => { if (/^l\d+$/.test(f)) location = f.toUpperCase(); }); + + let mode = 'recall'; + if (flags.includes('set')) mode = 'set'; + else if (flags.includes('all')) mode = 'all'; + else if (flags.includes('summon')) mode = 'summon'; + else if (flags.includes('convert')) mode = 'convert'; + else if (flags.includes('clear')) mode = 'clear'; + else if (flags.includes('help')) mode = 'help'; + + let radius = DEFAULT_RADIUS; + flags.forEach(f => { + if (f.startsWith('radius|')) { + const v = f.split('|')[1]; + if (v.endsWith('g')) { + radius = Number(v.slice(0, -1)) * 70; + } else { + radius = Number(v); + } + } + }); + +if (mode === 'help') { + showHomeHelp(); + return; +} + + + let targets = []; + const byName = args.find(a => a.startsWith('by-name ')); + if (byName) { + const name = byName.slice(8); + targets = findObjs({ type: 'graphic' }) + .filter(t => (t.get('name') || '').toLowerCase().includes(name)); + } else { + targets = (msg.selected || []) + .map(o => getObj('graphic', o._id)) + .filter(Boolean); + } + + if (mode === 'convert') { + let c = 0, s = 0; + targets.forEach(t => { + const r = convertLegacyHomes(t); + if (r?.converted) c++; else s++; + }); + sendChat('TokenHome', `/w gm Converted: ${c}, Skipped: ${s}`); + return; + } + + if (mode === 'clear') { + targets.forEach(t => clearHome(t, location)); + return; + } + + if (mode === 'set') { + targets.forEach(t => setHome(t, location || DEFAULT_LOC)); + return; + } + +if (mode === 'summon') { + const anchor = getAnchorFromSelection(msg.selected); + if (!anchor) return; + + const pageid = anchor.pageid; + + findObjs({ type: 'graphic', pageid }).forEach(t => { + const homes = getHomes(t); + const closest = findClosestHome(homes, anchor, location); + if (!closest) return; + + if (closest.dist <= radius) { + t.set({ + left: closest.home.left, + top: closest.home.top, + layer: closest.home.layer + }); + } + }); + return; +} + + // default recall + targets.forEach(t => { + const h = getHomes(t)[location || DEFAULT_LOC]; + if (!h) return; + t.set({ + left: h.left, + top: h.top, + layer: h.layer + }); + }); + }); +}); + + +{ try { throw new Error(''); } catch (e) { API_Meta.TokenHome.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.TokenHome.offset); } } diff --git a/TokenHome/TokenHome.js b/TokenHome/TokenHome.js new file mode 100644 index 000000000..0f27ae4bd --- /dev/null +++ b/TokenHome/TokenHome.js @@ -0,0 +1,546 @@ +// Script: TokenHome +// By: Keith Curtis, based on a script by the Aaron +// Contact: https://app.roll20.net/users/162065/keithcurtis +var API_Meta = API_Meta || {}; //eslint-disable-line no-var +API_Meta.TokenHome = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; +{ try { throw new Error(''); } catch (e) { API_Meta.TokenHome.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - 6); } } + +on('ready', () => { + + /************************* + * CONFIG + *************************/ + const STORAGE_ATTR = 'gmnotes'; + const DEFAULT_LOC = 'L1'; + const VALID_LAYERS = ['objects', 'map', 'gmlayer', 'walls']; + const DEFAULT_RADIUS = 300; + + /************************* + * REGEX + *************************/ + const HOME_BLOCK_REGEX = + /
    \s*TOKENHOME([\s\S]*?)<\/div>/i; + + const HOME_LINE_REGEX = + /^\s*(L\d+)\s*:\s*(-?\d+(?:\.\d*)?)\s*,\s*(-?\d+(?:\.\d*)?)\s*,\s*(\w+)\s*$/gim; + + const LEGACY_BLOCK_REGEX = + /
    ]*data-tokenhomes\s*=\s*"(?:true|1)"[^>]*>([\s\S]*?)<\/div>/i; + + + /************************* + * Help System + *************************/ + + const HOME_HELP_NAME = "Help: Token Home"; +const HOME_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; + +const HOME_HELP_TEXT = ` +

    Token Home Script Help

    + +

    +The Token Home script allows tokens to store and recall multiple +named locations on the current page. +Each location records an X/Y position and the token’s layer. +

    + +

    +Tokens can be sent back to saved locations, queried, or summoned to a selected +anchor point based on proximity. +

    + +
      +
    • Store multiple locations per token (L1, L2, L3, …)
    • +
    • Recall tokens to stored locations
    • +
    • Preserve token layer when moving
    • +
    • Summon tokens to a selected map object based on distance
    • +
    • Compatible with tokens placed outside page bounds
    • +
    + +

    Base Command: !home

    + +
    + +

    Primary Commands

    + +
      +
    • --set — Store the selected token’s current position as a location.
    • +
    • --lN — Recall the selected token to a stored location.
    • +
    • --summon — Pull tokens to a selected anchor based on proximity.
    • +
    • --clear — Remove stored location data from selected tokens.
    • +
    • --help — Open this help handout.
    • +
    + +
    + +

    Location Storage

    + +

    +Locations are identified by numbered slots: +L1, L2, L3, and higher. +There is no fixed upper limit. +

    + +
      +
    • L1 — Typically used as the token’s default location
    • +
    • L2 — Commonly used for Residence
    • +
    • L3 — Commonly used for Work
    • +
    • L4 — Commonly used for Encounter
    • +
    + +

    +Each stored location records: +

    + +
      +
    • X position (pixels)
    • +
    • Y position (pixels)
    • +
    • Token layer
    • +
    + +
    + +

    Set Command

    + +

    Format:

    +
    +!home --set --lN
    +
    + +

    +Stores the selected token’s current position and layer into location L N. +

    + +

    Rules

    + +
      +
    • Exactly one token must be selected
    • +
    • Existing data for that location is overwritten
    • +
    • Page ID is not stored
    • +
    + +

    Examples

    + +
      +
    • !home --set --l1 — Set default location
    • +
    • !home --set --l2 — Set residence
    • +
    • !home --set --l5 — Set custom location
    • +
    + +
    + +

    Recall Command

    + +

    Format:

    +
    +!home --lN
    +
    + +

    +Moves the selected token to the stored location L N. +

    + +

    Rules

    + +
      +
    • Exactly one token must be selected
    • +
    • If the location does not exist, the command aborts
    • +
    • The token’s layer is restored
    • +
    + +

    Examples

    + +
      +
    • !home --l1
    • +
    • !home --l3
    • +
    + +
    + +

    Summon Command

    + +

    +The summon command pulls tokens toward a selected anchor object +based on proximity to their stored locations. +

    + +

    Format:

    +
    +!home --summon [--lN] [--r pixels or grid squares]
    +
    +

    +if no value is given, then pixels are assumed. Use 'g' for grid squares.

    --r300
    = 300 pixels,
    --r5g
    = 5 grid squares. +

    + +

    Anchor Selection

    + +

    +Exactly one object must be selected: +

    + +
      +
    • Token (graphic)
    • +
    • Text object (text)
    • +
    • Map pin (pin)
    • +
    + +

    +The selected object’s X/Y position is used as the summon target. +

    + +

    Optional Arguments

    + +
      +
    • + --lN
      + Restrict the summon to a specific stored location. +
    • +
    • + --r pixels
      + Maximum distance from the anchor. + Default: 70. +
    • +
    + +

    Behavior

    + +
      +
    • If --lN is supplied, only that location is tested
    • +
    • If omitted, all stored locations are considered
    • +
    • The closest matching location is used per token
    • +
    • Distance is measured from the stored location, not current token position
    • +
    • Tokens outside the radius are ignored
    • +
    + +

    Examples

    + +
      +
    • !home --summon
    • +
    • !home --summon --r 210
    • +
    • !home --summon --l2
    • +
    • !home --summon --l4 --r 140
    • +
    + +
    + +

    Clear Command

    + +

    Format:

    +
    +!home --clear [--lN]
    +
    + +
      +
    • If --lN is supplied, only that location is removed
    • +
    • If omitted, all stored locations are removed
    • +
    + +
    + +

    General Rules

    + +
      +
    • All commands are GM-only
    • +
    • Commands operate only on the current page
    • +
    • Tokens may be placed outside page bounds
    • +
    • Invalid arguments abort the command
    • +
    +`; + + /************************* + * HELP HANDOUT + *************************/ +const showHomeHelp = () => { + let handout = findObjs({ + type: 'handout', + name: HOME_HELP_NAME + })[0]; + + if (!handout) { + handout = createObj('handout', { + name: HOME_HELP_NAME, + avatar: HOME_HELP_AVATAR, + notes: HOME_HELP_TEXT, + inplayerjournals: 'gm', + controlledby: 'gm' + }); + } else { + // Ensure content stays current + handout.set({ + avatar: HOME_HELP_AVATAR, + notes: HOME_HELP_TEXT + }); + } + +sendChat( + 'TokenHome', `/w gm
    Token Home Help
    ${HOME_HELP_NAME}
    ` +); + +}; + + + + + /************************* + * LOW-LEVEL HELPERS + *************************/ + const readNotes = (token) => + unescape(token.get(STORAGE_ATTR) || ''); + + const writeNotes = (token, text) => + token.set(STORAGE_ATTR, escape(text)); + + const distance = (a, b) => + Math.hypot(a.left - b.left, a.top - b.top); + + /************************* + * STORAGE + *************************/ + const getHomes = (token) => { + let notes = readNotes(token); + + // 🔁 Auto-upgrade legacy once, silently + if (!HOME_BLOCK_REGEX.test(notes) && LEGACY_BLOCK_REGEX.test(notes)) { + convertLegacyHomes(token); + notes = readNotes(token); + } + + const match = notes.match(HOME_BLOCK_REGEX); + const homes = {}; + if (!match) return homes; + + HOME_LINE_REGEX.lastIndex = 0; + let m; + while ((m = HOME_LINE_REGEX.exec(match[1])) !== null) { + const [, loc, left, top, layer] = m; + homes[loc.toUpperCase()] = { + left: Number(left), + top: Number(top), + layer: VALID_LAYERS.includes(layer) ? layer : 'objects' + }; + } + return homes; + }; + + const saveHomes = (token, homes) => { + let notes = readNotes(token).replace(HOME_BLOCK_REGEX, ''); + + const lines = Object.entries(homes) + .map(([loc, h]) => `${loc}:${h.left},${h.top},${h.layer}`) + .join('\n'); + + if (!lines.trim()) { + writeNotes(token, notes); + return; + } + + const block = +`
    +TOKENHOME +${lines} +
    `; + + writeNotes(token, notes + block); + }; + + const setHome = (token, loc) => { + const homes = getHomes(token); + homes[loc] = { + left: token.get('left'), + top: token.get('top'), + layer: VALID_LAYERS.includes(token.get('layer')) + ? token.get('layer') + : 'objects' + }; + saveHomes(token, homes); + }; + + const clearHome = (token, loc) => { + const homes = getHomes(token); + if (loc) delete homes[loc]; + else Object.keys(homes).forEach(k => delete homes[k]); + saveHomes(token, homes); + }; + + /************************* + * LEGACY CONVERSION + *************************/ + const convertLegacyHomes = (token) => { + const notes = readNotes(token); + if (HOME_BLOCK_REGEX.test(notes)) return { skipped: true }; + + const legacyMatch = notes.match(LEGACY_BLOCK_REGEX); + if (!legacyMatch) return { skipped: true }; + + let raw = legacyMatch[1]; + try { + if (/%[0-9A-Fa-f]{2}/.test(raw)) raw = decodeURIComponent(raw); + const legacy = JSON.parse(raw); + + const homes = {}; + Object.keys(legacy).forEach(k => { + const h = legacy[k]; + if (typeof h.left !== 'number' || typeof h.top !== 'number') return; + const loc = /^L\d+$/i.test(k) ? k.toUpperCase() : 'L1'; + homes[loc] = { + left: h.left, + top: h.top, + layer: VALID_LAYERS.includes(h.layer) + ? h.layer + : token.get('layer') + }; + }); + + writeNotes(token, notes.replace(LEGACY_BLOCK_REGEX, '')); + saveHomes(token, homes); + return { converted: true }; + } catch { + return { failed: true }; + } + }; + + /************************* + * ANCHORS + *************************/ + const getAnchorFromSelection = (sel) => { + if (!sel || sel.length !== 1) return null; + const o = sel[0]; + const obj = getObj(o._type, o._id); + if (!obj) return null; + + if (o._type === 'graphic' || o._type === 'text') + return { left: obj.get('left'), top: obj.get('top'), pageid: obj.get('pageid') }; + + if (o._type === 'pin') + return { left: obj.get('x'), top: obj.get('y'), pageid: obj.get('pageid') }; + + if (o._type === 'path') { + const pts = JSON.parse(obj.get('path')); + const a = pts[0], b = pts[pts.length - 1]; + return { + left: (a[1] + b[1]) / 2 + obj.get('left'), + top: (a[2] + b[2]) / 2 + obj.get('top'), + pageid: obj.get('pageid') + }; + } + return null; + }; + + const findClosestHome = (homes, anchor, limitLoc) => { + let best = null; + Object.entries(homes).forEach(([loc, h]) => { + if (limitLoc && loc !== limitLoc) return; + const d = distance(h, anchor); + if (!best || d < best.dist) best = { home: h, dist: d }; + }); + return best; + }; + + /************************* + * CHAT HANDLER + *************************/ + on('chat:message', (msg) => { + if (msg.type !== 'api' || !/^!home\b/i.test(msg.content)) return; + if (!playerIsGM(msg.playerid)) return; + + const args = msg.content.split(/\s+--/).slice(1); + const flags = args.map(a => a.toLowerCase()); + + let location = null; + flags.forEach(f => { if (/^l\d+$/.test(f)) location = f.toUpperCase(); }); + + let mode = 'recall'; + if (flags.includes('set')) mode = 'set'; + else if (flags.includes('all')) mode = 'all'; + else if (flags.includes('summon')) mode = 'summon'; + else if (flags.includes('convert')) mode = 'convert'; + else if (flags.includes('clear')) mode = 'clear'; + else if (flags.includes('help')) mode = 'help'; + + let radius = DEFAULT_RADIUS; + flags.forEach(f => { + if (f.startsWith('radius|')) { + const v = f.split('|')[1]; + if (v.endsWith('g')) { + radius = Number(v.slice(0, -1)) * 70; + } else { + radius = Number(v); + } + } + }); + +if (mode === 'help') { + showHomeHelp(); + return; +} + + + let targets = []; + const byName = args.find(a => a.startsWith('by-name ')); + if (byName) { + const name = byName.slice(8); + targets = findObjs({ type: 'graphic' }) + .filter(t => (t.get('name') || '').toLowerCase().includes(name)); + } else { + targets = (msg.selected || []) + .map(o => getObj('graphic', o._id)) + .filter(Boolean); + } + + if (mode === 'convert') { + let c = 0, s = 0; + targets.forEach(t => { + const r = convertLegacyHomes(t); + if (r?.converted) c++; else s++; + }); + sendChat('TokenHome', `/w gm Converted: ${c}, Skipped: ${s}`); + return; + } + + if (mode === 'clear') { + targets.forEach(t => clearHome(t, location)); + return; + } + + if (mode === 'set') { + targets.forEach(t => setHome(t, location || DEFAULT_LOC)); + return; + } + +if (mode === 'summon') { + const anchor = getAnchorFromSelection(msg.selected); + if (!anchor) return; + + const pageid = anchor.pageid; + + findObjs({ type: 'graphic', pageid }).forEach(t => { + const homes = getHomes(t); + const closest = findClosestHome(homes, anchor, location); + if (!closest) return; + + if (closest.dist <= radius) { + t.set({ + left: closest.home.left, + top: closest.home.top, + layer: closest.home.layer + }); + } + }); + return; +} + + // default recall + targets.forEach(t => { + const h = getHomes(t)[location || DEFAULT_LOC]; + if (!h) return; + t.set({ + left: h.left, + top: h.top, + layer: h.layer + }); + }); + }); +}); + + +{ try { throw new Error(''); } catch (e) { API_Meta.TokenHome.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.TokenHome.offset); } } diff --git a/TokenHome/readme.md b/TokenHome/readme.md new file mode 100644 index 000000000..a6815e83a --- /dev/null +++ b/TokenHome/readme.md @@ -0,0 +1,162 @@ +# Token Home Script Help + +The **Token Home** script allows tokens to store and recall multiple +named locations on the current page. +Each location records an X/Y position and the token’s layer. + +Tokens can be sent back to saved locations, queried, or summoned to a selected +anchor point based on proximity. + +- Store multiple locations per token (L1, L2, L3, …) +- Recall tokens to stored locations +- Preserve token layer when moving +- Summon tokens to a selected map object based on distance +- Compatible with tokens placed outside page bounds + +**Base Command:** `!home` +**In-game Help Handout:** `!home --help` + +--- + +## Primary Commands + +- `--set` — Store the selected token’s current position as a location. +- `--L#` — Recall the selected token to a stored location. +- `--summon` — Pull tokens to a selected anchor based on proximity. +- `--clear` — Remove stored location data from selected tokens. +- `--help` — Open this help handout. + +--- + +## Location Storage + +Locations are identified by numbered slots: +`L1`, `L2`, `L3`, and higher. +There is no fixed upper limit. + +- **L1** — Typically used as the token’s default location +- **L2** — Commonly used for Residence +- **L3** — Commonly used for Work +- **L4** — Commonly used for Encounter + +Each stored location records: + +- X position (pixels) +- Y position (pixels) +- Token layer + +--- + +## Set Command + +**Format:** +``` +!home --set --L# +``` + +Stores the selected token’s current position and layer into location `L N`. + +### Rules + +- Exactly one token must be selected +- Existing data for that location is overwritten +- Page ID is not stored + +### Examples + +- `!home --set --l1` — Set default location +- `!home --set --l2` — Set residence +- `!home --set --l5` — Set custom location + +--- + +## Recall Command + +**Format:** +``` +!home --L# +``` + +Moves the selected token to the stored location `L N`. + +### Rules + +- Exactly one token must be selected +- If the location does not exist, the command aborts +- The token’s layer is restored + +### Examples + +- `!home --l1` +- `!home --l3` + +--- + +## Summon Command + +The **summon** command pulls tokens toward a selected anchor object +based on proximity to their stored locations. + +**Format:** +``` +!home --summon [--L#] [--r pixels] +``` + +### Anchor Selection + +Exactly one object of any of the following types must be selected: + +- Token +- Text object +- Map pin +- Door +- Window + +The selected object’s X/Y position is used as the summon target. + +### Optional Arguments + +- `--L#` + Restrict the summon to a specific stored location. + +- `--r|pixels` + Maximum distance from the anchor. + Default: `300`. + Alternatively, the radius may be expressed in grid squares: + Default: `5g`. + +### Behavior + +- If `--L#` is supplied, only that location is tested +- If omitted, all stored locations are considered +- The closest matching location is used per token +- Distance is measured from the stored location, not current token position +- Tokens outside the radius are ignored + +### Examples + +- `!home --summon` +- `!home --summon --radius|210` +- `!home --summon --l2` +- `!home --summon --l4 --radius|140` + +--- + +## Clear Command + +**Format:** +``` +!home --clear [--L#] +``` + +- If `--L#` is supplied, only that location is removed +- If omitted, all stored locations are removed + +--- + +## General Rules + +- All commands are GM-only +- Commands operate only on the current page +- Tokens may be placed outside page bounds +- Invalid arguments abort the command diff --git a/TokenHome/script.json b/TokenHome/script.json new file mode 100644 index 000000000..b80695f22 --- /dev/null +++ b/TokenHome/script.json @@ -0,0 +1,14 @@ +{ + "name": "TokenHome", + "script": "TokenHome.js", + "version": "1.0.0", + "description": "# TokenHome\n\nTokenHome is a GM-only Roll20 API script that allows tokens to store, recall, and manage multiple named \"home\" locations on the current page. Each location records pixel-precise X/Y coordinates and the token’s layer, enabling reliable repositioning, staging, and summoning workflows.\n\n---\n\n## Core Capabilities\n\n- Store multiple locations per token (L1, L2, L3, …)\n- Recall tokens to saved locations with layer restoration\n- Summon tokens toward a selected anchor based on proximity\n- Preserve compatibility with tokens outside page bounds\n- Automatically migrate legacy single-home token data\n- Clear individual locations or all stored data per token\n\n**Base Command:** `!home`\n\n---\n\n## Primary Commands\n\n```\n!home --set --L#\n!home --L#\n!home --summon [--L#] [--radius|#]\n!home --clear [--L#]\n!home --help\n```\n\n- `--set` stores the selected token’s current position and layer.\n- `--L#` recalls a token to a stored location.\n- `--summon` pulls tokens toward a selected anchor based on distance.\n- `--clear` removes stored location data from selected tokens.\n- `--help` opens the script’s help handout.\n\n---\n\n## Highlights\n\n- Unlimited numbered locations per token (case-insensitive).\n- Distance-based summoning can target a specific location or choose the closest.\n- Radius supports both pixel values and grid units.\n- Layer is restored on recall and summon.\n- Storage is embedded safely in GM Notes using a hidden JSON block.\n\nDesigned for GMs who want fast, reliable control over token positioning, staging, and recall without page locking or teleport hacks.", + "authors": "Keith Curtis, based on a Script by the Aaron", + "roll20userid": "162065", + "dependencies": [], + "modifies": { + "graphic": "write" + }, + "conflicts": [], + "previousversions": ["1.0.0"] +}